diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index e7f8dc89..1a03e31a 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,5 +1,389 @@
-Search API 1.x, dev (xx/xx/xxxx):
+Search API 1.x, dev (xxxx-xx-xx):
---------------------------------
+- #1783746 by das-peter, sammys, SpadXIII, drunken monkey, ruloweb, KarlShea,
+ heshanlk, Anas_maw, pinkonomy, Damien Tournoud, rudiedirkx: Added support
+ for the "(not) between" operator.
+- #2408727 by drunken monkey, OliverColeman: Fixed out-of-memory errors when
+ executing pending tasks.
+- Issue #2948820 by capysara, drunken monkey: Added a link to the "need to
+ reindex" message on the Filters tab.
+- #2828883 by JorgenSandstrom, drunken monkey: Fixed property type for
+ string-typed aggregated fields.
+- #2949899 by drunken monkey, DamienMcKenna: Added a warning against using
+ particular processors with Solr servers to the "Workflow" tab.
+
+Search API 1.24 (2018-04-05):
+-----------------------------
+- #2958201 by jcnventura, drunken monkey: Reverted issue #2566529: Added
+ support for the "Content access" processor for "Multiple types" indexes.
+
+Search API 1.23 (2018-03-31):
+-----------------------------
+- #2949562 by DamienMcKenna, drunken monkey: Fixed stemming of multi-word
+ tokens.
+- #1903004 by AndyF, joseph.olstad, drunken monkey: Fixed errors at feature
+ module installation in certain edge cases.
+- #2889989 by kevineinarsson, drunken monkey, kristofferwiklund: Fixed
+ highlighting for text with multi-byte characters.
+- #1393064 by xlyz, drunken monkey, jannis: Fixed handling of empty facet
+ filters.
+- #2927692 by drunken monkey, Kristi Wachter: Fixed exposed grouped Views
+ options filters.
+- #2928769 by jannis, drunken monkey: Fixed Views cache not being cleared when
+ enabling indexes.
+- #2566529 by Dylan Donkersgoed, drunken monkey, joachim, swirt: Added support
+ for the "Content access" processor for "Multiple types" indexes.
+- #2905445 by ciss, drunken monkey: Fixed error handling in Views term filter
+ handler.
+- #2904268 by pobster, drunken monkey: Added support for language hierarchy in
+ Views.
+
+Search API 1.22 (2017-07-18):
+-----------------------------
+- #1710212 by drunken monkey: Added a data alteration for indexing a user's
+ content.
+- #2879892 by blacklabel_tom, drunken monkey: Fixed link in description of
+ "Stemmer" processor.
+- #2788593 by drunken monkey: Fixed error in Views query settings for specific
+ setups.
+- #2749963 by drunken monkey: Fixed "Index hierarchy" not having values
+ numerically indexed.
+- #2875793 by drunken monkey: Fixed buggy error handling in Views.
+- #2860624 by drunken monkey: Fixed problem with empty words in Views fulltext
+ filter.
+- #2855447 by mparker17, drunken monkey: Added "Separator" option for
+ aggregated fields of type "Fulltext".
+- #2863445 by dbjpanda, drunken monkey: Fixed phrasing in README.txt.
+
+Search API 1.21 (2017-02-23):
+-----------------------------
+- #2780341 by Berdir: Fixed passing of custom ranges to date facets.
+- #2765317 by JorgenSandstrom, NWOM, drunken monkey: Added a "Last" aggregation
+ type.
+- #2842856 by drunken monkey: Fixed language filters for "Multiple types"
+ indexes.
+- #2844990 by drunken monkey: Made the "Role filter" data alteration available
+ for multi-type indexes.
+- #2837745 by drunken monkey, klausi: Fixed NULL tags on old serialized queries.
+- #2833482 by drunken monkey: Fixed undefined constant when uninstalling facets
+ module.
+- #2840261 by alan-ps: Fixed usage of outdated hash functions.
+- #1670420 by kyletaylored, dorficus, drunken monkey: Fixed potential fatal
+ error in facet adapter's getSearchKeys() method.
+- #2838075 by dsnopek: Fixed possible race condition in
+ hook_system_info_alter().
+- #2836687 by sarthak drupal: Fixed one doc comment typo.
+- #2632880 by drunken monkey, donquixote: Added possibility to change indexed
+ bundles on disabled indexes.
+- #2828380 by jansete: Fixed taxonomy term access tag in Views filter.
+- #2827717 by Fabien.Godineau, drunken monkey: Fixed disabling of search views
+ when reverting an index.
+- #2822836 by prince_zyxware: Fixed some Drupal coding standards violations.
+- #2822145 by drunken monkey: Fixed problem with phrase search in Views
+ fulltext filter.
+- #2778261 by drunken monkey, BAHbKA: Fixed "Index items immediately"
+ functionality for unindexed items.
+- #2358065 by Jelle_S, graper, drunken monkey: Added the option for
+ highlighting of partial matches to the processor.
+- #2779159 by mark_fullmer, drunken monkey: Added a Stemmer processor.
+- #2649412 by relaxnow, GoZ: Added support for minimum granularity to date
+ facets.
+- #2769021 by Plazik, drunken monkey: Added the generated Search API query to
+ the Views preview.
+- #2769877 by mfernea: Fixed database exception when filtering for anonymous
+ user.
+
+Search API 1.20 (2016-07-21):
+-----------------------------
+- #2731103 by drunken monkey: Fixed the default value for the taxonomy term
+ filter "multiple" setting.
+- #1818572 by morningtime, drunken monkey, lodey, guillaumev: Added pretty
+ paths support to the Views facets block.
+- #2753441 by Johnny vd Laar: Fixed translated field names in
+ language-independent cache.
+
+Search API 1.19 (2016-07-05):
+-----------------------------
+- #2724687 by StefanPr, drunken monkey: Fixed failed sanitization of NULL field
+ values.
+- #2744189 by nikolabintev, drunken monkey: Fixed highlighting for single-word
+ fields.
+- #2744995 by John Cook, drunken monkey: Fixed search views without pager.
+- #2742053 by tunic: Fixed change notification on node access records change.
+- #2733447 by jsacksick: Fixed translatability of our Views taxonomy term
+ filter.
+- #2720465 by drunken monkey: Fixed bundle filter's handling of entity types
+ with no bundles on multi-type indexes.
+- #2710893 by alan-ps, drunken monkey: Fixed creation of comment indexes when
+ no nodes exist.
+- #2707039 by alan-ps: Fixed indexes of flag entities with "bundles" setting.
+- #2700879 by drunken monkey: Fixed breadcrumbs on index tabs.
+- #1889940 by cspurk, Yaron Tal: Fixed "HTML filter" processor to recognize all
+ valid HTML tags.
+- #2700011 by drunken monkey: Fixed compatibility issues of facets from
+ different indexes.
+- #2665970 by andrei.colesnic, drunken monkey: Added "Limit list to selected
+ items" exposed option support for Views taxonomy term filters.
+- #2703675 by drunken monkey, heykarthikwithu: Fixed accidental assumption that
+ all facets are taxonomy terms.
+- #2419853 by drunken monkey: Fixed HTML filter leaves escaped entities in
+ field values sometimes.
+
+Search API 1.18 (2016-04-20):
+-----------------------------
+- Various security fixes – see https://www.drupal.org/node/2710063.
+- #2693425 by jojyja: Fixed a typo in search_api.info.
+
+Search API 1.17 (2016-03-14):
+-----------------------------
+- #2665586 by recrit, drunken monkey: Fixed parsing of invalid date facet
+ filters.
+- #2677900 by stefan.r, drunken monkey: Added the possibility to change date
+ facet formats.
+- #2678856 by stefan.r, drunken monkey: Fixed date facets showing wrong month
+ on certain days.
+- #2667872 by Les Lim: Added "0" to field boost options.
+- #2654328 by drunken monkey, donquixote: Fixed use of "<" and ">" for open
+ facet ranges.
+- #2639200 by joachim: Added sorting to "related fields" select box.
+- #2638740 by joachim, drunken monkey: Added a link to the index to the
+ "re-indexing necessary" message.
+- #2629136 by drunken monkey, deranga: Fixed wrong facet counts in edge cases
+ for active OR facets.
+- #2569461 by kraynuk.m, drunken monkey: Fixed existing table in update #7118.
+- #2631276 by tauno: Fixed the MLT handler for multi-entity indexes.
+- #2576265 by drunken monkey: Fixed view trying to search on non-fulltext field.
+- #2572487 by drunken monkey: Removed operator setting for date facets.
+- #2611714 by rakesh.gectcr, drunken monkey: Improved compliance with
+ documentation standards.
+- #2613054 by temkin: Fixed the "search-api-index" Drush command to allow
+ setting further options when indexing on all indexes.
+- #2611726 by Hubbs, rakesh.gectcr: Fixed several typos.
+- #2603500 by drunken monkey, krishna savithraj: Fixed Views fulltext searches
+ for keyword "All".
+- #2529262 by kingmackenzie, stefan.r: Added an option to Views date filters to
+ choose the format used by date popup.
+- #2583263 by drunken monkey: Fixed Views integration in combination with
+ Search API ET and similar modules.
+- #2592231 by drunken monkey, balintcsaba: Fixed ignored item language when
+ viewing translated items.
+- #2570879 by thePanz, drunken monkey: Added sorting of remembered search IDs.
+- #2565743 by drunken monkey: Fixed creation of comment indexes with specific
+ bundles.
+- #2563793 by drunken monkey, smitty, ReBa: Fixed Views base table definition
+ for "Multiple types" indexes.
+- #2567775 by joseph.olstad, drunken monkey: Fixed handling of broken HTML in
+ the "HTML filter" processor.
+- #2565005 by drunken monkey: Properly escape labels of "checkboxes"/"radios"
+ options
+- #2524314 by drunken monkey: Fixed bundle-setting for taxonomy term indexes.
+- #2550599 by ACF, drunken monkey: Fixed error on entity rebuilds.
+
+Search API 1.16 (2015-08-30):
+-----------------------------
+- #2502819: Fixed example code for hook_search_api_query_alter().
+- #2491175 by ptmkenny, drunken monkey: Added a data alteration for filtering
+ out blocked users.
+- #1197538 by thePanz, k4v, drunken monkey, ayalon, nadavoid, timodwhit, becw,
+ Elvar: Added support for the "Global: Random" sort in Views.
+- #2520934 by drunken monkey: Added an item type for indexing several types of
+ entities in one index.
+- #2533096 by drunken monkey: Fixed uncaught exception when deleting a server.
+- #2479453 by prics, drunken monkey: Added a Drush command to
+ list/enable/disable servers.
+- #2520684 by drunken monkey: Fixed "bundles" setting on indexes with "Index
+ immediately".
+- #2489882 by dww: Fixed Views taxonomy filter with "is (not) empty" operator
+- #2447213 by drunken monkey: Fixed issues with stale field settings for MLT
+ contextual filter.
+
+Search API 1.15 (2015-06-03):
+-----------------------------
+- #2190627 by m1n0, drunken monkey: Fixed fatal errors for views of disabled
+ indexes.
+- #2448849 by cgoffin: Added "year range" option for date filters.
+- #2414425 by Darren Oh, drunken monkey: Fixed backend form validation when
+ adding or editing a server.
+- #2450333 by drunken monkey: Added performance improvement when indexing
+ entity references.
+- #2450227 by StryKaizer, drunken monkey: Fixed OR facets on taxonomy terms.
+- #1184610 by drunken monkey: Added option to limit indexes to specific entity
+ bundles.
+- #1396222 by drunken monkey: Added a "First letter" aggregation type to the
+ "Aggregated fields" data alteration.
+- #2412895 by drunken monkey: Fixed entity load for Views entity access check.
+- #2414367 by Darren Oh, drunken monkey: Fixed detection of missing fields in
+ Views.
+- #2387161 by drunken monkey: Added a hook for altering search results.
+
+Search API 1.14 (2014-12-26):
+-----------------------------
+- #2382385 by illusionuk, drunken monkey: Fixed error handling when using
+ invalid fulltext or sort field in Views.
+- #2371099 by drunken monkey: Fixed display of active "Exclude" facets.
+- #1861134 by Cyberwolf, jackbravo, drunken monkey: Fixed indexing on multiple
+ indexes with Drush.
+- #2347367 by drunken monkey, das-peter: Fixed forgotten usages of
+ $index->item_type.
+- #2359201 by drunken monkey: Added a "List" option to "Aggregated fields".
+- #2364247 by drunken monkey: Fixed documentation for
+ SearchApiQueryFilterInterface::getFilters().
+- #2364875 by Xano: Fixed Views argument handler for fulltext fields.
+- #2174163 by drunken monkey: Fixed detection of field type changes by data
+ alterations.
+- #2305755 by drunken monkey, pfrenssen: Fixed invalidation of the stored index
+ fields cache.
+- #2334727 by Alex Bukach, drunken monkey: Fixed Views caching does not take
+ items_per_page into account.
+- #1372092 by drunken monkey: Added an error message when no service class is
+ available when creating a server.
+- #2305627 by drunken monkey, cpliakas: Fixed date facets not displayed when
+ the configured granularity is larger than the calculated granularity.
+- #2319263 by solotandem: Added easier way to subclass entity classes.
+- #2278737 by drunken monkey: Fixed use of multiple Views fulltext search
+ filters.
+
+Search API 1.13 (2014-07-23):
+-----------------------------
+- #2281535 by areynolds, nicola85: Adapted to latest changes in Views cache
+ plugins.
+- #2145547 by aaronbauman: Fixed duplicated sorts (one exposed) in Views.
+- #2146435 by alanmackenzie: Fixed Views paging with custom pager add-ons.
+- #2278791 by drunken monkey | tksmd: Fixed excerpt when searching single CJK
+ word.
+- #2272983 by idflood, drunken monkey: Fixed Highlighting processor for queries
+ without returned results.
+- #2216345 by bacardi55, fabianderijk, drunken monkey: Fixed array to string
+ conversion in Highlighting processor.
+
+Search API 1.12 (2014-05-23):
+-----------------------------
+- #2265349 by drunken monkey: Marked _search_api_settings_equals() as
+ deprecated.
+- #2256891 by justanothermark: Fixed "0" entity labels.
+- #2233749 by rjacobs, drunken monkey: Added drush support to change the server
+ used by an index.
+- #2219553 by drunken monkey: Fixed Views fulltext filter operators.
+- #2135697 by drunken monkey: Fixed handling of HTML attributes in the
+ Highlighting processor.
+- #2179755 by drunken monkey, fago: Fixed whitespaces after HTML filter.
+- #2204847 by drunken monkey, alanmackenzie: Fixed Views caching issues with
+ pagination.
+- #2198791 by drunken monkey: Fixed empty Views entity filters.
+- #2195469 by freakalis, drunken monkey: Added "Exclude fields" options to
+ Highlighting processor.
+- #2169455 by drunken monkey: Fixed "undefined index" in
+ search_api_update_7116().
+- #2219563 by drunken monkey: Added __toString() methods for queries and
+ filters.
+- #1888174 by drunken monkey, ipallian: Fixed problems with date facets.
+- #2187487 by drunken monkey: Fixed admin summary of language filter.
+- #2198261 by drunken monkey: Fixed fatal error on view editing.
+- #2168713 by idebr: Fixed highlighting of keys containing slashes.
+- #2150779 by hefox: Fixed "Overridden" detection for index features.
+- #1227702 by drunken monkey: Improved error handling.
+
+Search API 1.11 (2013-12-25):
+-----------------------------
+- #1879196 by drunken monkey: Fixed invalid old indexes causing errors.
+- #2155127 by drunken monkey: Clarified the scope of the "Node access" and
+ "Exclude unpublished nodes" data alterations.
+- #2155575 by drunken monkey: Fixed incorrect "Server index status" warnings.
+- #2159011 by idebr, drunken monkey: Fixed highlighting of keywords with PCRE
+ special characters.
+- #2155721 by rjacobs, drunken monkey: Added support for Views' get_total_rows
+ property.
+- #2158873 by drumm, drunken monkey: Fixed "all of" operator of Views entity
+ filter handler.
+- #2156021 by jgullstr: Fixed confirm message when disabling servers.
+- #2146435 by timkang: Fixed Views paging with custom pager add-ons.
+- #2150347 by drunken monkey: Added access callbacks for indexes and servers.
+
+Search API 1.10 (2013-12-09):
+-----------------------------
+- #2130819 by drunken monkey, Bojhan: Added UI improvements for the "View" tabs.
+- #2152327 by sirtet, miro_dietiker: Fixed typo in help text for drush sapi-c.
+- #2144531 by drunken monkey: Fixed cloning of queries to clone filters, too.
+- #2100671 by drunken monkey: Fixed stopwords processor to ignore missing
+ stopwords.
+- #2139239 by drunken monkey: Fixed highlighting for the last word of a field.
+- #1925114 by azinck: Fixed Views Facet Block integration with Panels.
+- #2139215 by drunken monkey: Fixed $context parameter of batch callback.
+- #2143659 by khiminrm: Fixed typo in update function 7116.
+- #2134509 by kscheirer, drunken monkey: Removed unused variables and
+ parameters.
+- #2136019 by drunken monkey: Fixed mapping callback for taxonomy term facets.
+- #2128001 by drunken monkey: Fixed the logic of the "contains none of these
+ words" fulltext operator.
+- #2128947 by stBorchert, drunken monkey: Fixed facet handling for multiple
+ searches on a page.
+- #2128529 by Frando, drunken monkey: Added a way for facet query type plugins
+ to pass options to the search query.
+- #1551302 by drunken monkey: Fixed the server tasks system.
+- #2135363 by drumm, drunken monkey: Added support for Views' use_count_query()
+ method.
+- #1390598 by Damien Tournoud, drunken monkey: Added the concept of query filter
+ tags.
+- #2135255 by dww: Fixed missing pager on first page of search results.
+- #1832334 by Damien Tournoud, drunken monkey: Fixed performance issues of
+ Views options filter handler for huge options lists.
+- #2118589 by mxr576, drunken monkey: Added node access for comment indexes.
+- #1961120 by drunken monkey: Fixed Views handling of short fulltext keywords.
+- #2100231 by drunken monkey: Renamed "Workflow" tab to "Filters".
+- #2100193 by drunken monkey: Turned operations in overview into D8 dropbuttons.
+- #2100199 by drunken monkey: Merged index tabs for a cleaner look.
+- #2115127 by drunken monkey: Fixed cron indexing logic to keep the right order.
+- #1750144 by jsacksick, drunken monkey: Fixed missing Boost option for custom
+ fulltext field types.
+- #1956650 by drunken monkey, wwhurley: Fixed trackItemChange not checking for
+ empty $item_ids.
+- #2100191 by drunken monkey, Bojhan: Added an admin description to the Search
+ API landing page.
+
+Search API 1.9 (2013-10-23):
+----------------------------
+- #2113277 by moonray, drunken monkey: Fixed date facet count for active item.
+- #2086783 by drunken monkey: Removed Views field handlers for "virtual" fields.
+- #2114593 by drunken monkey: Added list of floats to test module.
+- #2109247 by mmikitka, drunken monkey: Exposed the status and module
+ properties to Entity API.
+- #2091499 by sammys, drunken monkey: Added Views contextual filter handler for
+ dates.
+- #2109537 by hefox, drunken monkey: Added alter hooks for workflow plugin
+ definitions.
+- #2102111 by sergei_brill: Added hook_search_api_views_query_alter().
+- #2110315 by drumm, drunken monkey: Added specialized Views filters for users
+ and terms.
+- #2111273 by drunken monkey: Fixed Javascript states for exposed filter
+ operator.
+- #2102353 by aaronbauman: Fixed "smaller than" to read "less than".
+- #2097559 by thijsvdanker: Fixed the language of created search excerpts.
+- #2096275 by andrewbelcher: Fixed calling of Views pager pre/post execute
+ callbacks.
+- #2093023 by maciej.zgadzaj: Added Drush commands to enable and disable
+ indexes.
+- #2088905 by queenvictoria, drunken monkey: Fixed handling of Views
+ override_path option.
+- #2083481 by drunken monkey, nickgs: Added "exclude" option for facets.
+- #2084953 by Yaron Tal: Fixed issue with theme initialization.
+- #2075839 by leeomara, drunken monkey: Added descriptions to field lists for
+ 'Aggregated Fields'.
+
+Search API 1.8 (2013-09-01):
+----------------------------
+- #1414048 by drunken monkey: Fixed exception in views.inc removes all Search
+ API tables.
+- #1921690 by drunken monkey: Fixed stale Views cache when indexed fields
+ change.
+- #2077035 by maciej.zgadzaj: Fixed whitespace recognition for search keys.
+- #2071229 by drunken monkey: Fixed use of core search constant.
+- #2069023 by drunken monkey: Fixed reaction to disabled modules.
+- #2057867 by drunken monkey: Fixed multiple values for taxonomy contextual
+ filter.
+- #2052701 by drunken monkey, erdos: Fixed cron queue state when disabling the
+ module.
+- #1878606 by drunken monkey: Fixed labels for boolean facets.
+- #2053171 by drunken monkey: Improved tests.
- #1433720 by davidwbarratt, drunken monkey, JvE: Fixed handling of empty
selection for checkboxes.
- #1414078 by drunken monkey, jaxxed: Fixed revert of exportables.
@@ -19,7 +403,7 @@ Search API 1.x, dev (xx/xx/xxxx):
- #2040111 by arpieb: Fixed Views URL argument handler to allow multiple values.
- #1064520 by drunken monkey: Added a processor for highlighting.
-Search API 1.7 (07/01/2013):
+Search API 1.7 (2013-07-01):
----------------------------
- #1612708 by drunken monkey: Fixed Views caching with facet blocks.
- #2024189 by drunken monkey: Improved serialization of the query class.
@@ -46,7 +430,7 @@ Search API 1.7 (07/01/2013):
- #1285794 by drunken monkey: Fixed "All" option in Views' exposed "Items per
page" setting.
-Search API 1.6 (05/29/2013):
+Search API 1.6 (2013-05-29):
----------------------------
- #1649976 by Berdir, ilari.stenroth, drunken monkey: Fixed memory error during
crons run for large indexes.
@@ -64,7 +448,7 @@ Search API 1.6 (05/29/2013):
- #1760706 by jgraham, das-peter, drunken monkey: Added a flexible way for
determining whether an index contains entities.
-Search API 1.5 (05/04/2013):
+Search API 1.5 (2013-05-04):
----------------------------
- #1169254 by cslavoie, drunken monkey, DYdave: Added transliteration processor.
- #1959088 by drunken monkey: Fixed titles for contextual filters.
@@ -81,7 +465,7 @@ Search API 1.5 (05/04/2013):
in the Hierarchy data alteration.
- #1702604 by JvE, slucero: Added option for maximum date facet depth.
-Search API 1.4 (01/09/2013):
+Search API 1.4 (2013-01-09):
----------------------------
- #1827272 by drunken monkey: Fixed regression introduced by #1777710.
- #1807622 by drunken monkey: Fixed definition of the default node index.
@@ -91,7 +475,7 @@ Search API 1.4 (01/09/2013):
filters.
- #1823916 by aschiwi: Fixed batch_sise typos.
-Search API 1.3 (10/10/2012):
+Search API 1.3 (2012-10-10):
----------------------------
- Patch by mr.baileys: Fixed "enable" function doesn't use security tokens.
- #1318904 by becw, das-peter, orakili, drunken monkey: Added improved handling
@@ -107,7 +491,7 @@ Search API 1.3 (10/10/2012):
- #1414138 by drunken monkey: Fixed internal static index property cache.
- #1253320 by drunken monkey, fago: Fixed improper error handling.
-Search API 1.2 (07/07/2012):
+Search API 1.2 (2012-07-07):
----------------------------
- #1368548 by das-peter: Do not index views results by entity id.
- #1422750 by drunken monkey, sepgil: Fixed illegal modification of entity
@@ -120,7 +504,7 @@ Search API 1.2 (07/07/2012):
changed.
- #1528436 by jsacksick, drunken monkey: Fixed handling of exportable entities.
-Search API 1.1 (05/23/2012):
+Search API 1.1 (2012-05-23):
----------------------------
- Fixed escaping of error messages.
- #1330506 by drunken monkey: Removed the old Facets module.
@@ -131,7 +515,7 @@ Search API 1.1 (05/23/2012):
$service->configurationFormValidate() for empty forms.
- #1400882 by mh86: Fixed "Index hierarchy" for "All parents".
-Search API 1.0 (12/15/2011):
+Search API 1.0 (2011-12-15):
----------------------------
- #1350322 by drunken monkey: Fixed regressions introduced with cron queue
indexing.
@@ -149,7 +533,7 @@ Search API 1.0 (12/15/2011):
dependency plugin.
- #1337292 by drunken monkey: Fixed facet dependency system.
-Search API 1.0, RC 1 (11/10/2011):
+Search API 1.0, RC 1 (2011-11-10):
----------------------------------
API changes:
- #1260834 by drunken monkey: Added a way to define custom data types.
@@ -220,7 +604,7 @@ Others:
- #1161532 by drunken monkey: Fixed discerning between delete and revert in
hook_*_delete().
-Search API 1.0, Beta 10 (06/20/2011):
+Search API 1.0, Beta 10 (2011-06-20):
-------------------------------------
API changes:
- #1068342 by drunken monkey: Added a 'fields to run on' option for processors.
@@ -234,7 +618,7 @@ Others:
- #1133864 by agentrickard, awolfey, greg.1.anderson, drunken monkey: Added
Drush integration.
-Search API 1.0, Beta 9 (06/06/2011):
+Search API 1.0, Beta 9 (2011-06-06):
------------------------------------
API changes:
- #1089758 by becw, drunken monkey: Updated Views field handlers to utilize new
@@ -276,7 +660,7 @@ Others:
- #1120850 by drunken monkey, fangel: Fixed type of related entities in nested
lists.
-Search API 1.0, Beta 8 (04/02/2011):
+Search API 1.0, Beta 8 (2011-04-02):
------------------------------------
API changes:
- #1012878 by drunken monkey: Added a way to index an entity directly.
@@ -295,12 +679,12 @@ Others:
search_api_facets_by_block_status().
- #1081666 by danielnolde: Fixed PHP notices when property labels are missing.
-Search API 1.0, Beta 7 (03/08/2011):
+Search API 1.0, Beta 7 (2011-03-08):
------------------------------------
- #1083828 by drunken monkey: Added documentation on indexing custom data.
- #1081244 by drunken monkey: Fixed debug line still contained in DB backend.
-Search API 1.0, Beta 6 (03/04/2011):
+Search API 1.0, Beta 6 (2011-03-04):
------------------------------------
API changes:
- #1075810 by drunken monkey: Added API function for marking entities as dirty.
@@ -343,7 +727,7 @@ Others:
- #1024514: Error when preprocessing muli-valued fulltext fields.
- #1020372: CSS classes for facets.
-Search API 1.0, Beta 5 (01/05/2011):
+Search API 1.0, Beta 5 (2011-01-05):
------------------------------------
API changes:
- #917998: Enhance data alterations by making them objects.
@@ -365,7 +749,7 @@ Others:
- #985324: Add "Current search" block.
- #984174: Bug in Index::prepareProcessors() when processors have not been set.
-Search API 1.0, Beta 4 (11/29/2010):
+Search API 1.0, Beta 4 (2010-11-29):
------------------------------------
API changes:
- #976876: Move Solr module into its own project.
@@ -407,7 +791,7 @@ Others:
- #938982: Not all SearchApiQuery options are passed.
- #931066 by luke_b: HTTP timeout not set correctly.
-Search API 1.0, Beta 3 (09/30/2010):
+Search API 1.0, Beta 3 (2010-09-30):
------------------------------------
- API mostly stable.
- Five contrib modules exist:
diff --git a/README.txt b/README.txt
index 9ea75ed0..a6264e97 100644
--- a/README.txt
+++ b/README.txt
@@ -31,9 +31,9 @@ Terms as used in this module.
Sphinx or any other professional or simple indexing mechanism. Takes care of
the details of all operations, especially indexing or searching content.
- Server:
- One specific place for indexing data, using a set service class. Can
- e.g. be some tables in a database, a connection to a Solr server or other
- external services, etc.
+ One specific place for indexing data, using a specific service class. For
+ example this could be some tables in a database, a connection to a Solr server
+ or other external services, etc.
- Index:
A configuration object for indexing data of a specific type. What and how data
is indexed is determined by its settings. Also keeps track of which items
@@ -90,7 +90,7 @@ IMPORTANT: Access checks
results are displayed – either by only indexing such items, or by filtering
appropriately at search time.
For search on general site content (item type "Node"), this is already
- supported by the Search API. To enable this, go to the index's "Workflow" tab
+ supported by the Search API. To enable this, go to the index's "Filters" tab
and activate the "Node access" data alteration. This will add the necessary
field, "Node access information", to the index (which you have to leave as
"indexed"). If both this field and "Published" are set to be indexed, access
@@ -171,8 +171,8 @@ form at the bottom of the page. For instance, you might want to index the
author's username to the indexed data of a node, and you need to add the "Body"
entity to the node when you want to index the actual text it contains.
-- Index workflow
- (Configuration > Search API > [Index name] > Workflow)
+- Indexing workflow
+ (Configuration > Search API > [Index name] > Filters)
This page lets you customize how the created index works, and what metadata will
be available, by selecting data alterations and processors (see the glossary for
@@ -210,12 +210,6 @@ search_api_index_worker_callback_runtime:
API will spend indexing (for all indexes combined) in each cron run. The
default is 15 seconds.
-search_api_batch_per_cron:
- By changing this variable, you can define how many batch items are created on
- a single cron run. The value is per index, so on a site with 5 indexes with a
- cron limit of 100 each, the default value of 10 will load and queue up to 5000
- search items in up to 50 batch items.
-
Information for developers
--------------------------
@@ -391,6 +385,10 @@ Included components
Enables the admin to specify a stopwords file, the words contained in which
will be filtered out of the text data indexed. This can be used to exclude
too common words from indexing, for servers not supporting this natively.
+ * Stem words
+ Uses the PorterStemmer method to reduce words to stems. A search for
+ "garden" will return results for "gardening" and "garden," as will a search
+ for "gardening."
- Additional modules
diff --git a/contrib/search_api_facetapi/plugins/facetapi/adapter.inc b/contrib/search_api_facetapi/plugins/facetapi/adapter.inc
index b99f7dcf..a5b5cdc0 100644
--- a/contrib/search_api_facetapi/plugins/facetapi/adapter.inc
+++ b/contrib/search_api_facetapi/plugins/facetapi/adapter.inc
@@ -61,6 +61,10 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
public function initActiveFilters($query) {
$search_id = $query->getOption('search id');
$index_id = $this->info['instance'];
+ // Only act on queries from the right index.
+ if ($index_id != $query->getIndex()->machine_name) {
+ return;
+ }
$facets = facetapi_get_enabled_facets($this->info['name']);
$this->fields = array();
@@ -78,21 +82,21 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
// displayed.
$facet_search_ids = isset($options['facet_search_ids']) ? $options['facet_search_ids'] : array();
+ // Remember this search ID, if necessary.
+ $this->rememberSearchId($index_id, $search_id);
+
if (array_search($search_id, $facet_search_ids) === FALSE) {
- $search_ids = variable_get('search_api_facets_search_ids', array());
- if (empty($search_ids[$index_id][$search_id])) {
- // Remember this search ID.
- $search_ids[$index_id][$search_id] = $search_id;
- variable_set('search_api_facets_search_ids', $search_ids);
- }
if (!$default_true) {
- continue; // We are only to show facets for explicitly named search ids.
+ // We are only to show facets for explicitly named search ids.
+ continue;
}
}
elseif ($default_true) {
- continue; // The 'facet_search_ids' in the settings are to be excluded.
+ // The 'facet_search_ids' in the settings are to be excluded.
+ continue;
}
- $active[$facet['name']] = $search_id;
+ $facet_key = $facet['name'] . '@' . $this->getSearcher();
+ $active[$facet_key] = $search_id;
$this->fields[$facet['name']] = array(
'field' => $facet['field'],
'limit' => $options['hard_limit'],
@@ -103,13 +107,35 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
}
}
+ /**
+ * Adds a search ID to the list of known searches for an index.
+ *
+ * @param string $index_id
+ * The machine name of the search index.
+ * @param string $search_id
+ * The identifier of the executed search.
+ */
+ protected function rememberSearchId($index_id, $search_id) {
+ $search_ids = variable_get('search_api_facets_search_ids', array());
+ if (empty($search_ids[$index_id][$search_id])) {
+ $search_ids[$index_id][$search_id] = $search_id;
+ asort($search_ids[$index_id]);
+ variable_set('search_api_facets_search_ids', $search_ids);
+ }
+ }
+
/**
* Add the given facet to the query.
*/
public function addFacet(array $facet, SearchApiQueryInterface $query) {
if (isset($this->fields[$facet['name']])) {
$options = &$query->getOptions();
- $options['search_api_facets'][$facet['name']] = $this->fields[$facet['name']];
+ $facet_info = $this->fields[$facet['name']];
+ if (!empty($facet['query_options'])) {
+ // Let facet-specific query options override the set options.
+ $facet_info = $facet['query_options'] + $facet_info;
+ }
+ $options['search_api_facets'][$facet['name']] = $facet_info;
}
}
@@ -139,7 +165,7 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
// I suspect that http://drupal.org/node/593658 would help.
// For now, just taking the first current search for this index. :-/
foreach (search_api_current_search() as $search) {
- list($query, $results) = $search;
+ list($query) = $search;
if ($query->getIndex()->machine_name == $index_id) {
$this->current_search = $search;
}
@@ -166,6 +192,12 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
*/
public function getSearchKeys() {
$search = $this->getCurrentSearch();
+
+ // If the search is empty then there's no reason to continue.
+ if (!$search) {
+ return NULL;
+ }
+
$keys = $search[0]->getOriginalKeys();
if (is_array($keys)) {
// This will happen nearly never when displaying the search keys to the
@@ -196,7 +228,6 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
*/
public function settingsForm(&$form, &$form_state) {
$facet = $form['#facetapi']['facet'];
- $realm = $form['#facetapi']['realm'];
$facet_settings = $this->getFacet($facet)->getSettings();
$options = $facet_settings->settings;
$search_ids = variable_get('search_api_facets_search_ids', array());
@@ -205,6 +236,7 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
$form['global']['default_true'] = array(
'#type' => 'select',
'#title' => t('Display for searches'),
+ '#prefix' => '
',
'#options' => array(
TRUE => t('For all except the selected'),
FALSE => t('Only for the selected'),
@@ -214,6 +246,7 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
$form['global']['facet_search_ids'] = array(
'#type' => 'select',
'#title' => t('Search IDs'),
+ '#suffix' => '
',
+ '#options' => $granularity_options,
+ '#default_value' => $default_value,
+ );
+ }
+
+ // Add an "Exclude" option for terms.
+ if (!empty($facet['query types']) && in_array('term', $facet['query types'])) {
+ $form['global']['operator']['#weight'] = -2;
+ unset($form['global']['operator']['#suffix']);
+ $form['global']['exclude'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Exclude'),
+ '#description' => t('Make the search exclude selected facets, instead of restricting it to them.'),
+ '#suffix' => '',
+ '#weight' => -1,
+ '#default_value' => !empty($options['exclude']),
+ );
}
}
}
diff --git a/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc b/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc
index ab3002c1..6aeb8362 100644
--- a/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc
+++ b/contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc
@@ -37,6 +37,17 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
public function execute($query) {
// Return terms for this facet.
$this->adapter->addFacet($this->facet, $query);
+
+ $settings = $this->adapter->getFacet($this->facet)->getSettings()->settings;
+
+ // First check if the facet is enabled for this search.
+ $default_true = isset($settings['default_true']) ? $settings['default_true'] : TRUE;
+ $facet_search_ids = isset($settings['facet_search_ids']) ? $settings['facet_search_ids'] : array();
+ if ($default_true != empty($facet_search_ids[$query->getOption('search id')])) {
+ // Facet is not enabled for this search ID.
+ return;
+ }
+
// Change limit to "unlimited" (-1).
$options = &$query->getOptions();
if (!empty($options['search_api_facets'][$this->facet['name']])) {
@@ -46,14 +57,145 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
if ($active = $this->adapter->getActiveItems($this->facet)) {
$item = end($active);
$field = $this->facet['field'];
- $regex = str_replace(array('^', '$'), '', FACETAPI_REGEX_DATE);
- $filter = preg_replace_callback($regex, array($this, 'replaceDateString'), $item['value']);
- $this->addFacetFilter($query, $field, $filter);
+ $filter = $this->createRangeFilter($item['value']);
+ if ($filter) {
+ $this->addFacetFilter($query, $field, $filter);
+ }
}
}
+ /**
+ * Rewrites the handler-specific date range syntax to the normal facet syntax.
+ *
+ * @param string $value
+ * The user-facing facet value.
+ *
+ * @return string|null
+ * A facet to add as a filter, in the format used internally in this module.
+ * Or NULL if the raw facet in $value is not valid.
+ */
+ protected function createRangeFilter($value) {
+ // Ignore any filters passed directly from the server (range or missing).
+ if (!$value || $value == '!' || (!ctype_digit($value[0]) && preg_match('/^[\[(][^ ]+ TO [^ ]+[\])]$/', $value))) {
+ return $value ? $value : NULL;
+ }
+
+ // Parse into date parts.
+ $parts = $this->parseRangeFilter($value);
+
+ // Return NULL if the date parts are invalid or none were found.
+ if (empty($parts)) {
+ return NULL;
+ }
+
+ $date = new DateTime();
+ switch (count($parts)) {
+ case 1:
+ $date->setDate($parts[0], 1, 1);
+ $date->setTime(0, 0, 0);
+ $lower = $date->format('U');
+ $date->setDate($parts[0] + 1, 1, 1);
+ $date->setTime(0, 0, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 2:
+ // Luckily, $month = 13 is treated as January of next year. (The same
+ // goes for all other parameters.) We use the inverse trick for the
+ // seconds of the upper bound, since that's inclusive and we want to
+ // stop at a second before the next segment starts.
+ $date->setDate($parts[0], $parts[1], 1);
+ $date->setTime(0, 0, 0);
+ $lower = $date->format('U');
+ $date->setDate($parts[0], $parts[1] + 1, 1);
+ $date->setTime(0, 0, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 3:
+ $date->setDate($parts[0], $parts[1], $parts[2]);
+ $date->setTime(0, 0, 0);
+ $lower = $date->format('U');
+ $date->setDate($parts[0], $parts[1], $parts[2] + 1);
+ $date->setTime(0, 0, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 4:
+ $date->setDate($parts[0], $parts[1], $parts[2]);
+ $date->setTime($parts[3], 0, 0);
+ $lower = $date->format('U');
+ $date->setTime($parts[3] + 1, 0, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 5:
+ $date->setDate($parts[0], $parts[1], $parts[2]);
+ $date->setTime($parts[3], $parts[4], 0);
+ $lower = $date->format('U');
+ $date->setTime($parts[3], $parts[4] + 1, -1);
+ $upper = $date->format('U');
+ break;
+
+ case 6:
+ $date->setDate($parts[0], $parts[1], $parts[2]);
+ $date->setTime($parts[3], $parts[4], $parts[5]);
+ return $date->format('U');
+
+ default:
+ return $value;
+ }
+
+ return "[$lower TO $upper]";
+ }
+
+ /**
+ * Parses the date range filter value into parts.
+ *
+ * @param string $value
+ * The user-facing facet value.
+ *
+ * @return int[]|null
+ * An array of date parts, or NULL if an invalid value was provided.
+ */
+ protected static function parseRangeFilter($value) {
+ $parts = explode('-', $value);
+
+ foreach ($parts as $i => $part) {
+ // Invalidate if part is not an integer.
+ if ($part === '' || !is_numeric($part) || intval($part) != $part) {
+ return NULL;
+ }
+ $parts[$i] = (int) $part;
+ // Depending on the position, negative numbers or 0 are invalid.
+ switch ($i) {
+ case 0:
+ // Years can contain anything – negative values are unlikely, but
+ // technically possible.
+ break;
+ case 1:
+ case 2:
+ // Days and months have to be positive.
+ if ($part <= 0) {
+ return NULL;
+ }
+ break;
+ default:
+ // All others can be 0, but not negative.
+ if ($part < 0) {
+ return NULL;
+ }
+ }
+ }
+
+ return $parts;
+ }
+
/**
* Replacement callback for replacing ISO dates with timestamps.
+ *
+ * Not used anymore, but kept for backwards compatibility with potential
+ * subclasses.
*/
public function replaceDateString($matches) {
return strtotime($matches[0]);
@@ -68,22 +210,17 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
public function build() {
$facet = $this->adapter->getFacet($this->facet);
$search_ids = drupal_static('search_api_facetapi_active_facets', array());
- if (empty($search_ids[$facet['name']]) || !search_api_current_search($search_ids[$facet['name']])) {
+ $facet_key = $facet['name'] . '@' . $this->adapter->getSearcher();
+ if (empty($search_ids[$facet_key]) || !search_api_current_search($search_ids[$facet_key])) {
return array();
}
- $search_id = $search_ids[$facet['name']];
+ $search_id = $search_ids[$facet_key];
$build = array();
$search = search_api_current_search($search_id);
$results = $search[1];
- if (!$results['result count']) {
- return array();
- }
// Gets total number of documents matched in search.
$total = $results['result count'];
- // Most of the code below is copied from search_facetapi's implementation of
- // this method.
-
// Executes query, iterates over results.
if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) {
$values = $results['search_api_facets'][$this->facet['name']];
@@ -102,37 +239,46 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
}
}
else {
- $filter = substr($value['filter'], 1, -1);
- $pos = strpos($filter, ' ');
- if ($pos !== FALSE) {
- $lower = facetapi_isodate(substr($filter, 0, $pos), FACETAPI_DATE_DAY);
- $upper = facetapi_isodate(substr($filter, $pos + 1), FACETAPI_DATE_DAY);
- $filter = '[' . $lower . ' TO ' . $upper . ']';
- }
$build[$filter]['#count'] = $value['count'];
}
}
}
}
- // Get the finest level of detail we're allowed to drill down to.
$settings = $facet->getSettings()->settings;
- $granularity = isset($settings['date_granularity']) ? $settings['date_granularity'] : FACETAPI_DATE_MINUTE;
+
+ // Get the finest level of detail we're allowed to drill down to.
+ $max_granularity = FACETAPI_DATE_MINUTE;
+ if (isset($settings['date_granularity'])) {
+ $max_granularity = $settings['date_granularity'];
+ }
+
+ // Get the coarsest level of detail we're allowed to start at.
+ $min_granularity = FACETAPI_DATE_YEAR;
+ if (isset($settings['date_granularity_min'])) {
+ $min_granularity = $settings['date_granularity_min'];
+ }
// Gets active facets, starts building hierarchy.
- $parent = $gap = NULL;
- foreach ($this->adapter->getActiveItems($this->facet) as $value => $item) {
+ $parent = $granularity = NULL;
+ $active_items = $this->adapter->getActiveItems($this->facet);
+ foreach ($active_items as $value => $item) {
// If the item is active, the count is the result set count.
$build[$value] = array('#count' => $total);
- // Gets next "gap" increment.
- if ($value[0] != '[' || $value[strlen($value) - 1] != ']' || !($pos = strpos($value, ' TO '))) {
+ // Gets next "gap" increment. Ignore any filters passed directly from the
+ // server (range or missing). We always create filters starting with a
+ // year.
+ $value = "$value";
+ if (!$value || !ctype_digit($value[0])) {
+ continue;
+ }
+
+ $granularity = search_api_facetapi_date_get_granularity($value);
+ if (!$granularity) {
continue;
}
- $start = substr($value, 1, $pos);
- $end = substr($value, $pos + 4, -1);
- $date_gap = facetapi_get_date_gap($start, $end);
- $gap = facetapi_get_next_date_gap($date_gap, $granularity);
+ $granularity = facetapi_get_next_date_gap($granularity, $max_granularity);
// If there is a previous item, there is a parent, uses a reference so the
// arrays are populated when they are updated.
@@ -144,6 +290,7 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
// Stores the last value iterated over.
$parent = $value;
}
+
if (empty($raw_values)) {
return $build;
}
@@ -153,7 +300,7 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
$timestamps = array_keys($raw_values);
if (NULL === $parent) {
if (count($raw_values) > 1) {
- $gap = facetapi_get_timestamp_gap(min($timestamps), max($timestamps));
+ $granularity = facetapi_get_timestamp_gap(min($timestamps), max($timestamps), $max_granularity);
// Array of numbers used to determine whether the next gap is smaller than
// the minimum gap allowed in the drilldown.
$gap_numbers = array(
@@ -164,42 +311,31 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
FACETAPI_DATE_MINUTE => 2,
FACETAPI_DATE_SECOND => 1,
);
- // Gets gap numbers for both the gap and minimum gap, checks if the gap
- // is within the limit set by the $granularity parameter.
- if ($gap_numbers[$gap] < $gap_numbers[$granularity]) {
- $gap = $granularity;
+ // Gets gap numbers for both the gap, minimum and maximum gap, checks if
+ // the gap is within the limit set by the $granularity parameters.
+ if ($gap_numbers[$granularity] < $gap_numbers[$max_granularity]) {
+ $granularity = $max_granularity;
+ }
+ if ($gap_numbers[$granularity] > $gap_numbers[$min_granularity]) {
+ $granularity = $min_granularity;
}
}
else {
- $gap = $granularity;
+ $granularity = $max_granularity;
}
}
- // Converts all timestamps to dates in ISO 8601 format.
- $dates = array();
- foreach ($timestamps as $timestamp) {
- $dates[$timestamp] = facetapi_isodate($timestamp, $gap);
- }
-
- // Treat each date as the range start and next date as the range end.
- $range_end = array();
- $previous = NULL;
- foreach (array_unique($dates) as $date) {
- if (NULL !== $previous) {
- $range_end[$previous] = facetapi_get_next_date_increment($previous, $gap);
- }
- $previous = $date;
- }
- $range_end[$previous] = facetapi_get_next_date_increment($previous, $gap);
-
- // Groups dates by the range they belong to, builds the $build array
- // with the facet counts and formatted range values.
+ // Groups dates by the range they belong to, builds the $build array with
+ // the facet counts and formatted range values.
+ $format = search_api_facetapi_date_get_granularity_format($granularity);
foreach ($raw_values as $value => $count) {
- $new_value = '[' . $dates[$value] . ' TO ' . $range_end[$dates[$value]] . ']';
+ $new_value = date($format, $value);
if (!isset($build[$new_value])) {
$build[$new_value] = array('#count' => $count);
}
- else {
+ // Active items already have their value set because it's the current
+ // result count.
+ elseif (!isset($active_items[$new_value])) {
$build[$new_value]['#count'] += $count;
}
@@ -212,4 +348,5 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
return $build;
}
+
}
diff --git a/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc b/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc
index 1d0e8ebc..4c674a8a 100644
--- a/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc
+++ b/contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc
@@ -30,53 +30,98 @@ class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTy
// Return terms for this facet.
$this->adapter->addFacet($this->facet, $query);
- $settings = $this->adapter->getFacet($this->facet)->getSettings();
- // Adds the operator parameter.
- $operator = $settings->settings['operator'];
+ $settings = $this->getSettings()->settings;
+
+ // First check if the facet is enabled for this search.
+ $default_true = isset($settings['default_true']) ? $settings['default_true'] : TRUE;
+ $facet_search_ids = isset($settings['facet_search_ids']) ? $settings['facet_search_ids'] : array();
+ if ($default_true != empty($facet_search_ids[$query->getOption('search id')])) {
+ // Facet is not enabled for this search ID.
+ return;
+ }
- // Add active facet filters.
+ // Retrieve the active facet filters.
$active = $this->adapter->getActiveItems($this->facet);
if (empty($active)) {
return;
}
- if (FACETAPI_OPERATOR_OR == $operator) {
- // If we're dealing with an OR facet, we need to use a nested filter.
- $facet_filter = $query->createFilter('OR');
+ // Create the facet filter, and add a tag to it so that it can be easily
+ // identified down the line by services when they need to exclude facets.
+ $operator = $settings['operator'];
+ if ($operator == FACETAPI_OPERATOR_AND) {
+ $conjunction = 'AND';
+ }
+ elseif ($operator == FACETAPI_OPERATOR_OR) {
+ $conjunction = 'OR';
+ // When the operator is OR, remove parent terms from the active ones if
+ // children are active. If we don't do this, sending a term and its
+ // parent will produce the same results as just sending the parent.
+ if (is_callable($this->facet['hierarchy callback']) && !$settings['flatten']) {
+ // Check the filters in reverse order, to avoid checking parents that
+ // will afterwards be removed anyways.
+ $values = array_keys($active);
+ $parents = call_user_func($this->facet['hierarchy callback'], $values);
+ foreach (array_reverse($values) as $filter) {
+ // Skip this filter if it was already removed, or if it is the
+ // "missing value" filter ("!").
+ if (!isset($active[$filter]) || !is_numeric($filter)) {
+ continue;
+ }
+ // Go through the entire hierarchy of the value and remove all its
+ // ancestors.
+ while (!empty($parents[$filter])) {
+ $ancestor = array_shift($parents[$filter]);
+ if (isset($active[$ancestor])) {
+ unset($active[$ancestor]);
+ if (!empty($parents[$ancestor])) {
+ $parents[$filter] = array_merge($parents[$filter], $parents[$ancestor]);
+ }
+ }
+ }
+ }
+ }
}
else {
- // Otherwise we set the conditions directly on the query.
- $facet_filter = $query;
+ $vars = array(
+ '%operator' => $operator,
+ '%facet' => !empty($this->facet['label']) ? $this->facet['label'] : $this->facet['name'],
+ );
+ watchdog('search_api_facetapi', 'Unknown facet operator %operator used for facet %facet.', $vars, WATCHDOG_WARNING);
+ return;
}
+ $tags = array('facet:' . $this->facet['field']);
+ $facet_filter = $query->createFilter($conjunction, $tags);
foreach ($active as $filter => $filter_array) {
$field = $this->facet['field'];
$this->addFacetFilter($facet_filter, $field, $filter);
}
- // For OR facets, we now have to add the filter to the query.
- if (FACETAPI_OPERATOR_OR == $operator) {
- $query->filter($facet_filter);
- }
+ // Now add the filter to the query.
+ $query->filter($facet_filter);
}
/**
* Helper method for setting a facet filter on a query or query filter object.
*/
protected function addFacetFilter($query_filter, $field, $filter) {
- // Integer (or other nun-string) filters might mess up some of the following
+ // Test if this filter should be negated.
+ $settings = $this->adapter->getFacet($this->facet)->getSettings();
+ $exclude = !empty($settings->settings['exclude']);
+ // Integer (or other non-string) filters might mess up some of the following
// comparison expressions.
$filter = (string) $filter;
if ($filter == '!') {
- $query_filter->condition($field, NULL);
+ $query_filter->condition($field, NULL, $exclude ? '<>' : '=');
}
- elseif ($filter[0] == '[' && $filter[strlen($filter) - 1] == ']' && ($pos = strpos($filter, ' TO '))) {
+ elseif ($filter && $filter[0] == '[' && $filter[strlen($filter) - 1] == ']' && ($pos = strpos($filter, ' TO '))) {
$lower = trim(substr($filter, 1, $pos));
$upper = trim(substr($filter, $pos + 4, -1));
if ($lower == '*' && $upper == '*') {
- $query_filter->condition($field, NULL, '<>');
+ $query_filter->condition($field, NULL, $exclude ? '=' : '<>');
}
- else {
+ elseif (!$exclude) {
if ($lower != '*') {
// Iff we have a range with two finite boundaries, we set two
// conditions (larger than the lower bound and less than the upper
@@ -92,9 +137,22 @@ class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTy
$query_filter->condition($field, $upper, '<=');
}
}
+ else {
+ // Same as above, but with inverted logic.
+ if ($lower != '*') {
+ if ($upper != '*' && ($query_filter instanceof SearchApiQueryInterface || $query_filter->getConjunction() === 'AND')) {
+ $original_query_filter = $query_filter;
+ $query_filter = new SearchApiQueryFilter('OR');
+ }
+ $query_filter->condition($field, $lower, '<');
+ }
+ if ($upper != '*') {
+ $query_filter->condition($field, $upper, '>');
+ }
+ }
}
else {
- $query_filter->condition($field, $filter);
+ $query_filter->condition($field, $filter, $exclude ? '<>' : '=');
}
if (isset($original_query_filter)) {
$original_query_filter->filter($query_filter);
@@ -113,13 +171,20 @@ class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTy
// initActiveFilters) so that we can retrieve it here and get the correct
// current search for this facet.
$search_ids = drupal_static('search_api_facetapi_active_facets', array());
- if (empty($search_ids[$facet['name']]) || !search_api_current_search($search_ids[$facet['name']])) {
+ $facet_key = $facet['name'] . '@' . $this->adapter->getSearcher();
+ if (empty($search_ids[$facet_key]) || !search_api_current_search($search_ids[$facet_key])) {
return array();
}
- $search_id = $search_ids[$facet['name']];
- $search = search_api_current_search($search_id);
+ $search_id = $search_ids[$facet_key];
+ list(, $results) = search_api_current_search($search_id);
$build = array();
- $results = $search[1];
+
+ // Always include the active facet items.
+ foreach ($this->adapter->getActiveItems($this->facet) as $filter) {
+ $build[$filter['value']]['#count'] = 0;
+ }
+
+ // Then, add the facets returned by the server.
if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) {
$values = $results['search_api_facets'][$this->facet['name']];
foreach ($values as $value) {
diff --git a/contrib/search_api_facetapi/search_api_facetapi.install b/contrib/search_api_facetapi/search_api_facetapi.install
index a626a0cd..77c08026 100644
--- a/contrib/search_api_facetapi/search_api_facetapi.install
+++ b/contrib/search_api_facetapi/search_api_facetapi.install
@@ -5,9 +5,41 @@
* Install, update and uninstall functions for the Search facets module.
*/
+/**
+ * Implements hook_install().
+ */
+function search_api_facetapi_install() {
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_YEAR, 'Y');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_MONTH, 'F Y');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_DAY, 'F j, Y');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_HOUR, 'H:__');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_MINUTE, 'H:i');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_SECOND, 'H:i:S');
+}
+
/**
* Implements hook_uninstall().
*/
function search_api_facetapi_uninstall() {
variable_del('search_api_facets_search_ids');
-}
\ No newline at end of file
+ // We have to use the literal values here, as the Facet API module could have
+ // already been disabled at this point.
+ variable_del('date_format_search_api_facetapi_YEAR');
+ variable_del('date_format_search_api_facetapi_MONTH');
+ variable_del('date_format_search_api_facetapi_DAY');
+ variable_del('date_format_search_api_facetapi_HOUR');
+ variable_del('date_format_search_api_facetapi_MINUTE');
+ variable_del('date_format_search_api_facetapi_SECOND');
+}
+
+/**
+ * Set up date formats.
+ */
+function search_api_facetapi_update_7101() {
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_YEAR, 'Y');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_MONTH, 'F Y');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_DAY, 'F j, Y');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_HOUR, 'H:__');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_MINUTE, 'H:i');
+ variable_set('date_format_search_api_facetapi_' . FACETAPI_DATE_SECOND, 'H:i:S');
+}
diff --git a/contrib/search_api_facetapi/search_api_facetapi.module b/contrib/search_api_facetapi/search_api_facetapi.module
index feff6840..3696eae5 100644
--- a/contrib/search_api_facetapi/search_api_facetapi.module
+++ b/contrib/search_api_facetapi/search_api_facetapi.module
@@ -53,7 +53,7 @@ function search_api_facetapi_facetapi_searcher_info() {
$info = array();
$indexes = search_api_index_load_multiple(FALSE);
foreach ($indexes as $index) {
- if ($index->enabled && $index->server()->supportsFeature('search_api_facets')) {
+ if (_search_api_facetapi_index_support_feature($index)) {
$searcher_name = 'search_api@' . $index->machine_name;
$info[$searcher_name] = array(
'label' => t('Search service: @name', array('@name' => $index->name)),
@@ -92,12 +92,12 @@ function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
// other modules.
$type_settings = array(
'taxonomy_term' => array(
- 'hierarchy callback' => 'facetapi_get_taxonomy_hierarchy',
+ 'hierarchy callback' => 'search_api_facetapi_get_taxonomy_hierarchy',
),
'date' => array(
'query type' => 'date',
'map options' => array(
- 'map callback' => 'facetapi_map_date',
+ 'map callback' => 'search_api_facetapi_map_date',
),
),
);
@@ -116,7 +116,7 @@ function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
'description' => t('Filter by @type.', array('@type' => $field['name'])),
'allowed operators' => array(
FACETAPI_OPERATOR_AND => TRUE,
- FACETAPI_OPERATOR_OR => $index->server()->supportsFeature('search_api_facets_operator_or'),
+ FACETAPI_OPERATOR_OR => _search_api_facetapi_index_support_feature($index, 'search_api_facets_operator_or'),
),
'dependency plugins' => array('role'),
'facet missing allowed' => TRUE,
@@ -211,6 +211,58 @@ function search_api_facetapi_search_api_query_alter($query) {
}
}
+/**
+ * Implements hook_date_formats().
+ */
+function search_api_facetapi_date_formats() {
+ return array(
+ array(
+ 'type' => 'search_api_facetapi_' . FACETAPI_DATE_YEAR,
+ 'format' => 'Y',
+ 'locales' => array(),
+ ),
+ array(
+ 'type' => 'search_api_facetapi_' . FACETAPI_DATE_MONTH,
+ 'format' => 'F Y',
+ 'locales' => array(),
+ ),
+ array(
+ 'type' => 'search_api_facetapi_' . FACETAPI_DATE_DAY,
+ 'format' => 'F j, Y',
+ 'locales' => array(),
+ ),
+ array(
+ 'type' => 'search_api_facetapi_' . FACETAPI_DATE_HOUR,
+ 'format' => 'H:__',
+ 'locales' => array(),
+ ),
+ array(
+ 'type' => 'search_api_facetapi_' . FACETAPI_DATE_MINUTE,
+ 'format' => 'H:i',
+ 'locales' => array(),
+ ),
+ array(
+ 'type' => 'search_api_facetapi_' . FACETAPI_DATE_SECOND,
+ 'format' => 'H:i:s',
+ 'locales' => array(),
+ ),
+ );
+}
+
+/**
+ * Implements hook_date_format_types().
+ */
+function search_api_facetapi_date_format_types() {
+ return array(
+ 'search_api_facetapi_' . FACETAPI_DATE_YEAR => t('Search facets - Years'),
+ 'search_api_facetapi_' . FACETAPI_DATE_MONTH => t('Search facets - Months'),
+ 'search_api_facetapi_' . FACETAPI_DATE_DAY => t('Search facets - Days'),
+ 'search_api_facetapi_' . FACETAPI_DATE_HOUR => t('Search facets - Hours'),
+ 'search_api_facetapi_' . FACETAPI_DATE_MINUTE => t('Search facets - Minutes'),
+ 'search_api_facetapi_' . FACETAPI_DATE_SECOND => t('Search facets - Seconds'),
+ );
+}
+
/**
* Menu callback for the facet settings page.
*/
@@ -218,7 +270,7 @@ function search_api_facetapi_settings($realm_name, SearchApiIndex $index) {
if (!$index->enabled) {
return array('#markup' => t('Since this index is at the moment disabled, no facets can be activated.'));
}
- if (!$index->server()->supportsFeature('search_api_facets')) {
+ if (!_search_api_facetapi_index_support_feature($index)) {
return array('#markup' => t('This index uses a server that does not support facet functionality.'));
}
$searcher_name = 'search_api@' . $index->machine_name;
@@ -226,6 +278,48 @@ function search_api_facetapi_settings($realm_name, SearchApiIndex $index) {
return drupal_get_form('facetapi_realm_settings_form', $searcher_name, $realm_name);
}
+/**
+ * Checks whether a certain feature is supported for an index.
+ *
+ * @param SearchApiIndex $index
+ * The search index which should be checked.
+ * @param string $feature
+ * (optional) The feature to check for. Defaults to "search_api_facets".
+ *
+ * @return bool
+ * TRUE if the feature is supported by the index's server (and the index is
+ * currently enabled), FALSE otherwise.
+ */
+function _search_api_facetapi_index_support_feature(SearchApiIndex $index, $feature = 'search_api_facets') {
+ try {
+ $server = $index->server();
+ return $server && $server->supportsFeature($feature);
+ }
+ catch (SearchApiException $e) {
+ return FALSE;
+ }
+}
+
+/**
+ * Gets hierarchy information for taxonomy terms.
+ *
+ * Used as a hierarchy callback in search_api_facetapi_facetapi_facet_info().
+ *
+ * Internally just uses facetapi_get_taxonomy_hierarchy(), but makes sure that
+ * our special "!" value is not passed.
+ *
+ * @param array $values
+ * An array containing the term IDs.
+ *
+ * @return array
+ * An associative array mapping term IDs to parent IDs (where parents could be
+ * found).
+ */
+function search_api_facetapi_get_taxonomy_hierarchy(array $values) {
+ $values = array_filter($values, 'is_numeric');
+ return $values ? facetapi_get_taxonomy_hierarchy($values) : array();
+}
+
/**
* Map callback for all search_api facet fields.
*
@@ -298,13 +392,13 @@ function search_api_facetapi_facet_map_callback(array $values, array $options =
$lower = isset($map[$range['lower']]) ? $map[$range['lower']] : $range['lower'];
$upper = isset($map[$range['upper']]) ? $map[$range['upper']] : $range['upper'];
if ($lower == '*' && $upper == '*') {
- $map[$value] = t('any');
+ $map[$value] = t('any');
}
elseif ($lower == '*') {
- $map[$value] = "< $upper";
+ $map[$value] = "≤ $upper";
}
elseif ($upper == '*') {
- $map[$value] = "> $lower";
+ $map[$value] = "≥ $lower";
}
else {
$map[$value] = "$lower – $upper";
@@ -316,25 +410,49 @@ function search_api_facetapi_facet_map_callback(array $values, array $options =
/**
* Creates a human-readable label for single facet filter values.
+ *
+ * @param array $values
+ * The values for which labels should be returned.
+ * @param array $options
+ * An associative array containing the following information about the facet:
+ * - field: Field information, as stored in the index, but with an additional
+ * "key" property set to the field's internal name.
+ * - index id: The machine name of the index for this facet.
+ * - map callback: (optional) A callback that will be called at the beginning,
+ * which allows initial mapping of filters. Only values not mapped by that
+ * callback will be processed by this method.
+ * - value callback: A callback used to map single values and the limits of
+ * ranges. The signature is the same as for this function, but all values
+ * will be single values.
+ * - missing label: (optional) The label used for the "missing" facet.
+ *
+ * @return array
+ * An array mapping raw facet values to their labels.
*/
function _search_api_facetapi_facet_create_label(array $values, array $options) {
$field = $options['field'];
+ $map = array();
+ $n = count($values);
+
// For entities, we can simply use the entity labels.
if (isset($field['entity_type'])) {
$type = $field['entity_type'];
$entities = entity_load($type, $values);
- $map = array();
foreach ($entities as $id => $entity) {
$label = entity_label($type, $entity);
- if ($label) {
+ if ($label !== FALSE) {
$map[$id] = $label;
}
}
- return $map;
+ if (count($map) == $n) {
+ return $map;
+ }
}
+
// Then, we check whether there is an options list for the field.
$index = search_api_index_load($options['index id']);
$wrapper = $index->entityWrapper();
+ $values = drupal_map_assoc($values);
foreach (explode(':', $field['key']) as $part) {
if (!isset($wrapper->$part)) {
$wrapper = NULL;
@@ -345,12 +463,18 @@ function _search_api_facetapi_facet_create_label(array $values, array $options)
$wrapper = $wrapper[0];
}
}
- if ($wrapper && ($options = $wrapper->optionsList('view'))) {
- return $options;
+ if ($wrapper && ($options_list = $wrapper->optionsList('view'))) {
+ // We have no use for empty strings, as then the facet links would be
+ // invisible.
+ $map += array_intersect_key(array_filter($options_list, 'strlen'), $values);
+ if (count($map) == $n) {
+ return $map;
+ }
}
- // As a "last resort" we try to create a label based on the field type.
- $map = array();
- foreach ($values as $value) {
+
+ // As a "last resort" we try to create a label based on the field type, for
+ // all values that haven't got a mapping yet.
+ foreach (array_diff_key($values, $map) as $value) {
switch ($field['type']) {
case 'boolean':
$map[$value] = $value ? t('true') : t('false');
@@ -382,3 +506,126 @@ function search_api_facetapi_search_api_admin_index_fields_submit($form, &$form_
$cid = 'facetapi:facet_info:search_api@' . $form_state['index']->machine_name . ':';
cache_clear_all($cid, 'cache', TRUE);
}
+
+/**
+ * Computes the granularity of a date facet filter.
+ *
+ * @param $filter
+ * The filter value to examine.
+ *
+ * @return string|null
+ * Either one of the FACETAPI_DATE_* constants corresponding to the
+ * granularity of the filter, or NULL if it couldn't be computed.
+ */
+function search_api_facetapi_date_get_granularity($filter) {
+ // Granularity corresponds to number of dashes in filter value.
+ $units = array(
+ FACETAPI_DATE_YEAR,
+ FACETAPI_DATE_MONTH,
+ FACETAPI_DATE_DAY,
+ FACETAPI_DATE_HOUR,
+ FACETAPI_DATE_MINUTE,
+ FACETAPI_DATE_SECOND,
+ );
+ $count = substr_count($filter, '-');
+ return isset($units[$count]) ? $units[$count] : NULL;
+}
+
+/**
+ * Returns the date format used for a given granularity.
+ *
+ * @param $granularity
+ * One of the FACETAPI_DATE_* constants.
+ *
+ * @return string
+ * The date format used for the given granularity.
+ */
+function search_api_facetapi_date_get_granularity_format($granularity) {
+ $formats = array(
+ FACETAPI_DATE_YEAR => 'Y',
+ FACETAPI_DATE_MONTH => 'Y-m',
+ FACETAPI_DATE_DAY => 'Y-m-d',
+ FACETAPI_DATE_HOUR => 'Y-m-d-H',
+ FACETAPI_DATE_MINUTE => 'Y-m-d-H-i',
+ FACETAPI_DATE_SECOND => 'Y-m-d-H-i-s',
+ );
+ return $formats[$granularity];
+}
+
+/**
+ * Constructs labels for date facet filter values.
+ *
+ * @param array $values
+ * The date facet filter values, as used in URL parameters.
+ * @param array $options
+ * (optional) Options for creating the mapping. The following options are
+ * recognized:
+ * - format callback: A callback for creating a label for a timestamp. The
+ * function signature is like search_api_facetapi_format_timestamp(),
+ * receiving a timestamp and one of the FACETAPI_DATE_* constants as the
+ * parameters and returning a human-readable label.
+ *
+ * @return array
+ * An array of labels for the given facet filters.
+ */
+function search_api_facetapi_map_date(array $values, array $options = array()) {
+ $map = array();
+ foreach ($values as $value) {
+ // Ignore any filters passed directly from the server (range or missing). We
+ // always create filters starting with a year.
+ $value = "$value";
+ if (!$value || !ctype_digit($value[0])) {
+ continue;
+ }
+
+ // Get the granularity of the filter.
+ $granularity = search_api_facetapi_date_get_granularity($value);
+ if (!$granularity) {
+ continue;
+ }
+
+ // Otherwise, parse the timestamp from the known format and format it as a
+ // label.
+ $format = search_api_facetapi_date_get_granularity_format($granularity);
+ // Use the "!" modifier to make the date parsing independent of the current
+ // date/time. (See #2678856.)
+ $date = DateTime::createFromFormat('!' . $format, $value);
+ if (!$date) {
+ continue;
+ }
+ $format_callback = 'search_api_facetapi_format_timestamp';
+ if (!empty($options['format callback']) && is_callable($options['format callback'])) {
+ $format_callback = $options['format callback'];
+ }
+ $map[$value] = call_user_func($format_callback, $date->format('U'), $granularity);
+ }
+ return $map;
+}
+
+/**
+ * Format a date according to the default timezone and the given precision.
+ *
+ * @param int $timestamp
+ * An integer containing the Unix timestamp being formated.
+ * @param string $precision
+ * A string containing the formatting precision. See the FACETAPI_DATE_*
+ * constants for valid values.
+ *
+ * @return string
+ * A human-readable representation of the timestamp.
+ */
+function search_api_facetapi_format_timestamp($timestamp, $precision = FACETAPI_DATE_YEAR) {
+ $formats = array(
+ FACETAPI_DATE_YEAR,
+ FACETAPI_DATE_MONTH,
+ FACETAPI_DATE_DAY,
+ FACETAPI_DATE_HOUR,
+ FACETAPI_DATE_MINUTE,
+ FACETAPI_DATE_SECOND,
+ );
+
+ if (!in_array($precision, $formats)) {
+ $precision = FACETAPI_DATE_YEAR;
+ }
+ return format_date($timestamp, 'search_api_facetapi_' . $precision);
+}
diff --git a/contrib/search_api_views/README.txt b/contrib/search_api_views/README.txt
index bae140fe..50cfce1c 100644
--- a/contrib/search_api_views/README.txt
+++ b/contrib/search_api_views/README.txt
@@ -24,6 +24,37 @@ When these are present, the normal keywords should be ignored and the related
items be returned as results instead. Sorting, filtering and range restriction
should all work normally.
+"Random sort" feature
+---------------------
+This module defines the "Random sort" feature (feature key:
+"search_api_random_sort") that allows to randomly sort the results returned by a
+search. With a server supporting this, you can use the "Global: Random" sort to
+sort the view's results randomly. Every time the query is run a different
+sorting will be provided.
+
+For developers:
+A service class that wants to support this feature has to check for a
+"search_api_random" field in the search query's sorts and insert a random sort
+in that position. If the query is sorted in this way, then the
+"search_api_random_sort" query option can contain additional options for the
+random sort, as an associative array with any of the following keys:
+- seed: A numeric seed value to use for the random sort.
+
+"BETWEEN operator" feature
+--------------------------
+This module defines the "BETWEEN operator" feature (feature key:
+"search_api_between") that adds the "BETWEEN" and "NOT BETWEEN" filter
+operators to search queries. If your search server supports this feature, you
+can use the "Is between" and "Is not between" operators when adding Views
+filters for numeric, string or date types.
+
+For developers:
+A service class that wants to support this feature has to accept "BETWEEN" and
+"NOT BETWEEN" as additional $operator values in query conditions. The value in
+both cases is an array with the keys 0 and 1, with the value under key 0 being
+the lower and the value under key 1 being the upper bound for the range in which
+the field's value should ("BETWEEN") or should not ("NOT BETWEEN") be.
+
"Facets block" display
----------------------
Most features should be clear to users of Views. However, the module also
diff --git a/contrib/search_api_views/includes/display_facet_block.inc b/contrib/search_api_views/includes/display_facet_block.inc
index 39d256b3..00e80c29 100644
--- a/contrib/search_api_views/includes/display_facet_block.inc
+++ b/contrib/search_api_views/includes/display_facet_block.inc
@@ -151,11 +151,9 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
}
}
- public function execute() {
- if (substr($this->view->base_table, 0, 17) != 'search_api_index_') {
- form_set_error('', t('The "Facets block" display can only be used with base tables based on Search API indexes.'));
- return NULL;
- }
+ public function query() {
+ parent::query();
+
$facet_field = $this->get_option('facet_field');
if (!$facet_field) {
return NULL;
@@ -165,7 +163,7 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
if (!$base_path) {
$base_path = $_GET['q'];
}
- $this->view->build();
+
$limit = empty($this->view->query->pager->options['items_per_page']) ? 10 : $this->view->query->pager->options['items_per_page'];
$query_options = &$this->view->query->getOptions();
if (!$this->get_option('hide_block')) {
@@ -179,6 +177,17 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
}
$query_options['search_api_base_path'] = $base_path;
$this->view->query->range(0, 0);
+ }
+
+ public function render() {
+ if (substr($this->view->base_table, 0, 17) != 'search_api_index_') {
+ form_set_error('', t('The "Facets block" display can only be used with base tables based on Search API indexes.'));
+ return NULL;
+ }
+ $facet_field = $this->get_option('facet_field');
+ if (!$facet_field) {
+ return NULL;
+ }
$this->view->execute();
@@ -229,7 +238,7 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
// Initializes variables passed to theme hook.
$variables = array(
'text' => $name,
- 'path' => $base_path,
+ 'path' => $this->view->query->getOption('search_api_base_path'),
'count' => $term['count'],
'options' => array(
'attributes' => array('class' => 'facetapi-inactive'),
@@ -238,6 +247,31 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
),
);
+ // Override the $variables['#path'] if facetapi_pretty_paths is enabled.
+ if (module_exists('facetapi_pretty_paths')) {
+ // Get the appropriate facet adapter.
+ $adapter = facetapi_adapter_load('search_api@' . $index->machine_name);
+
+ // Get the URL processor and check if it uses pretty paths.
+ $urlProcessor = $adapter->getUrlProcessor();
+ if ($urlProcessor instanceof FacetapiUrlProcessorPrettyPaths) {
+ // Retrieve the pretty path alias from the URL processor.
+ $facet = facetapi_facet_load($facet_field, 'search_api@' . $index->machine_name);
+ $values = array(trim($term['filter'], '"'));
+
+ // Get the pretty path for the facet and remove the current search's
+ // base path from it.
+ $base_path_current = $urlProcessor->getBasePath();
+ $pretty_path = $urlProcessor->getFacetPath($facet, $values, FALSE);
+ $pretty_path = str_replace($base_path_current, '', $pretty_path);
+
+ // Set the new, pretty path for the facet and remove the "f" query
+ // parameter.
+ $variables['path'] = $variables['path'] . $pretty_path;
+ unset($variables['options']['query']['f']);
+ }
+ }
+
// Themes the link, adds row to facets.
$facets[] = array(
'class' => array('leaf'),
@@ -249,10 +283,16 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
return NULL;
}
- $info['content']['facets'] = array(
+ return array(
+ 'facets' => array(
'#theme' => 'item_list',
'#items' => $facets,
+ )
);
+ }
+
+ public function execute() {
+ $info['content'] = $this->render();
$info['content']['more'] = $this->render_more_link();
$info['subject'] = filter_xss_admin($this->view->get_title());
return $info;
diff --git a/contrib/search_api_views/includes/handler_argument.inc b/contrib/search_api_views/includes/handler_argument.inc
index 7891e548..a11a662b 100644
--- a/contrib/search_api_views/includes/handler_argument.inc
+++ b/contrib/search_api_views/includes/handler_argument.inc
@@ -1,5 +1,10 @@
value)) {
+ $this->fillValue();
+ if ($this->value === FALSE) {
+ $this->abort();
+ return;
+ }
+ }
+
+ $outer_conjunction = strtoupper($this->operator);
+
+ if (empty($this->options['not'])) {
+ $operator = '=';
+ $inner_conjunction = 'OR';
+ }
+ else {
+ $operator = '<>';
+ $inner_conjunction = 'AND';
+ }
+
+ if (!empty($this->value)) {
+ if (!empty($this->value)) {
+ $outer_filter = $this->query->createFilter($outer_conjunction);
+ foreach ($this->value as $value) {
+ $value_filter = $this->query->createFilter($inner_conjunction);
+ $values = explode(';', $value);
+ $values = array_map(array($this, 'getTimestamp'), $values);
+ if (in_array(FALSE, $values, TRUE)) {
+ $this->abort();
+ return;
+ }
+ $is_range = (count($values) > 1);
+
+ $inner_filter = ($is_range ? $this->query->createFilter('AND') : $value_filter);
+ $range_op = (empty($this->options['not']) ? '>=' : '<');
+ $inner_filter->condition($this->real_field, $values[0], $is_range ? $range_op : $operator);
+ if ($is_range) {
+ $range_op = (empty($this->options['not']) ? '<=' : '>');
+ $inner_filter->condition($this->real_field, $values[1], $range_op);
+ $value_filter->filter($inner_filter);
+ }
+ $outer_filter->filter($value_filter);
+ }
+
+ $this->query->filter($outer_filter);
+ }
+ }
+ }
+
+ /**
+ * Converts a value to a timestamp, if it isn't one already.
+ *
+ * @param string|int $value
+ * The value to convert. Either a timestamp, or a date/time string as
+ * recognized by strtotime().
+ *
+ * @return int|false
+ * The parsed timestamp, or FALSE if an illegal string was passed.
+ */
+ public function getTimestamp($value) {
+ if (is_numeric($value)) {
+ return $value;
+ }
+
+ return strtotime($value);
+ }
+
+ /**
+ * Fills $this->value with data from the argument.
+ */
+ protected function fillValue() {
+ if (!empty($this->options['break_phrase'])) {
+ // Set up defaults:
+ if (!isset($this->value)) {
+ $this->value = array();
+ }
+
+ if (!isset($this->operator)) {
+ $this->operator = 'OR';
+ }
+
+ if (empty($this->argument)) {
+ return;
+ }
+
+ if (preg_match('/^([-\d;:\s]+\+)*[-\d;:\s]+$/', $this->argument)) {
+ // The '+' character in a query string may be parsed as ' '.
+ $this->value = explode('+', $this->argument);
+ }
+ elseif (preg_match('/^([-\d;:\s]+,)*[-\d;:\s]+$/', $this->argument)) {
+ $this->operator = 'AND';
+ $this->value = explode(',', $this->argument);
+ }
+
+ // Keep an 'error' value if invalid strings were given.
+ if (!empty($this->argument) && (empty($this->value) || !is_array($this->value))) {
+ $this->value = FALSE;
+ }
+ }
+ else {
+ $this->value = array($this->argument);
+ }
+ }
+
+ /**
+ * Aborts the associated query due to an illegal argument.
+ */
+ protected function abort() {
+ $variables['!field'] = $this->definition['group'] . ': ' . $this->definition['title'];
+ $this->query->abort(t('Illegal argument passed to !field contextual filter.', $variables));
+ }
+
+ /**
+ * Computes the title this argument will assign the view, given the argument.
+ *
+ * @return string
+ * A title fitting for the passed argument.
+ */
+ public function title() {
+ if (!empty($this->argument)) {
+ if (empty($this->value)) {
+ $this->fillValue();
+ }
+ $dates = array();
+ foreach ($this->value as $date) {
+ $date_parts = explode(';', $date);
+
+ $ts = $this->getTimestamp($date_parts[0]);
+ $datestr = format_date($ts, 'short');
+ if (count($date_parts) > 1) {
+ $ts = $this->getTimestamp($date_parts[1]);
+ $datestr .= ' - ' . format_date($ts, 'short');
+ }
+
+ if ($datestr) {
+ $dates[] = $datestr;
+ }
+ }
+
+ return $dates ? implode(', ', $dates) : check_plain($this->argument);
+ }
+
+ return check_plain($this->argument);
+ }
+
+}
diff --git a/contrib/search_api_views/includes/handler_argument_fulltext.inc b/contrib/search_api_views/includes/handler_argument_fulltext.inc
index a2b3f553..a4db8e25 100644
--- a/contrib/search_api_views/includes/handler_argument_fulltext.inc
+++ b/contrib/search_api_views/includes/handler_argument_fulltext.inc
@@ -1,5 +1,10 @@
options['fields']) {
- $this->query->fields($this->options['fields']);
+ try {
+ $this->query->fields($this->options['fields']);
+ }
+ catch (SearchApiException $e) {
+ $this->query->abort($e->getMessage());
+ return;
+ }
}
if ($this->options['conjunction'] != 'AND') {
$this->query->setOption('conjunction', $this->options['conjunction']);
diff --git a/contrib/search_api_views/includes/handler_argument_more_like_this.inc b/contrib/search_api_views/includes/handler_argument_more_like_this.inc
index 12b3598e..df526e4b 100644
--- a/contrib/search_api_views/includes/handler_argument_more_like_this.inc
+++ b/contrib/search_api_views/includes/handler_argument_more_like_this.inc
@@ -1,5 +1,10 @@
FALSE);
$options['fields'] = array('default' => array());
return $options;
}
@@ -26,6 +32,20 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
unset($form['not']);
$index = search_api_index_load(substr($this->table, 17));
+
+ if ($index->datasource() instanceof SearchApiCombinedEntityDataSourceController) {
+ $types = array_intersect_key(search_api_entity_type_options_list(), array_flip($index->options['datasource']['types']));
+ $form['entity_type'] = array(
+ '#type' => 'select',
+ '#title' => t('Entity type'),
+ '#description' => t('Select the entity type of the argument.'),
+ '#options' => $types,
+ '#default_value' => $this->options['entity_type'],
+ '#required' => TRUE,
+ );
+ }
+
+
if (!empty($index->options['fields'])) {
$fields = array();
foreach ($index->getFields() as $key => $field) {
@@ -57,24 +77,38 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
* The argument sent may be found at $this->argument.
*/
public function query($group_by = FALSE) {
- $server = $this->query->getIndex()->server();
- if (!$server->supportsFeature('search_api_mlt')) {
- $class = search_api_get_service_info($server->class);
- watchdog('search_api_views', 'The search service "@class" does not offer "More like this" functionality.',
+ try {
+ $server = $this->query->getIndex()->server();
+ if (!$server->supportsFeature('search_api_mlt')) {
+ $class = search_api_get_service_info($server->class);
+ watchdog('search_api_views', 'The search service "@class" does not offer "More like this" functionality.',
array('@class' => $class['name']), WATCHDOG_ERROR);
- $this->query->abort();
- return;
- }
- $fields = $this->options['fields'] ? $this->options['fields'] : array();
- if (empty($fields)) {
- foreach ($this->query->getIndex()->options['fields'] as $key => $field) {
- $fields[] = $key;
+ $this->query->abort();
+ return;
+ }
+ $index_fields = array_keys($this->query->getIndex()->options['fields']);
+ if (empty($this->options['fields'])) {
+ $fields = $index_fields;
+ }
+ else {
+ $fields = array_intersect($this->options['fields'], $index_fields);
+ }
+ if ($this->query->getIndex()->datasource() instanceof SearchApiCombinedEntityDataSourceController) {
+ $id = $this->options['entity_type'] . '/' . $this->argument;
+ }
+ else {
+ $id = $this->argument;
}
+
+ $mlt = array(
+ 'id' => $id,
+ 'fields' => $fields,
+ );
+ $this->query->getSearchApiQuery()->setOption('search_api_mlt', $mlt);
+ }
+ catch (SearchApiException $e) {
+ $this->query->abort($e->getMessage());
}
- $mlt = array(
- 'id' => $this->argument,
- 'fields' => $fields,
- );
- $this->query->getSearchApiQuery()->setOption('search_api_mlt', $mlt);
}
+
}
diff --git a/contrib/search_api_views/includes/handler_argument_string.inc b/contrib/search_api_views/includes/handler_argument_string.inc
index b63ed39d..f932ff0e 100644
--- a/contrib/search_api_views/includes/handler_argument_string.inc
+++ b/contrib/search_api_views/includes/handler_argument_string.inc
@@ -1,5 +1,10 @@
fillValue();
}
+ $outer_conjunction = strtoupper($this->operator);
+
if (empty($this->options['not'])) {
$operator = '=';
- $conjunction = 'OR';
+ $inner_conjunction = 'OR';
}
else {
$operator = '<>';
- $conjunction = 'AND';
+ $inner_conjunction = 'AND';
}
if (!empty($this->value)) {
$terms = entity_load('taxonomy_term', $this->value);
- $vocabularies = taxonomy_vocabulary_get_names();
if (!empty($terms)) {
- $filter = $this->query->createFilter($conjunction);
+ $filter = $this->query->createFilter($outer_conjunction);
$vocabulary_fields = $this->definition['vocabulary_fields'];
$vocabulary_fields += array('' => array());
foreach ($terms as $term) {
+ $inner_filter = $filter;
+ if ($outer_conjunction != $inner_conjunction) {
+ $inner_filter = $this->query->createFilter($inner_conjunction);
+ }
// Set filters for all term reference fields which don't specify a
// vocabulary, as well as for all fields specifying the term's
// vocabulary.
if (!empty($this->definition['vocabulary_fields'][$term->vocabulary_machine_name])) {
foreach ($this->definition['vocabulary_fields'][$term->vocabulary_machine_name] as $field) {
- $filter->condition($field, $term->tid, $operator);
+ $inner_filter->condition($field, $term->tid, $operator);
}
}
foreach ($vocabulary_fields[''] as $field) {
- $filter->condition($field, $term->tid, $operator);
+ $inner_filter->condition($field, $term->tid, $operator);
+ }
+ if ($outer_conjunction != $inner_conjunction) {
+ $filter->filter($inner_filter);
}
}
diff --git a/contrib/search_api_views/includes/handler_filter.inc b/contrib/search_api_views/includes/handler_filter.inc
index 85c66745..770526a8 100644
--- a/contrib/search_api_views/includes/handler_filter.inc
+++ b/contrib/search_api_views/includes/handler_filter.inc
@@ -1,5 +1,10 @@
t('Is smaller than'),
- '<=' => t('Is smaller than or equal to'),
+ '<' => t('Is less than'),
+ '<=' => t('Is less than or equal to'),
'=' => t('Is equal to'),
'<>' => t('Is not equal to'),
'>=' => t('Is greater than or equal to'),
@@ -46,8 +51,8 @@ class SearchApiViewsHandlerFilter extends views_handler_filter {
* Provide a form for setting the filter value.
*/
public function value_form(&$form, &$form_state) {
- while (is_array($this->value)) {
- $this->value = $this->value ? array_shift($this->value) : NULL;
+ while (is_array($this->value) && count($this->value) < 2) {
+ $this->value = $this->value ? reset($this->value) : NULL;
}
$form['value'] = array(
'#type' => 'textfield',
@@ -58,10 +63,19 @@ class SearchApiViewsHandlerFilter extends views_handler_filter {
// Hide the value box if the operator is 'empty' or 'not empty'.
// Radios share the same selector so we have to add some dummy selector.
- $form['value']['#states']['visible'] = array(
- ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
- ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
- );
+ if (empty($form_state['exposed'])) {
+ $form['value']['#states']['visible'] = array(
+ ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
+ ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
+ );
+ }
+ elseif (!empty($this->options['expose']['use_operator'])) {
+ $name = $this->options['expose']['operator_id'];
+ $form['value']['#states']['visible'] = array(
+ ':input[name="' . $name . '"],dummy-empty' => array('!value' => 'empty'),
+ ':input[name="' . $name . '"],dummy-not-empty' => array('!value' => 'not empty'),
+ );
+ }
}
/**
diff --git a/contrib/search_api_views/includes/handler_filter_boolean.inc b/contrib/search_api_views/includes/handler_filter_boolean.inc
index b3b21727..2ff1f25e 100644
--- a/contrib/search_api_views/includes/handler_filter_boolean.inc
+++ b/contrib/search_api_views/includes/handler_filter_boolean.inc
@@ -1,5 +1,10 @@
array('default' => 'default'),
+ 'date_popup_format' => array('default' => 'm/d/Y'),
+ 'year_range' => array('default' => '-3:+3'),
);
}
@@ -29,14 +36,49 @@ class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
*/
public function extra_options_form(&$form, &$form_state) {
parent::extra_options_form($form, $form_state);
+
if (module_exists('date_popup')) {
- $widget_options = array('default' => 'Default', 'date_popup' => 'Date popup');
+ $widget_options = array(
+ 'default' => 'Default',
+ 'date_popup' => 'Date popup',
+ );
$form['widget_type'] = array(
'#type' => 'radios',
'#title' => t('Date selection form element'),
'#default_value' => $this->options['widget_type'],
'#options' => $widget_options,
);
+ $form['date_popup_format'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Date format'),
+ '#default_value' => $this->options['date_popup_format'],
+ '#description' => t('A date in any format understood by PHP. For example, "Y-m-d" or "m/d/Y".', array(
+ '@doc-link' => 'http://php.net/manual/en/function.date.php'
+ )),
+ '#states' => array(
+ 'visible' => array(
+ ':input[name="options[widget_type]"]' => array('value' => 'date_popup'),
+ ),
+ ),
+ );
+ }
+
+ if (module_exists('date_api')) {
+ $form['year_range'] = array(
+ '#type' => 'date_year_range',
+ '#default_value' => $this->options['year_range'],
+ );
+ }
+ }
+
+ /**
+ * Validate extra options.
+ */
+ public function extra_options_validate($form, &$form_state) {
+ if (isset($form_state['values']['options']['year_range'])) {
+ if (!preg_match('/^(?:\-[0-9]{1,4}|[0-9]{4}):(?:[\+|\-][0-9]{1,4}|[0-9]{4})$/', $form_state['values']['options']['year_range'])) {
+ form_error($form['year_range'], t('Date year range must be in the format -9:+9, 2005:2010, -9:2010, or 2005:+9'));
+ }
}
}
@@ -46,11 +88,25 @@ class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
public function value_form(&$form, &$form_state) {
parent::value_form($form, $form_state);
+ $is_date_popup = ($this->options['widget_type'] == 'date_popup' && module_exists('date_popup'));
+
+ // If the operator is between
+ if ($this->operator == 'between') {
+ if ($is_date_popup) {
+ $form['value']['min']['#type'] = 'date_popup';
+ $form['value']['min']['#date_format'] = $this->options['date_popup_format'];
+ $form['value']['min']['#date_year_range'] = $this->options['year_range'];
+ $form['value']['max']['#type'] = 'date_popup';
+ $form['value']['max']['#date_format'] = $this->options['date_popup_format'];
+ $form['value']['max']['#date_year_range'] = $this->options['year_range'];
+ }
+ }
// If we are using the date popup widget, overwrite the settings of the form
// according to what date_popup expects.
- if ($this->options['widget_type'] == 'date_popup' && module_exists('date_popup')) {
+ elseif ($is_date_popup) {
$form['value']['#type'] = 'date_popup';
- $form['value']['#date_format'] = 'm/d/Y';
+ $form['value']['#date_format'] = $this->options['date_popup_format'];
+ $form['value']['#date_year_range'] = $this->options['year_range'];
unset($form['value']['#description']);
}
elseif (empty($form_state['exposed'])) {
@@ -72,11 +128,31 @@ class SearchApiViewsHandlerFilterDate extends SearchApiViewsHandlerFilter {
elseif ($this->operator === 'not empty') {
$this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
}
- else {
- while (is_array($this->value)) {
- $this->value = $this->value ? reset($this->value) : NULL;
+ elseif (in_array($this->operator, array('between', 'not between'), TRUE)) {
+ $min = isset($this->value[0]['min']) ? $this->value[0]['min'] : '';
+ if ($min !== '') {
+ $min = is_numeric($min) ? $min : strtotime($min, REQUEST_TIME);
+ }
+ $max = isset($this->value[0]['max']) ? $this->value[0]['max'] : '';
+ if ($max !== '') {
+ $max = is_numeric($max) ? $max : strtotime($max, REQUEST_TIME);
}
- $v = is_numeric($this->value) ? $this->value : strtotime($this->value, REQUEST_TIME);
+
+ if (is_numeric($min) && is_numeric($max)) {
+ $this->query->condition($this->real_field, array($min, $max), strtoupper($this->operator), $this->options['group']);
+ }
+ elseif (is_numeric($min)) {
+ $operator = $this->operator === 'between' ? '>=' : '<';
+ $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+ }
+ elseif (is_numeric($max)) {
+ $operator = $this->operator === 'between' ? '<=' : '>';
+ $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+ }
+ }
+ else {
+ $value = isset($this->value[0]) ? $this->value[0]['value'] : $this->value['value'];
+ $v = is_numeric($value) ? $value : strtotime($value, REQUEST_TIME);
if ($v !== FALSE) {
$this->query->condition($this->real_field, $v, $this->operator, $this->options['group']);
}
diff --git a/contrib/search_api_views/includes/handler_filter_entity.inc b/contrib/search_api_views/includes/handler_filter_entity.inc
new file mode 100644
index 00000000..ce5c753d
--- /dev/null
+++ b/contrib/search_api_views/includes/handler_filter_entity.inc
@@ -0,0 +1,207 @@
+ $this->isMultiValued() ? t('Is one of') : t('Is'),
+ 'all of' => t('Is all of'),
+ '<>' => $this->isMultiValued() ? t('Is not one of') : t('Is not'),
+ 'empty' => t('Is empty'),
+ 'not empty' => t('Is not empty'),
+ );
+ if (!$this->isMultiValued()) {
+ unset($operators['all of']);
+ }
+ return $operators;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value_form(&$form, &$form_state) {
+ parent::value_form($form, $form_state);
+
+ if (!is_array($this->value)) {
+ $this->value = $this->value ? array($this->value) : array();
+ }
+
+ // Set the correct default value in case the admin-set value is used (and a
+ // value is present). The value is used if the form is either not exposed,
+ // or the exposed form wasn't submitted yet. (There doesn't seem to be an
+ // easier way to check for that.)
+ if ($this->value && (empty($form_state['input']) || !empty($form_state['input']['live_preview']))) {
+ $form['value']['#default_value'] = $this->ids_to_strings($this->value);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value_validate($form, &$form_state) {
+ if (!empty($form['value'])) {
+ $value = &$form_state['values']['options']['value'];
+ if (strlen($value)) {
+ $values = $this->isMultiValued($form_state['values']['options']) ? drupal_explode_tags($value) : array($value);
+ $ids = $this->validate_entity_strings($form['value'], $values);
+
+ if ($ids) {
+ $value = $ids;
+ }
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function accept_exposed_input($input) {
+ $rc = parent::accept_exposed_input($input);
+
+ if ($rc) {
+ // If we have previously validated input, override.
+ if ($this->validated_exposed_input) {
+ $this->value = $this->validated_exposed_input;
+ }
+ }
+
+ return $rc;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exposed_validate(&$form, &$form_state) {
+ if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+ return;
+ }
+
+ $this->validated_exposed_input = FALSE;
+ $identifier = $this->options['expose']['identifier'];
+ $input = $form_state['values'][$identifier];
+
+ if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+ $this->operator = $this->options['group_info']['group_items'][$input]['operator'];
+ $input = $this->options['group_info']['group_items'][$input]['value'];
+ }
+
+ if (!strlen($input)) {
+ return;
+ }
+ $values = $this->isMultiValued() ? drupal_explode_tags($input) : array($input);
+
+ if (!$this->options['is_grouped'] || ($this->options['is_grouped'] && ($input != 'All'))) {
+ $this->validated_exposed_input = $this->validate_entity_strings($form[$identifier], $values);
+ }
+ }
+
+ /**
+ * Determines whether multiple user names can be entered into this filter.
+ *
+ * This is either the case if the form isn't exposed, or if the " Allow
+ * multiple selections" option is enabled.
+ *
+ * @param array $options
+ * (optional) The options array to use. If not supplied, the options set on
+ * this filter will be used.
+ *
+ * @return bool
+ * TRUE if multiple values can be entered for this filter, FALSE otherwise.
+ */
+ protected function isMultiValued(array $options = array()) {
+ $options = $options ? $options : $this->options;
+ return empty($options['exposed']) || !empty($options['expose']['multiple']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function admin_summary() {
+ if (!is_array($this->value)) {
+ $this->value = $this->value ? array($this->value) : array();
+ }
+ $value = $this->value;
+ $this->value = empty($value) ? '' : $this->ids_to_strings($value);
+ $ret = parent::admin_summary();
+ $this->value = $value;
+ return $ret;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query() {
+ if ($this->operator === 'empty') {
+ $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
+ }
+ elseif ($this->operator === 'not empty') {
+ $this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
+ }
+ elseif (is_array($this->value)) {
+ $all_of = $this->operator === 'all of';
+ $operator = $all_of ? '=' : $this->operator;
+ if (count($this->value) == 1) {
+ $this->query->condition($this->real_field, reset($this->value), $operator, $this->options['group']);
+ }
+ else {
+ $filter = $this->query->createFilter($operator === '<>' || $all_of ? 'AND' : 'OR');
+ foreach ($this->value as $value) {
+ $filter->condition($this->real_field, $value, $operator);
+ }
+ $this->query->filter($filter, $this->options['group']);
+ }
+ }
+ }
+
+}
diff --git a/contrib/search_api_views/includes/handler_filter_fulltext.inc b/contrib/search_api_views/includes/handler_filter_fulltext.inc
index 952a81c5..dcb3e263 100644
--- a/contrib/search_api_views/includes/handler_filter_fulltext.inc
+++ b/contrib/search_api_views/includes/handler_filter_fulltext.inc
@@ -1,5 +1,10 @@
'keys');
+ $options['min_length'] = array('default' => '');
$options['fields'] = array('default' => array());
return $options;
@@ -75,6 +81,70 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
if (isset($form['expose'])) {
$form['expose']['#weight'] = -5;
}
+
+ $form['min_length'] = array(
+ '#title' => t('Minimum keyword length'),
+ '#description' => t('Minimum length of each word in the search keys. Leave empty to allow all words.'),
+ '#type' => 'textfield',
+ '#element_validate' => array('element_validate_integer_positive'),
+ '#default_value' => $this->options['min_length'],
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exposed_validate(&$form, &$form_state) {
+ // Only validate exposed input.
+ if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+ return;
+ }
+
+ // We only need to validate if there is a minimum word length set.
+ if ($this->options['min_length'] < 2) {
+ return;
+ }
+
+ $identifier = $this->options['expose']['identifier'];
+ $input = &$form_state['values'][$identifier];
+
+ if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+ $this->operator = $this->options['group_info']['group_items'][$input]['operator'];
+ $input = &$this->options['group_info']['group_items'][$input]['value'];
+ }
+
+ // If there is no input, we're fine.
+ if (!trim($input)) {
+ return;
+ }
+
+ $words = preg_split('/\s+/', $input);
+ $quoted = FALSE;
+ foreach ($words as $i => $word) {
+ $word_length = drupal_strlen($word);
+ if (!$word_length) {
+ unset($words[$i]);
+ continue;
+ }
+ // Protect quoted strings.
+ if ($quoted && $word[strlen($word) - 1] === '"') {
+ $quoted = FALSE;
+ continue;
+ }
+ if ($quoted || $word[0] === '"') {
+ $quoted = TRUE;
+ continue;
+ }
+ if ($word_length < $this->options['min_length']) {
+ unset($words[$i]);
+ }
+ }
+ if (!$words) {
+ $vars['@count'] = $this->options['min_length'];
+ $msg = t('You must include at least one positive keyword with @count characters or more.', $vars);
+ form_error($form[$identifier], $msg);
+ }
+ $input = implode(' ', $words);
}
/**
@@ -89,7 +159,8 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
return;
}
$fields = $this->options['fields'];
- $fields = $fields ? $fields : array_keys($this->getFulltextFields());
+ $available_fields = array_keys($this->getFulltextFields());
+ $fields = $fields ? array_intersect($fields, $available_fields) : $available_fields;
// If something already specifically set different fields, we silently fall
// back to mere filtering.
@@ -101,21 +172,29 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
if ($filter) {
$filter = $this->query->createFilter('OR');
+ $op = $this->operator === 'NOT' ? '<>' : '=';
foreach ($fields as $field) {
- $filter->condition($field, $this->value, $this->operator);
+ $filter->condition($field, $this->value, $op);
}
$this->query->filter($filter);
return;
}
- // If the operator was set to OR, set it as the conjunction. (AND is set by
- // default.)
- if ($this->operator === 'OR') {
- $this->query->setOption('conjunction', $this->operator);
+ // If the operator was set to OR or NOT, set OR as the conjunction. (It is
+ // also set for NOT since otherwise it would be "not all of these words".)
+ if ($this->operator != 'AND') {
+ $this->query->setOption('conjunction', 'OR');
}
- $this->query->fields($fields);
- $old = $this->query->getOriginalKeys();
+ try {
+ $this->query->fields($fields);
+ }
+ catch (SearchApiException $e) {
+ $this->query->abort($e->getMessage());
+ return;
+ }
+ $old = $this->query->getKeys();
+ $old_original = $this->query->getOriginalKeys();
$this->query->keys($this->value);
if ($this->operator == 'NOT') {
$keys = &$this->query->getKeys();
@@ -126,16 +205,44 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
// We can't know how negation is expressed in the server's syntax.
}
}
+
+ // If there were fulltext keys set, we take care to combine them in a
+ // meaningful way (especially with negated keys).
if ($old) {
$keys = &$this->query->getKeys();
+ // Array-valued keys are combined.
if (is_array($keys)) {
- $keys[] = $old;
+ // If the old keys weren't parsed into an array, we instead have to
+ // combine the original keys.
+ if (is_scalar($old)) {
+ $keys = "($old) ({$this->value})";
+ }
+ else {
+ // If the conjunction or negation settings aren't the same, we have to
+ // nest both old and new keys array.
+ if (!empty($keys['#negation']) != !empty($old['#negation']) || $keys['#conjunction'] != $old['#conjunction']) {
+ $keys = array(
+ '#conjunction' => 'AND',
+ $old,
+ $keys,
+ );
+ }
+ // Otherwise, just add all individual words from the old keys to the
+ // new ones.
+ else {
+ foreach (element_children($old) as $i) {
+ $keys[] = $old[$i];
+ }
+ }
+ }
}
- elseif (is_array($old)) {
- // We don't support such nonsense.
- }
- else {
- $keys = "($old) ($keys)";
+ // If the parse mode was "direct" for both old and new keys, we
+ // concatenate them and set them both via method and reference (to also
+ // update the originalKeys property.
+ elseif (is_scalar($old_original)) {
+ $combined_keys = "($old_original) ($keys)";
+ $this->query->keys($combined_keys);
+ $keys = $combined_keys;
}
}
}
diff --git a/contrib/search_api_views/includes/handler_filter_language.inc b/contrib/search_api_views/includes/handler_filter_language.inc
index f95ddaf9..3202cdbc 100644
--- a/contrib/search_api_views/includes/handler_filter_language.inc
+++ b/contrib/search_api_views/includes/handler_filter_language.inc
@@ -14,26 +14,17 @@
class SearchApiViewsHandlerFilterLanguage extends SearchApiViewsHandlerFilterOptions {
/**
- * Provide a form for setting options.
+ * {@inheritdoc}
*/
- public function value_form(&$form, &$form_state) {
- parent::value_form($form, $form_state);
- $form['value']['#options'] = array(
- 'current' => t("Current user's language"),
- 'default' => t('Default site language'),
- ) + $form['value']['#options'];
- }
-
- /**
- * Provides a summary of this filter's value for the admin UI.
- */
- public function admin_summary() {
- $tmp = $this->definition['options'];
- $this->definition['options']['current'] = t('current');
- $this->definition['options']['default'] = t('default');
- $ret = parent::admin_summary();
- $this->definition['options'] = $tmp;
- return $ret;
+ protected function get_value_options() {
+ parent::get_value_options();
+ $options = array();
+ if (module_exists('language_hierarchy')) {
+ $options['fallback'] = t("Current user's language with fallback");
+ }
+ $options['current'] = t("Current user's language");
+ $options['default'] = t('Default site language');
+ $this->value_options = $options + $this->value_options;
}
/**
@@ -41,6 +32,10 @@ class SearchApiViewsHandlerFilterLanguage extends SearchApiViewsHandlerFilterOpt
*/
public function query() {
global $language_content;
+
+ if (!is_array($this->value)) {
+ $this->value = $this->value ? array($this->value) : array();
+ }
foreach ($this->value as $i => $v) {
if ($v == 'current') {
$this->value[$i] = $language_content->language;
@@ -48,6 +43,11 @@ class SearchApiViewsHandlerFilterLanguage extends SearchApiViewsHandlerFilterOpt
elseif ($v == 'default') {
$this->value[$i] = language_default('language');
}
+ elseif ($v == 'fallback' && module_exists('language_hierarchy')) {
+ $fallbacks = array($language_content->language => $language_content->language);
+ $fallbacks += array_keys(language_hierarchy_get_ancestors($language_content->language));
+ $this->value[$i] = drupal_map_assoc($fallbacks);
+ }
}
parent::query();
}
diff --git a/contrib/search_api_views/includes/handler_filter_numeric.inc b/contrib/search_api_views/includes/handler_filter_numeric.inc
new file mode 100644
index 00000000..398b2e5b
--- /dev/null
+++ b/contrib/search_api_views/includes/handler_filter_numeric.inc
@@ -0,0 +1,209 @@
+ array(
+ 'value' => array('default' => ''),
+ 'min' => array('default' => ''),
+ 'max' => array('default' => ''),
+ ),
+ );
+
+ return $options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function operator_options() {
+ $operators = parent::operator_options();
+
+ $index = search_api_index_load(substr($this->table, 17));
+ $server = NULL;
+ try {
+ if ($index) {
+ $server = $index->server();
+ }
+ }
+ catch (SearchApiException $e) {
+ // Ignore.
+ }
+ if ($server && $server->supportsFeature('search_api_between')) {
+ $operators += array(
+ 'between' => t('Is between'),
+ 'not between' => t('Is not between'),
+ );
+ }
+
+ return $operators;
+ }
+
+ /**
+ * Provides a form for setting the filter value.
+ *
+ * Heavily borrowed from views_handler_filter_numeric.
+ *
+ * @see views_handler_filter_numeric::value_form()
+ */
+ public function value_form(&$form, &$form_state) {
+ $form['value']['#tree'] = TRUE;
+
+ $single_field_operators = $this->operator_options();
+ unset($single_field_operators['empty'], $single_field_operators['not empty'], $single_field_operators['between']);
+
+ // We have to make some choices when creating this as an exposed
+ // filter form. For example, if the operator is locked and thus
+ // not rendered, we can't render dependencies; instead we only
+ // render the form items we need.
+ $which = 'all';
+ if (!empty($form['operator'])) {
+ $source = ($form['operator']['#type'] == 'radios') ? 'radio:options[operator]' : 'edit-options-operator';
+ }
+
+ if (!empty($form_state['exposed'])) {
+ $identifier = $this->options['expose']['identifier'];
+ if (empty($this->options['expose']['use_operator']) || empty($this->options['expose']['operator_id'])) {
+ // Exposed and locked.
+ $which = ($this->operator == 'between') ? 'minmax' : 'value';
+ }
+ else {
+ $source = 'edit-' . drupal_html_id($this->options['expose']['operator_id']);
+ }
+ }
+
+ // Hide the value box if the operator is 'empty' or 'not empty'.
+ // Radios share the same selector so we have to add some dummy selector.
+ if ($which == 'all') {
+ $form['value']['value'] = array(
+ '#type' => 'textfield',
+ '#title' => empty($form_state['exposed']) ? t('Value') : '',
+ '#size' => 30,
+ '#default_value' => $this->value['value'],
+ '#dependency' => array($source => array_keys($single_field_operators)),
+ );
+ if (!empty($form_state['exposed']) && !isset($form_state['input'][$identifier]['value'])) {
+ $form_state['input'][$identifier]['value'] = $this->value['value'];
+ }
+ }
+ elseif ($which == 'value') {
+ // When exposed we drop the value-value and just do value if
+ // the operator is locked.
+ $form['value'] = array(
+ '#type' => 'textfield',
+ '#title' => empty($form_state['exposed']) ? t('Value') : '',
+ '#size' => 30,
+ '#default_value' => isset($this->value['value']) ? $this->value['value'] : '',
+ );
+ if (!empty($form_state['exposed']) && !isset($form_state['input'][$identifier])) {
+ $form_state['input'][$identifier] = isset($this->value['value']) ? $this->value['value'] : '';
+ }
+ }
+
+ if ($which == 'all' || $which == 'minmax') {
+ $form['value']['min'] = array(
+ '#type' => 'textfield',
+ '#title' => empty($form_state['exposed']) ? t('Min') : '',
+ '#size' => 30,
+ '#default_value' => $this->value['min'],
+ );
+ $form['value']['max'] = array(
+ '#type' => 'textfield',
+ '#title' => empty($form_state['exposed']) ? t('And max') : t('And'),
+ '#size' => 30,
+ '#default_value' => $this->value['max'],
+ );
+
+ if ($which == 'all') {
+ $form['value']['min']['#dependency'] = array($source => array('between'));
+ $form['value']['max']['#dependency'] = array($source => array('between'));
+ }
+
+ if (!empty($form_state['exposed']) && !isset($form_state['input'][$identifier]['min'])) {
+ $form_state['input'][$identifier]['min'] = $this->value['min'];
+ }
+ if (!empty($form_state['exposed']) && !isset($form_state['input'][$identifier]['max'])) {
+ $form_state['input'][$identifier]['max'] = $this->value['max'];
+ }
+
+ if (!isset($form['value']['value'])) {
+ // Ensure there is something in the 'value'.
+ $form['value']['value'] = array(
+ '#type' => 'value',
+ '#value' => NULL,
+ );
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function admin_summary() {
+ if (!empty($this->options['exposed'])) {
+ return t('exposed');
+ }
+
+ if ($this->operator === 'empty') {
+ return t('is empty');
+ }
+ if ($this->operator === 'not empty') {
+ return t('is not empty');
+ }
+
+ $value = isset($this->value[0]) ? $this->value[0] : $this->value;
+
+ if (in_array($this->operator, array('between', 'not between'), TRUE)) {
+ // This is of course wrong for translation purposes, but copied from
+ // views_handler_filter_numeric::admin_summary() so probably still better
+ // to re-use this than to do it correctly.
+ $operator = $this->operator === 'between' ? t('between') : t('not between');
+ $vars = array(
+ '@min' => (string) $value['min'],
+ '@max' => (string) $value['max'],
+ );
+ return $operator . ' ' . t('@min and @max', $vars);
+ }
+
+ return check_plain((string) $this->operator) . ' ' . check_plain((string) $value['value']);
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query() {
+ if (in_array($this->operator, array('between', 'not between'), TRUE)) {
+ $min = isset($this->value[0]['min']) ? $this->value[0]['min'] : '';
+ $max = isset($this->value[0]['max']) ? $this->value[0]['max'] : '';
+ if ($min !== '' && $max !== '') {
+ $this->query->condition($this->real_field, array($min, $max), strtoupper($this->operator), $this->options['group']);
+ }
+ elseif ($min !== '') {
+ $operator = $this->operator === 'between' ? '>=' : '<';
+ $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+ }
+ elseif ($max !== '') {
+ $operator = $this->operator === 'between' ? '<=' : '>';
+ $this->query->condition($this->real_field, $min, $operator, $this->options['group']);
+ }
+ }
+ else {
+ parent::query();
+ }
+ }
+
+}
diff --git a/contrib/search_api_views/includes/handler_filter_options.inc b/contrib/search_api_views/includes/handler_filter_options.inc
index 8e1361d1..2184fc85 100644
--- a/contrib/search_api_views/includes/handler_filter_options.inc
+++ b/contrib/search_api_views/includes/handler_filter_options.inc
@@ -1,16 +1,82 @@
query) {
+ $index = $this->query->getIndex();
+ }
+ elseif (substr($this->view->base_table, 0, 17) == 'search_api_index_') {
+ $index = search_api_index_load(substr($this->view->base_table, 17));
+ }
+ else {
+ return NULL;
+ }
+ $wrapper = $index->entityWrapper(NULL, TRUE);
+ $parts = explode(':', $this->real_field);
+ foreach ($parts as $i => $part) {
+ if (!isset($wrapper->$part)) {
+ return NULL;
+ }
+ $wrapper = $wrapper->$part;
+ $info = $wrapper->info();
+ if ($i < count($parts) - 1) {
+ // Unwrap lists.
+ $level = search_api_list_nesting_level($info['type']);
+ for ($j = 0; $j < $level; ++$j) {
+ $wrapper = $wrapper[0];
+ }
+ }
+ }
+
+ return $wrapper;
+ }
+
+ /**
+ * Fills the value_options property with all possible options.
+ */
+ protected function get_value_options() {
+ if (isset($this->value_options)) {
+ return;
+ }
+
+ $wrapper = $this->get_wrapper();
+ if ($wrapper) {
+ $this->value_options = $wrapper->optionsList('view');
+ }
+ else {
+ $this->value_options = array();
+ }
+ }
+
/**
* Provide a list of options for the operator form.
*/
@@ -55,6 +121,7 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
*/
public function option_definition() {
$options = parent::option_definition();
+ $options['value'] = array('default' => '');
$options['expose']['contains']['reduce'] = array('default' => FALSE);
return $options;
}
@@ -63,13 +130,12 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
* Reduce the options according to the selection.
*/
protected function reduce_value_options() {
- $options = array();
- foreach ($this->definition['options'] as $id => $option) {
- if (isset($this->options['value'][$id])) {
- $options[$id] = $option;
+ foreach ($this->value_options as $id => $option) {
+ if (!isset($this->options['value'][$id])) {
+ unset($this->value_options[$id]);
}
}
- return $options;
+ return $this->value_options;
}
/**
@@ -92,27 +158,38 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
* Provide a form for setting options.
*/
public function value_form(&$form, &$form_state) {
- $options = array();
+ $this->get_value_options();
if (!empty($this->options['expose']['reduce']) && !empty($form_state['exposed'])) {
- $options += $this->reduce_value_options($form_state);
+ $options = $this->reduce_value_options();
}
else {
- $options += $this->definition['options'];
+ $options = $this->value_options;
}
+
$form['value'] = array(
'#type' => $this->value_form_type,
'#title' => empty($form_state['exposed']) ? t('Value') : '',
'#options' => $options,
'#multiple' => TRUE,
- '#size' => min(4, count($this->definition['options'])),
+ '#size' => min(4, count($options)),
'#default_value' => is_array($this->value) ? $this->value : array(),
);
- // Hide the value box if operator is 'empty' or 'not empty'.
+
+ // Hide the value box if the operator is 'empty' or 'not empty'.
// Radios share the same selector so we have to add some dummy selector.
- $form['value']['#states']['visible'] = array(
- ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
- ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
- );
+ if (empty($form_state['exposed'])) {
+ $form['value']['#states']['visible'] = array(
+ ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
+ ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
+ );
+ }
+ elseif (!empty($this->options['expose']['use_operator'])) {
+ $name = $this->options['expose']['operator_id'];
+ $form['value']['#states']['visible'] = array(
+ ':input[name="' . $name . '"],dummy-empty' => array('!value' => 'empty'),
+ ':input[name="' . $name . '"],dummy-not-empty' => array('!value' => 'not empty'),
+ );
+ }
}
/**
@@ -139,8 +216,9 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
$values = '';
// Remove every element which is not known.
+ $this->get_value_options();
foreach ($this->value as $i => $value) {
- if (!isset($this->definition['options'][$value])) {
+ if (!isset($this->value_options[$value])) {
unset($this->value[$i]);
}
}
@@ -161,7 +239,7 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
}
// If there is only a single value, use just the plain operator, = or <>.
$operator = check_plain($operator);
- $values = check_plain($this->definition['options'][reset($this->value)]);
+ $values = check_plain($this->value_options[reset($this->value)]);
}
else {
foreach ($this->value as $value) {
@@ -172,13 +250,39 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
$values .= '…';
break;
}
- $values .= check_plain($this->definition['options'][$value]);
+ $values .= check_plain($this->value_options[$value]);
}
}
return $operator . (($values !== '') ? ' ' . $values : '');
}
+ /**
+ * {@inheritdoc}
+ */
+ function accept_exposed_input($input) {
+ $accepted = parent::accept_exposed_input($input);
+
+ // Grouped filters will have the raw form values structure from the
+ // checkboxes as the value here. Convert that into the correct array of
+ // values instead.
+ if ($accepted && is_array($this->value) && $this->is_a_group()) {
+ // For some reason, Views thinks it's a good idea to nest the form values
+ // into a second array in some cases. That one will be numerically indexed
+ // with just a single entry, though, so it should be relatively easy to
+ // spot.
+ if (count($this->value) && isset($this->value[0])) {
+ $this->value = reset($this->value);
+ }
+ $this->value = array_keys(array_filter($this->value));
+ if (!$this->value) {
+ return FALSE;
+ }
+ }
+
+ return $accepted;
+ }
+
/**
* Add this filter to the query.
*/
@@ -197,28 +301,24 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
$this->value = reset($this->value);
}
- // Determine operator and conjunction.
+ // Determine operator and conjunction. The defaults are already right for
+ // "all of".
+ $operator = '=';
+ $conjunction = 'AND';
switch ($this->operator) {
case '=':
- $operator = '=';
$conjunction = 'OR';
break;
- case 'all of':
- $operator = '=';
- $conjunction = 'AND';
- break;
-
case '<>':
$operator = '<>';
- $conjunction = 'AND';
break;
}
// If the value is an empty array, we either want no filter at all (for
- // "is none of", or want to find only items with no value for the field.
+ // "is none of"), or want to find only items with no value for the field.
if ($this->value === array()) {
- if ($this->operator != '<>') {
+ if ($operator != '<>') {
$this->query->condition($this->real_field, NULL, '=', $this->options['group']);
}
return;
diff --git a/contrib/search_api_views/includes/handler_filter_taxonomy_term.inc b/contrib/search_api_views/includes/handler_filter_taxonomy_term.inc
new file mode 100644
index 00000000..51982b0e
--- /dev/null
+++ b/contrib/search_api_views/includes/handler_filter_taxonomy_term.inc
@@ -0,0 +1,335 @@
+definition['vocabulary']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function option_definition() {
+ $options = parent::option_definition();
+
+ $options['type'] = array('default' => !empty($this->definition['vocabulary']) ? 'textfield' : 'select');
+ $options['hierarchy'] = array('default' => 0);
+ $options['expose']['contains']['reduce'] = array('default' => FALSE);
+ $options['error_message'] = array('default' => TRUE, 'bool' => TRUE);
+
+ return $options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function extra_options_form(&$form, &$form_state) {
+ $form['type'] = array(
+ '#type' => 'radios',
+ '#title' => t('Selection type'),
+ '#options' => array('select' => t('Dropdown'), 'textfield' => t('Autocomplete')),
+ '#default_value' => $this->options['type'],
+ );
+
+ $form['hierarchy'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Show hierarchy in dropdown'),
+ '#default_value' => !empty($this->options['hierarchy']),
+ );
+ $form['hierarchy']['#states']['visible'][':input[name="options[type]"]']['value'] = 'select';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value_form(&$form, &$form_state) {
+ parent::value_form($form, $form_state);
+
+ if (!empty($this->definition['vocabulary'])) {
+ $vocabulary = taxonomy_vocabulary_machine_name_load($this->definition['vocabulary']);
+ $title = t('Select terms from vocabulary @voc', array('@voc' => $vocabulary->name));
+ }
+ else {
+ $vocabulary = FALSE;
+ $title = t('Select terms');
+ }
+ $form['value']['#title'] = $title;
+
+ if ($vocabulary && $this->options['type'] == 'textfield') {
+ $form['value']['#autocomplete_path'] = 'admin/views/ajax/autocomplete/taxonomy/' . $vocabulary->vid;
+ }
+ else {
+ if ($vocabulary && !empty($this->options['hierarchy'])) {
+ $tree = taxonomy_get_tree($vocabulary->vid, 0, NULL, TRUE);
+ $options = array();
+
+ if ($tree) {
+ foreach ($tree as $term) {
+ $choice = new stdClass();
+ $choice->option = array($term->tid => str_repeat('-', $term->depth) . check_plain(entity_label('taxonomy_term', $term)));
+ $options[] = $choice;
+ }
+ }
+ }
+ else {
+ $options = array();
+ $query = db_select('taxonomy_term_data', 'td');
+ $query->innerJoin('taxonomy_vocabulary', 'tv', 'td.vid = tv.vid');
+ $query->fields('td');
+ $query->orderby('tv.weight');
+ $query->orderby('tv.name');
+ $query->orderby('td.weight');
+ $query->orderby('td.name');
+ $query->addTag('taxonomy_term_access');
+ if ($vocabulary) {
+ $query->condition('tv.machine_name', $vocabulary->machine_name);
+ }
+ $result = $query->execute();
+ $tids = array();
+
+ foreach ($result as $term) {
+ $tids[] = $term->tid;
+ }
+ $terms = taxonomy_term_load_multiple($tids);
+
+ foreach ($terms as $term) {
+ $options[$term->tid] = check_plain(entity_label('taxonomy_term', $term));
+ }
+ }
+
+ $default_value = (array) $this->value;
+
+ if (!empty($form_state['exposed'])) {
+ $identifier = $this->options['expose']['identifier'];
+
+ if (!empty($this->options['expose']['reduce'])) {
+ $options = $this->reduce_value_options($options);
+
+ if (!empty($this->options['expose']['multiple']) && empty($this->options['expose']['required'])) {
+ $default_value = array();
+ }
+ }
+
+ if (empty($this->options['expose']['multiple'])) {
+ if (empty($this->options['expose']['required']) && (empty($default_value) || !empty($this->options['expose']['reduce']))) {
+ $default_value = 'All';
+ }
+ elseif (empty($default_value)) {
+ $keys = array_keys($options);
+ $default_value = array_shift($keys);
+ }
+ // Due to #1464174 there is a chance that array('') was saved in the
+ // admin ui. Let's choose a safe default value.
+ elseif ($default_value == array('')) {
+ $default_value = 'All';
+ }
+ else {
+ $copy = $default_value;
+ $default_value = array_shift($copy);
+ }
+ }
+ }
+ $form['value']['#type'] = 'select';
+ $form['value']['#multiple'] = TRUE;
+ $form['value']['#options'] = $options;
+ $form['value']['#size'] = min(9, count($options));
+ $form['value']['#default_value'] = $default_value;
+
+ if (!empty($form_state['exposed']) && isset($identifier) && !isset($form_state['input'][$identifier])) {
+ $form_state['input'][$identifier] = $default_value;
+ }
+ }
+ }
+
+ /**
+ * Reduces the available exposed options according to the selection.
+ */
+ protected function reduce_value_options(array $options) {
+ foreach ($options as $id => $option) {
+ if (empty($this->options['value'][$id])) {
+ unset($options[$id]);
+ }
+ }
+ return $options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function value_validate($form, &$form_state) {
+ // We only validate if they've chosen the text field style.
+ if ($this->options['type'] != 'textfield') {
+ return;
+ }
+
+ parent::value_validate($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function accept_exposed_input($input) {
+ if (empty($this->options['exposed'])) {
+ return TRUE;
+ }
+
+ // We need to know the operator, which is normally set in
+ // views_handler_filter::accept_exposed_input(), before we actually call
+ // the parent version of ourselves.
+ if (!empty($this->options['expose']['use_operator']) && !empty($this->options['expose']['operator_id']) && isset($input[$this->options['expose']['operator_id']])) {
+ $this->operator = $input[$this->options['expose']['operator_id']];
+ }
+
+ // If view is an attachment and is inheriting exposed filters, then assume
+ // exposed input has already been validated.
+ if (!empty($this->view->is_attachment) && $this->view->display_handler->uses_exposed()) {
+ $this->validated_exposed_input = (array) $this->view->exposed_raw_input[$this->options['expose']['identifier']];
+ }
+
+ // If we're checking for EMPTY or NOT, we don't need any input, and we can
+ // say that our input conditions are met by just having the right operator.
+ if ($this->operator == 'empty' || $this->operator == 'not empty') {
+ return TRUE;
+ }
+
+ // If it's non-required and there's no value don't bother filtering.
+ if (!$this->options['expose']['required'] && empty($this->validated_exposed_input)) {
+ return FALSE;
+ }
+
+ return parent::accept_exposed_input($input);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function exposed_validate(&$form, &$form_state) {
+ if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) {
+ return;
+ }
+
+ // We only validate if they've chosen the text field style.
+ if ($this->options['type'] != 'textfield') {
+ $input = $form_state['values'][$this->options['expose']['identifier']];
+ if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) {
+ $input = $this->options['group_info']['group_items'][$input]['value'];
+ }
+
+ if ($input != 'All') {
+ $this->validated_exposed_input = (array) $input;
+ }
+ return;
+ }
+
+ parent::exposed_validate($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function expose_options() {
+ parent::expose_options();
+ $this->options['expose']['reduce'] = FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function validate_entity_strings(array &$form, array $values) {
+ if (empty($values)) {
+ return array();
+ }
+
+ $tids = array();
+ $names = array();
+ $missing = array();
+ foreach ($values as $value) {
+ $missing[strtolower($value)] = TRUE;
+ $names[] = $value;
+ }
+
+ if (!$names) {
+ return FALSE;
+ }
+
+ $query = db_select('taxonomy_term_data', 'td');
+ $query->innerJoin('taxonomy_vocabulary', 'tv', 'td.vid = tv.vid');
+ $query->fields('td');
+ $query->condition('td.name', $names);
+ if (!empty($this->definition['vocabulary'])) {
+ $query->condition('tv.machine_name', $this->definition['vocabulary']);
+ }
+ $query->addTag('taxonomy_term_access');
+ $result = $query->execute();
+ foreach ($result as $term) {
+ unset($missing[strtolower($term->name)]);
+ $tids[] = $term->tid;
+ }
+
+ if ($missing) {
+ if (!empty($this->options['error_message'])) {
+ form_error($form, format_plural(count($missing), 'Unable to find term: @terms', 'Unable to find terms: @terms', array('@terms' => implode(', ', array_keys($missing)))));
+ }
+ else {
+ // Add a bogus TID which will show an empty result for a positive filter
+ // and be ignored for an excluding one.
+ $tids[] = 0;
+ }
+ }
+
+ return $tids;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function expose_form(&$form, &$form_state) {
+ parent::expose_form($form, $form_state);
+
+ if ($this->options['type'] == 'select') {
+ $form['expose']['reduce'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Limit list to selected items'),
+ '#description' => t('If checked, the only items presented to the user will be the ones selected here.'),
+ '#default_value' => $this->options['expose']['reduce'],
+ );
+ }
+ else {
+ $form['error_message'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Display error message'),
+ '#description' => t('Display an error message if one of the entered terms could not be found.'),
+ '#default_value' => $this->options['error_message'],
+ );
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function ids_to_strings(array $ids) {
+ $ids = array_filter($ids);
+ if (!$ids) {
+ return '';
+ }
+ return implode(', ', db_select('taxonomy_term_data', 'td')
+ ->fields('td', array('name'))
+ ->condition('td.tid', $ids)
+ ->execute()
+ ->fetchCol());
+ }
+
+}
diff --git a/contrib/search_api_views/includes/handler_filter_text.inc b/contrib/search_api_views/includes/handler_filter_text.inc
index a14d4a44..83152a1d 100644
--- a/contrib/search_api_views/includes/handler_filter_text.inc
+++ b/contrib/search_api_views/includes/handler_filter_text.inc
@@ -1,5 +1,10 @@
t('contains'), '<>' => t("doesn't contain"));
}
+ /**
+ * Determines whether input from the exposed filters affects this filter.
+ *
+ * Overridden to not treat "All" differently.
+ *
+ * @param array $input
+ * The user input from the exposed filters.
+ *
+ * @return bool
+ * TRUE if the input should change the behavior of this filter.
+ */
+ public function accept_exposed_input($input) {
+ if (empty($this->options['exposed'])) {
+ return TRUE;
+ }
+
+ if (!empty($this->options['expose']['use_operator']) && !empty($this->options['expose']['operator_id']) && isset($input[$this->options['expose']['operator_id']])) {
+ $this->operator = $input[$this->options['expose']['operator_id']];
+ }
+
+ if (!empty($this->options['expose']['identifier'])) {
+ $value = $input[$this->options['expose']['identifier']];
+
+ // Various ways to check for the absence of non-required input.
+ if (empty($this->options['expose']['required'])) {
+ if (($this->operator == 'empty' || $this->operator == 'not empty') && $value === '') {
+ $value = ' ';
+ }
+
+ if (!empty($this->always_multiple) && $value === '') {
+ return FALSE;
+ }
+ }
+
+ if (isset($value)) {
+ $this->value = $value;
+ if (empty($this->always_multiple) && empty($this->options['expose']['multiple'])) {
+ $this->value = array($value);
+ }
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+ }
+
}
diff --git a/contrib/search_api_views/includes/handler_filter_user.inc b/contrib/search_api_views/includes/handler_filter_user.inc
new file mode 100644
index 00000000..6255274f
--- /dev/null
+++ b/contrib/search_api_views/includes/handler_filter_user.inc
@@ -0,0 +1,79 @@
+isMultiValued() ? 'admin/views/ajax/autocomplete/user' : 'user/autocomplete';
+ $form['value']['#autocomplete_path'] = $path;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function ids_to_strings(array $ids) {
+ $names = array();
+ $args[':uids'] = array_filter($ids);
+ if ($args[':uids']) {
+ $result = db_query('SELECT uid, name FROM {users} u WHERE uid IN (:uids)', $args);
+ $result = $result->fetchAllKeyed();
+ }
+ foreach ($ids as $uid) {
+ if (!$uid) {
+ $names[] = variable_get('anonymous', t('Anonymous'));
+ }
+ elseif (isset($result[$uid])) {
+ $names[] = $result[$uid];
+ }
+ }
+ return implode(', ', $names);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function validate_entity_strings(array &$form, array $values) {
+ $uids = array();
+ $missing = array();
+ foreach ($values as $value) {
+ if (drupal_strtolower($value) === drupal_strtolower(variable_get('anonymous', t('Anonymous')))) {
+ $uids[] = 0;
+ }
+ else {
+ $missing[strtolower($value)] = $value;
+ }
+ }
+
+ if (!$missing) {
+ return $uids;
+ }
+
+ $result = db_query("SELECT * FROM {users} WHERE name IN (:names)", array(':names' => array_values($missing)));
+ foreach ($result as $account) {
+ unset($missing[strtolower($account->name)]);
+ $uids[] = $account->uid;
+ }
+
+ if ($missing) {
+ form_error($form, format_plural(count($missing), 'Unable to find user: @users', 'Unable to find users: @users', array('@users' => implode(', ', $missing))));
+ }
+
+ return $uids;
+ }
+
+}
diff --git a/contrib/search_api_views/includes/handler_sort.inc b/contrib/search_api_views/includes/handler_sort.inc
index 463e6555..d7ca1e47 100644
--- a/contrib/search_api_views/includes/handler_sort.inc
+++ b/contrib/search_api_views/includes/handler_sort.inc
@@ -1,5 +1,10 @@
query->orderby);
$sort = &$this->query->getSort();
$sort = array();
+ unset($sort);
+ }
+
+ // If two of the same fields are used for sort, ignore the latter in order
+ // for the prior to take precedence. (Temporary workaround until
+ // https://www.drupal.org/node/2145547 is fixed in Views.)
+ $alreadySorted = $this->query->getSort();
+ if (is_array($alreadySorted) && isset($alreadySorted[$this->real_field])) {
+ return;
+ }
+
+ try {
+ $this->query->sort($this->real_field, $this->options['order']);
+ }
+ catch (SearchApiException $e) {
+ $this->query->abort($e->getMessage());
}
- $this->query->sort($this->real_field, $this->options['order']);
}
}
diff --git a/contrib/search_api_views/includes/plugin_cache.inc b/contrib/search_api_views/includes/plugin_cache.inc
index 890c9701..c6bd41d4 100644
--- a/contrib/search_api_views/includes/plugin_cache.inc
+++ b/contrib/search_api_views/includes/plugin_cache.inc
@@ -77,31 +77,44 @@ class SearchApiViewsCache extends views_plugin_cache_time {
}
/**
- * Overrides views_plugin_cache::get_results_key().
+ * Overrides views_plugin_cache::get_cache_key().
*
- * Use the Search API query as the main source for the key.
+ * Use the Search API query as the main source for the key. Note that in
+ * Views < 3.8, this function does not exist.
*/
- public function get_results_key() {
+ public function get_cache_key($key_data = array()) {
global $user;
if (!isset($this->_results_key)) {
$query = $this->getSearchApiQuery();
$query->preExecute();
- $key_data = array(
+ $key_data += array(
'query' => $query,
'roles' => array_keys($user->roles),
'super-user' => $user->uid == 1, // special caching for super user.
'language' => $GLOBALS['language']->language,
'base_url' => $GLOBALS['base_url'],
+ 'offset' => $this->view->get_current_page() . '*' . $this->view->get_items_per_page() . '+' . $this->view->get_offset(),
);
// Not sure what gets passed in exposed_info, so better include it. All
// other parameters used in the parent method are already reflected in the
// Search API query object we use.
if (isset($_GET['exposed_info'])) {
- $key_data[$key] = $_GET[$key];
+ $key_data['exposed_info'] = $_GET['exposed_info'];
}
+ }
+ $key = drupal_hash_base64(serialize($key_data));
+ return $key;
+ }
- $this->_results_key = $this->view->name . ':' . $this->display->id . ':results:' . md5(serialize($key_data));
+ /**
+ * Overrides views_plugin_cache::get_results_key().
+ *
+ * This is unnecessary for Views >= 3.8.
+ */
+ public function get_results_key() {
+ if (!isset($this->_results_key)) {
+ $this->_results_key = $this->view->name . ':' . $this->display->id . ':results:' . $this->get_cache_key();
}
return $this->_results_key;
diff --git a/contrib/search_api_views/includes/query.inc b/contrib/search_api_views/includes/query.inc
index f695509b..ae58b9a7 100644
--- a/contrib/search_api_views/includes/query.inc
+++ b/contrib/search_api_views/includes/query.inc
@@ -1,5 +1,10 @@
query->sort($selector, $order);
+ if (!$this->errors) {
+ $this->query->sort($selector, $order);
+ }
+ }
+
+ /**
+ * Provides a sorting method as present in the Views default query plugin.
+ *
+ * This is provided so that the "Global: Random" sort included in Views will
+ * work properly with Search API Views. Random sorting is only supported if
+ * the active search server supports the "search_api_random_sort" feature,
+ * though, otherwise the call will be ignored.
+ *
+ * This method can only be used to sort randomly, as would be done with the
+ * default query plugin. All other calls are ignored.
+ *
+ * @param string|null $table
+ * Only "rand" is recognized here, all other calls are ignored.
+ * @param string|null $field
+ * Is ignored and only present for compatibility reasons.
+ * @param string $order
+ * Either "ASC" or "DESC".
+ * @param string|null $alias
+ * Is ignored and only present for compatibility reasons.
+ * @param array $params
+ * The following optional parameters are recognized:
+ * - seed: a predefined seed for the random generator.
+ *
+ * @see views_plugin_query_default::add_orderby()
+ */
+ public function add_orderby($table, $field = NULL, $order = 'ASC', $alias = '', $params = array()) {
+ $server = $this->getIndex()->server();
+ if ($table == 'rand') {
+ if ($server->supportsFeature('search_api_random_sort')) {
+ $this->add_selector_orderby('search_api_random', $order);
+ if ($params) {
+ $this->setOption('search_api_random_sort', $params);
+ }
+ }
+ else {
+ $variables['%server'] = $server->label();
+ watchdog('search_api_views', 'Tried to sort results randomly on server %server which does not support random sorting.', $variables, WATCHDOG_WARNING);
+ }
+ }
}
/**
@@ -164,7 +215,7 @@ class SearchApiViewsQuery extends views_plugin_query {
'#default_value' => $this->options['search_api_bypass_access'],
);
- if (entity_get_info($this->index->item_type)) {
+ if ($this->index && $this->index->getEntityType()) {
$form['entity_access'] = array(
'#type' => 'checkbox',
'#title' => t('Additional access checks on result entities'),
@@ -180,7 +231,6 @@ class SearchApiViewsQuery extends views_plugin_query {
'#options' => array(),
'#default_value' => $this->options['parse_mode'],
);
- $modes = array();
foreach ($this->query->parseModes() as $key => $mode) {
$form['parse_mode']['#options'][$key] = $mode['name'];
if (!empty($mode['description'])) {
@@ -199,6 +249,10 @@ class SearchApiViewsQuery extends views_plugin_query {
* Builds the necessary info to execute the query.
*/
public function build(&$view) {
+ if (!empty($this->errors)) {
+ return;
+ }
+
$this->view = $view;
// Setup the nested filter structure for this query.
@@ -243,16 +297,6 @@ class SearchApiViewsQuery extends views_plugin_query {
$view->init_pager();
$this->pager->query();
- // Views passes sometimes NULL and sometimes the integer 0 for "All" in a
- // pager. If set to 0 items, a string "0" is passed. Therefore, we unset
- // the limit if an empty value OTHER than a string "0" was passed.
- if (!$this->limit && $this->limit !== '0') {
- $this->limit = NULL;
- }
- // Set the range. (We always set this, as there might even be an offset if
- // all items are shown.)
- $this->query->range($this->offset, $this->limit);
-
// Set the search ID, if it was not already set.
if ($this->query->getOption('search id') == get_class($this->query)) {
$this->query->setOption('search id', 'search_api_views:' . $view->name . ':' . $view->current_display);
@@ -262,6 +306,23 @@ class SearchApiViewsQuery extends views_plugin_query {
if (!empty($this->options['search_api_bypass_access'])) {
$this->query->setOption('search_api_bypass_access', TRUE);
}
+
+ // If the View and the Panel conspire to provide an overridden path then
+ // pass that through as the base path.
+ if (!empty($this->view->override_path) && strpos(current_path(), $this->view->override_path) !== 0) {
+ $this->query->setOption('search_api_base_path', $this->view->override_path);
+ }
+
+ // Save query information for Views UI.
+ $view->build_info['query'] = (string) $this->query;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alter(&$view) {
+ parent::alter($view);
+ drupal_alter('search_api_views_query', $view, $this);
}
/**
@@ -284,7 +345,30 @@ class SearchApiViewsQuery extends views_plugin_query {
return;
}
+ // Calculate the "skip result count" option, if it wasn't already set to
+ // FALSE.
+ $skip_result_count = $this->query->getOption('skip result count', TRUE);
+ if ($skip_result_count) {
+ $skip_result_count = !$this->pager || (!$this->pager->use_count_query() && empty($view->get_total_rows));
+ $this->query->setOption('skip result count', $skip_result_count);
+ }
+
try {
+ // Trigger pager pre_execute().
+ if ($this->pager) {
+ $this->pager->pre_execute($this->query);
+ }
+
+ // Views passes sometimes NULL and sometimes the integer 0 for "All" in a
+ // pager. If set to 0 items, a string "0" is passed. Therefore, we unset
+ // the limit if an empty value OTHER than a string "0" was passed.
+ if (!$this->limit && $this->limit !== '0') {
+ $this->limit = NULL;
+ }
+ // Set the range. (We always set this, as there might even be an offset if
+ // all items are shown.)
+ $this->query->range($this->offset, $this->limit);
+
$start = microtime(TRUE);
// Execute the search.
@@ -292,11 +376,13 @@ class SearchApiViewsQuery extends views_plugin_query {
$this->search_api_results = $results;
// Store the results.
- $this->pager->total_items = $view->total_rows = $results['result count'];
- if (!empty($this->pager->options['offset'])) {
- $this->pager->total_items -= $this->pager->options['offset'];
+ if (!$skip_result_count) {
+ $this->pager->total_items = $view->total_rows = $results['result count'];
+ if (!empty($this->pager->options['offset'])) {
+ $this->pager->total_items -= $this->pager->options['offset'];
+ }
+ $this->pager->update_page_info();
}
- $this->pager->update_page_info();
$view->result = array();
if (!empty($results['results'])) {
$this->addResults($results['results'], $view);
@@ -304,11 +390,16 @@ class SearchApiViewsQuery extends views_plugin_query {
// We shouldn't use $results['performance']['complete'] here, since
// extracting the results probably takes considerable time as well.
$view->execute_time = microtime(TRUE) - $start;
+
+ // Trigger pager post_execute().
+ if ($this->pager) {
+ $this->pager->post_execute($view->result);
+ }
}
catch (Exception $e) {
$this->errors[] = $e->getMessage();
// Recursion to get the same error behaviour as above.
- return $this->execute($view);
+ $this->execute($view);
}
}
@@ -317,8 +408,14 @@ class SearchApiViewsQuery extends views_plugin_query {
*
* Used by handlers to flag a fatal error which shouldn't be displayed but
* still lead to the view returning empty and the search not being executed.
+ *
+ * @param string|null $msg
+ * Optionally, a translated, unescaped error message to display.
*/
- public function abort() {
+ public function abort($msg = NULL) {
+ if ($msg) {
+ $this->errors[] = $msg;
+ }
$this->abort = TRUE;
}
@@ -334,9 +431,9 @@ class SearchApiViewsQuery extends views_plugin_query {
// First off, we try to gather as much field values as possible without
// loading any items.
foreach ($results as $id => $result) {
- if (!empty($this->options['entity_access'])) {
- $entity = entity_load($this->index->item_type, array($id));
- if (!entity_access('view', $this->index->item_type, $entity[$id])) {
+ if (!empty($this->options['entity_access']) && ($entity_type = $this->index->getEntityType())) {
+ $entity = $this->index->loadItems(array($id));
+ if (!$entity || !entity_access('view', $entity_type, reset($entity))) {
continue;
}
}
@@ -356,11 +453,11 @@ class SearchApiViewsQuery extends views_plugin_query {
// Gather any fields from the search results.
if (!empty($result['fields'])) {
- $row['_entity_properties'] += $result['fields'];
+ $row['_entity_properties'] += search_api_get_sanitized_field_values($result['fields']);
}
// Check whether we need to extract any properties from the result item.
- $missing_fields = array_diff_key($this->fields, $row);
+ $missing_fields = array_diff_key($this->fields, $row['_entity_properties']);
if ($missing_fields) {
$missing[$id] = $missing_fields;
if (is_object($row['entity'])) {
@@ -378,14 +475,14 @@ class SearchApiViewsQuery extends views_plugin_query {
// Load items of those rows which haven't got all field values, yet.
if (!empty($ids)) {
$items += $this->index->loadItems($ids);
- // $items now includes loaded items, and those already passed in the
- // search results.
- foreach ($items as $id => $item) {
- // Extract item properties.
- $wrapper = $this->index->entityWrapper($item, FALSE);
- $rows[$id]->_entity_properties += $this->extractFields($wrapper, $missing[$id]);
- $rows[$id]->entity = $item;
- }
+ }
+ // $items now includes all loaded items from which fields still need to be
+ // extracted.
+ foreach ($items as $id => $item) {
+ // Extract item properties.
+ $wrapper = $this->index->entityWrapper($item, FALSE);
+ $rows[$id]->_entity_properties += $this->extractFields($wrapper, $missing[$id]);
+ $rows[$id]->entity = $item;
}
// Finally, add all rows to the Views result set.
@@ -450,31 +547,31 @@ class SearchApiViewsQuery extends views_plugin_query {
* query backend.
*/
public function get_result_wrappers($results, $relationship = NULL, $field = NULL) {
- $entity_type = $this->index->getEntityType();
+ $type = $this->index->getEntityType() ? $this->index->getEntityType() : $this->index->item_type;
$wrappers = array();
- $load_entities = array();
+ $load_items = array();
foreach ($results as $row_index => $row) {
- if ($entity_type && isset($row->entity)) {
+ if (isset($row->entity)) {
// If this entity isn't load, register it for pre-loading.
if (!is_object($row->entity)) {
- $load_entities[$row->entity] = $row_index;
+ $load_items[$row->entity] = $row_index;
+ }
+ else {
+ $wrappers[$row_index] = $this->index->entityWrapper($row->entity);
}
-
- $wrappers[$row_index] = $this->index->entityWrapper($row->entity);
}
}
// If the results are entities, we pre-load them to make use of a multiple
// load. (Otherwise, each result would be loaded individually.)
- if (!empty($load_entities)) {
- $entities = entity_load($entity_type, array_keys($load_entities));
- foreach ($entities as $entity_id => $entity) {
- $wrappers[$load_entities[$entity_id]] = $this->index->entityWrapper($entity);
+ if (!empty($load_items)) {
+ $items = $this->index->loadItems(array_keys($load_items));
+ foreach ($items as $id => $item) {
+ $wrappers[$load_items[$id]] = $this->index->entityWrapper($item);
}
}
// Apply the relationship, if necessary.
- $type = $entity_type ? $entity_type : $this->index->item_type;
$selector_suffix = '';
if ($field && ($pos = strrpos($field, ':'))) {
$selector_suffix = substr($field, 0, $pos);
@@ -520,9 +617,9 @@ class SearchApiViewsQuery extends views_plugin_query {
// Query interface methods (proxy to $this->query)
//
- public function createFilter($conjunction = 'AND') {
+ public function createFilter($conjunction = 'AND', $tags = array()) {
if (!$this->errors) {
- return $this->query->createFilter($conjunction);
+ return $this->query->createFilter($conjunction, $tags);
}
}
@@ -620,16 +717,18 @@ class SearchApiViewsQuery extends views_plugin_query {
return $ret;
}
- public function getOption($name) {
+ public function getOption($name, $default = NULL) {
if (!$this->errors) {
- return $this->query->getOption($name);
+ return $this->query->getOption($name, $default);
}
+ return $default;
}
public function setOption($name, $value) {
if (!$this->errors) {
return $this->query->setOption($name, $value);
}
+ return NULL;
}
public function &getOptions() {
diff --git a/contrib/search_api_views/search_api_views.api.php b/contrib/search_api_views/search_api_views.api.php
new file mode 100644
index 00000000..95a92ca2
--- /dev/null
+++ b/contrib/search_api_views/search_api_views.api.php
@@ -0,0 +1,34 @@
+name == 'my_view' && is_numeric($view->exposed_raw_input['title']) && $view->exposed_raw_input['title'] > 0) {
+ // Traverse through the 'where' part of the query.
+ foreach ($query->where as &$condition_group) {
+ foreach ($condition_group['conditions'] as &$condition) {
+ // If this is the part of the query filtering on title, chang the
+ // condition to filter on node ID.
+ if (reset($condition) == 'node.title') {
+ $condition = array('node.nid', $view->exposed_raw_input['title'],'=');
+ }
+ }
+ }
+ }
+}
diff --git a/contrib/search_api_views/search_api_views.info b/contrib/search_api_views/search_api_views.info
index 735ccfa7..9f2655ee 100644
--- a/contrib/search_api_views/search_api_views.info
+++ b/contrib/search_api_views/search_api_views.info
@@ -1,4 +1,3 @@
-
name = Search views
description = Integrates the Search API with Views, enabling users to create views with searches as filters or arguments.
dependencies[] = search_api
@@ -12,14 +11,19 @@ files[] = includes/handler_argument.inc
files[] = includes/handler_argument_fulltext.inc
files[] = includes/handler_argument_more_like_this.inc
files[] = includes/handler_argument_string.inc
+files[] = includes/handler_argument_date.inc
files[] = includes/handler_argument_taxonomy_term.inc
files[] = includes/handler_filter.inc
files[] = includes/handler_filter_boolean.inc
files[] = includes/handler_filter_date.inc
+files[] = includes/handler_filter_entity.inc
files[] = includes/handler_filter_fulltext.inc
files[] = includes/handler_filter_language.inc
+files[] = includes/handler_filter_numeric.inc
files[] = includes/handler_filter_options.inc
+files[] = includes/handler_filter_taxonomy_term.inc
files[] = includes/handler_filter_text.inc
+files[] = includes/handler_filter_user.inc
files[] = includes/handler_sort.inc
files[] = includes/plugin_cache.inc
files[] = includes/query.inc
diff --git a/contrib/search_api_views/search_api_views.install b/contrib/search_api_views/search_api_views.install
index 804d3079..03e610bf 100644
--- a/contrib/search_api_views/search_api_views.install
+++ b/contrib/search_api_views/search_api_views.install
@@ -1,4 +1,5 @@
$view) {
+ foreach (views_get_all_views() as $view) {
if (empty($view->base_table) || empty($table_fields[$view->base_table])) {
continue;
}
@@ -32,7 +33,7 @@ function search_api_views_update_7101() {
$fields = $table_fields[$view->base_table];
$change |= _search_api_views_update_7101_helper($view->base_field, $fields);
if (!empty($view->display)) {
- foreach ($view->display as $key => &$display) {
+ foreach ($view->display as &$display) {
$options = &$display->display_options;
if (isset($options['style_options']['grouping'])) {
$change |= _search_api_views_update_7101_helper($options['style_options']['grouping'], $fields);
@@ -66,8 +67,15 @@ function search_api_views_update_7101() {
/**
* Helper function for replacing field identifiers.
*
- * @return
- * TRUE iff the identifier was changed.
+ * @param $field
+ * Some data to be searched for field names that should be altered. Passed by
+ * reference.
+ * @param array $fields
+ * An array mapping Search API field identifiers (as previously used by Views)
+ * to the new, sanitized Views field identifiers.
+ *
+ * @return bool
+ * TRUE if any data was changed, FALSE otherwise.
*/
function _search_api_views_update_7101_helper(&$field, array $fields) {
if (is_array($field)) {
diff --git a/contrib/search_api_views/search_api_views.module b/contrib/search_api_views/search_api_views.module
index 7a65d730..a927f082 100644
--- a/contrib/search_api_views/search_api_views.module
+++ b/contrib/search_api_views/search_api_views.module
@@ -1,5 +1,10 @@
enabled && $index->original->enabled) {
+ // Check whether index was disabled.
+ $is_enabled = $index->enabled;
+ $was_enabled = $index->original->enabled;
+ if (!$is_enabled && $was_enabled) {
_search_api_views_index_unavailable($index);
+ return;
+ }
+
+ // Check whether the indexed fields changed.
+ $old_fields = $index->original->options + array('fields' => array());
+ $old_fields = $old_fields['fields'];
+ $new_fields = $index->options + array('fields' => array());
+ $new_fields = $new_fields['fields'];
+
+ // If the index was enabled or its fields changed, invalidate the Views cache.
+ if ($is_enabled != $was_enabled || $old_fields != $new_fields) {
+ views_invalidate_cache();
}
}
@@ -30,7 +50,10 @@ function search_api_views_search_api_index_update(SearchApiIndex $index) {
* Implements hook_search_api_index_delete().
*/
function search_api_views_search_api_index_delete(SearchApiIndex $index) {
- _search_api_views_index_unavailable($index);
+ // Only do this if this is a "real" deletion, no revert.
+ if (!$index->hasStatus(ENTITY_IN_CODE)) {
+ _search_api_views_index_unavailable($index);
+ }
}
/**
diff --git a/contrib/search_api_views/search_api_views.views.inc b/contrib/search_api_views/search_api_views.views.inc
index 9079b327..5982465b 100644
--- a/contrib/search_api_views/search_api_views.views.inc
+++ b/contrib/search_api_views/search_api_views.views.inc
@@ -1,12 +1,16 @@
machine_name;
@@ -20,14 +24,16 @@ function search_api_views_views_data() {
'help' => t('Use the %name search index for filtering and retrieving data.', array('%name' => $index->name)),
'query class' => 'search_api_views_query',
);
- if (isset($entity_types[$index->getEntityType()])) {
- $table['table'] += array(
- 'entity type' => $index->getEntityType(),
- 'skip entity load' => TRUE,
- );
- }
+ $table['table']['entity type'] = $index->getEntityType();
+ $table['table']['skip entity load'] = TRUE;
- $wrapper = $index->entityWrapper(NULL, TRUE);
+ try {
+ $wrapper = $index->entityWrapper(NULL, FALSE);
+ }
+ catch (EntityMetadataWrapperException $e) {
+ watchdog_exception('search_api_views', $e, "%type while retrieving metadata for index %index: !message in %function (line %line of %file).", array('%index' => $index->name), WATCHDOG_WARNING);
+ continue;
+ }
// Add field handlers and relationships provided by the Entity API.
foreach ($wrapper as $key => $property) {
@@ -37,6 +43,14 @@ function search_api_views_views_data() {
}
}
+ try {
+ $wrapper = $index->entityWrapper(NULL);
+ }
+ catch (EntityMetadataWrapperException $e) {
+ watchdog_exception('search_api_views', $e, "%type while retrieving metadata for index %index: !message in %function (line %line of %file).", array('%index' => $index->name), WATCHDOG_WARNING);
+ continue;
+ }
+
// Add handlers for all indexed fields.
foreach ($index->getFields() as $key => $field) {
$tmp = $wrapper;
@@ -63,7 +77,7 @@ function search_api_views_views_data() {
if ($group) {
// @todo Entity type label instead of $group?
$table[$id]['group'] = $group;
- $name = t('@field (indexed)', array('@field' => $name));
+ $name = t('!field (indexed)', array('!field' => $name));
}
$table[$id]['title'] = $name;
$table[$id]['help'] = empty($info['description']) ? t('(No information available)') : $info['description'];
@@ -115,8 +129,8 @@ function search_api_views_views_data() {
if (isset($field['entity_type']) && $field['entity_type'] === 'taxonomy_term') {
$field_id = ($pos = strrpos($key, ':')) ? substr($key, $pos + 1) : $key;
$field_info = field_info_field($field_id);
- if (isset($field_info['settings']['allowed_values'][0]['vocabulary'])) {
- $vocabulary_fields[$field_info['settings']['allowed_values'][0]['vocabulary']][] = $key;
+ if ($vocabulary = _search_api_views_get_field_vocabulary($field_info)) {
+ $vocabulary_fields[$vocabulary][] = $key;
}
else {
$vocabulary_fields[''][] = $key;
@@ -139,8 +153,18 @@ function search_api_views_views_data() {
}
/**
- * Helper function that returns an array of handler definitions to add to a
- * views field definition.
+ * Adds handler definitions for a field to a Views data table definition.
+ *
+ * Helper method for search_api_views_views_data().
+ *
+ * @param $id
+ * The internal identifier of the field.
+ * @param array $field
+ * Information about the field.
+ * @param EntityMetadataWrapper $wrapper
+ * A wrapper providing further metadata about the field.
+ * @param array $table
+ * The existing Views data table definition, as a reference.
*/
function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper $wrapper, array &$table) {
$type = $field['type'];
@@ -155,7 +179,7 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
if ($inner_type == 'text') {
$table[$id] += array(
'argument' => array(
- 'handler' => 'SearchApiViewsHandlerArgument',
+ 'handler' => 'SearchApiViewsHandlerArgumentString',
),
'filter' => array(
'handler' => 'SearchApiViewsHandlerFilterText',
@@ -164,9 +188,9 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
return;
}
- if ($options = $wrapper->optionsList('view')) {
+ $info = $wrapper->info();
+ if (isset($info['options list']) && is_callable($info['options list'])) {
$table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterOptions';
- $table[$id]['filter']['options'] = $options;
$table[$id]['filter']['multi-valued'] = search_api_is_list_type($type);
}
elseif ($inner_type == 'boolean') {
@@ -175,6 +199,29 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
elseif ($inner_type == 'date') {
$table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterDate';
}
+ elseif (isset($field['entity_type']) && $field['entity_type'] === 'user') {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterUser';
+ }
+ elseif (isset($field['entity_type']) && $field['entity_type'] === 'taxonomy_term') {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterTaxonomyTerm';
+ $field_info = field_info_field($info['name']);
+ // For the "Parent terms" and "All parent terms" properties, we can
+ // extrapolate the vocabulary from the parent in the selector. (E.g.,
+ // for "field_tags:parent" we can use the information of "field_tags".)
+ // Otherwise, we can't include any vocabulary information.
+ if (!$field_info && ($info['name'] == 'parent' || $info['name'] == 'parents_all')) {
+ if (!empty($table[$id]['real field'])) {
+ $parts = explode(':', $table[$id]['real field']);
+ $field_info = field_info_field($parts[count($parts) - 2]);
+ }
+ }
+ if ($vocabulary = _search_api_views_get_field_vocabulary($field_info)) {
+ $table[$id]['filter']['vocabulary'] = $vocabulary;
+ }
+ }
+ elseif (in_array($inner_type, array('integer', 'decimal', 'duration', 'string'))) {
+ $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterNumeric';
+ }
else {
$table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilter';
}
@@ -182,6 +229,9 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
if ($inner_type == 'string' || $inner_type == 'uri') {
$table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgumentString';
}
+ elseif ($inner_type == 'date') {
+ $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgumentDate';
+ }
else {
$table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgument';
}
@@ -240,3 +290,31 @@ function search_api_views_views_plugins() {
return $ret;
}
+
+/**
+ * Returns the vocabulary machine name of a term field.
+ *
+ * @param array|null $field_info
+ * The field's field info array, or NULL if the field is not provided by the
+ * Field API. See the return value of field_info_field().
+ *
+ * @return string|null
+ * If the field contains taxonomy terms of a single vocabulary (which could be
+ * determined), that vocabulary's machine name; NULL otherwise.
+ */
+function _search_api_views_get_field_vocabulary($field_info) {
+ // Test for "Term reference" fields.
+ if (isset($field_info['settings']['allowed_values'][0]['vocabulary'])) {
+ return $field_info['settings']['allowed_values'][0]['vocabulary'];
+ }
+ // Test for "Entity reference" fields.
+ elseif (isset($field_info['settings']['handler']) && $field_info['settings']['handler'] === 'base') {
+ if (!empty($field_info['settings']['handler_settings']['target_bundles'])) {
+ $bundles = $field_info['settings']['handler_settings']['target_bundles'];
+ if (count($bundles) == 1) {
+ return key($bundles);
+ }
+ }
+ }
+ return NULL;
+}
diff --git a/includes/callback.inc b/includes/callback.inc
index c05260e9..617aee37 100644
--- a/includes/callback.inc
+++ b/includes/callback.inc
@@ -26,7 +26,7 @@ interface SearchApiAlterCallbackInterface {
/**
* Check whether this data-alter callback is applicable for a certain index.
*
- * This can be used for hiding the callback on the index's "Workflow" tab. To
+ * This can be used for hiding the callback on the index's "Filters" tab. To
* avoid confusion, you should only use criteria that are immutable, such as
* the index's entity type. Also, since this is only used for UI purposes, you
* should not completely rely on this to ensure certain index configurations
@@ -182,4 +182,19 @@ abstract class SearchApiAbstractAlterCallback implements SearchApiAlterCallbackI
return array();
}
+ /**
+ * Determines whether the given index contains multiple types of entities.
+ *
+ * @param SearchApiIndex|null $index
+ * (optional) The index to examine. Defaults to the index set for this
+ * plugin.
+ *
+ * @return bool
+ * TRUE if the index is a multi-entity index, FALSE otherwise.
+ */
+ protected function isMultiEntityIndex(SearchApiIndex $index = NULL) {
+ $index = $index ? $index : $this->index;
+ return $index->datasource() instanceof SearchApiCombinedEntityDataSourceController;
+ }
+
}
diff --git a/includes/callback_add_aggregation.inc b/includes/callback_add_aggregation.inc
index 15246863..55ed611c 100644
--- a/includes/callback_add_aggregation.inc
+++ b/includes/callback_add_aggregation.inc
@@ -1,17 +1,48 @@
index->getFields(FALSE);
$field_options = array();
+ $field_properties = array();
foreach ($fields as $name => $field) {
- $field_options[$name] = $field['name'];
+ $field_options[$name] = check_plain($field['name']);
+ $field_properties[$name] = array(
+ '#attributes' => array('title' => $name),
+ '#description' => check_plain($field['description']),
+ );
}
$additional = empty($this->options['fields']) ? array() : $this->options['fields'];
@@ -60,17 +91,31 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
'#required' => TRUE,
);
$form['fields'][$name]['type_descriptions'] = $type_descriptions;
+ $type_selector = ':input[name="callbacks[search_api_alter_add_aggregation][settings][fields][' . $name . '][type]"]';
foreach (array_keys($types) as $type) {
- $form['fields'][$name]['type_descriptions'][$type]['#states']['visible'][':input[name="callbacks[search_api_alter_add_aggregation][settings][fields][' . $name . '][type]"]']['value'] = $type;
+ $form['fields'][$name]['type_descriptions'][$type]['#states']['visible'][$type_selector]['value'] = $type;
}
- $form['fields'][$name]['fields'] = array(
+ $form['fields'][$name]['separator'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Fulltext separator'),
+ '#description' => t('For aggregation type "Fulltext", set the text that should be used to separate the aggregated field values. Use "\t" for tabs and "\n" for newline characters.'),
+ '#default_value' => addcslashes(isset($field['separator']) ? $field['separator'] : "\n\n", "\0..\37\\"),
+ '#states' => array(
+ 'visible' => array(
+ $type_selector => array(
+ 'value' => 'fulltext',
+ ),
+ ),
+ ),
+ );
+ $form['fields'][$name]['fields'] = array_merge($field_properties, array(
'#type' => 'checkboxes',
'#title' => t('Contained fields'),
'#options' => $field_options,
'#default_value' => drupal_map_assoc($field['fields']),
'#attributes' => array('class' => array('search-api-alter-add-aggregation-fields')),
'#required' => TRUE,
- );
+ ));
$form['fields'][$name]['actions'] = array(
'#type' => 'actions',
'remove' => array(
@@ -106,11 +151,12 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
return;
}
foreach ($values['fields'] as $name => $field) {
- $fields = $values['fields'][$name]['fields'] = array_values(array_filter($field['fields']));
unset($values['fields'][$name]['actions']);
+ $fields = $values['fields'][$name]['fields'] = array_values(array_filter($field['fields']));
if ($field['name'] && !$fields) {
form_error($form['fields'][$name]['fields'], t('You have to select at least one field to aggregate. If you want to remove an aggregated field, please delete its name.'));
}
+ $values['fields'][$name]['separator'] = stripcslashes($field['separator']);
}
}
@@ -157,6 +203,7 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
$values = $this->flattenArray($values);
$this->reductionType = $field['type'];
+ $this->fulltextReductionSeparator = isset($field['separator']) ? $field['separator'] : "\n\n";
$item->$name = array_reduce($values, array($this, 'reduce'), NULL);
if ($field['type'] == 'count' && !$item->$name) {
$item->$name = 0;
@@ -173,7 +220,7 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
public function reduce($a, $b) {
switch ($this->reductionType) {
case 'fulltext':
- return isset($a) ? $a . "\n\n" . $b : $b;
+ return isset($a) ? $a . $this->fulltextReductionSeparator . $b : $b;
case 'sum':
return $a + $b;
case 'count':
@@ -184,7 +231,22 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
return isset($a) ? min($a, $b) : $b;
case 'first':
return isset($a) ? $a : $b;
+ case 'first_char':
+ $b = "$b";
+ if (isset($a) || $b === '') {
+ return $a;
+ }
+ return drupal_substr($b, 0, 1);
+ case 'last':
+ return isset($b) ? $b : $a;
+ case 'list':
+ if (!isset($a)) {
+ $a = array();
+ }
+ $a[] = $b;
+ return $a;
}
+ return NULL;
}
/**
@@ -237,10 +299,13 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
/**
* Helper method for getting all available aggregation types.
*
- * @param $info (optional)
- * One of "name", "type" or "description", to indicate what values should be
- * returned for the types. Defaults to "name".
+ * @param string $info
+ * (optional) One of "name", "type" or "description", to indicate what
+ * information should be returned for the types.
*
+ * @return string[]
+ * An associative array of aggregation type identifiers mapped to their
+ * names, data types or descriptions, as requested.
*/
protected function getTypes($info = 'name') {
switch ($info) {
@@ -252,6 +317,9 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
'max' => t('Maximum'),
'min' => t('Minimum'),
'first' => t('First'),
+ 'first_char' => t('First letter'),
+ 'last' => t('Last'),
+ 'list' => t('List'),
);
case 'type':
return array(
@@ -260,7 +328,10 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
'count' => 'integer',
'max' => 'integer',
'min' => 'integer',
- 'first' => 'string',
+ 'first' => 'token',
+ 'first_char' => 'token',
+ 'last' => 'token',
+ 'list' => 'list',
);
case 'description':
return array(
@@ -270,8 +341,12 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
'max' => t('The Maximum aggregation computes the numerically largest contained field value.'),
'min' => t('The Minimum aggregation computes the numerically smallest contained field value.'),
'first' => t('The First aggregation will simply keep the first encountered field value. This is helpful foremost when you know that a list field will only have a single value.'),
+ 'first_char' => t('The "First letter" aggregation uses just the first letter of the first encountered field value as the aggregated value. This can, for example, be used to build a Glossary view.'),
+ 'last' => t('The Last aggregation will simply keep the last encountered field value.'),
+ 'list' => t('The List aggregation collects all field values into a multi-valued field containing all values.'),
);
}
+ return array();
}
/**
@@ -280,6 +355,8 @@ class SearchApiAlterAddAggregation extends SearchApiAbstractAlterCallback {
public function formButtonSubmit(array $form, array &$form_state) {
$button_name = $form_state['triggering_element']['#name'];
if ($button_name == 'op') {
+ // Increment $i until the corresponding field is not set, then create the
+ // field with that number as suffix.
for ($i = 1; isset($this->options['fields']['search_api_aggregation_' . $i]); ++$i) {
}
$this->options['fields']['search_api_aggregation_' . $i] = array(
diff --git a/includes/callback_add_hierarchy.inc b/includes/callback_add_hierarchy.inc
index c21792ea..b9aada53 100644
--- a/includes/callback_add_hierarchy.inc
+++ b/includes/callback_add_hierarchy.inc
@@ -1,7 +1,12 @@
getHierarchicalFields();
}
/**
- * Display a form for configuring this callback.
- *
- * @return array
- * A form array for configuring this callback, or FALSE if no configuration
- * is possible.
+ * {@inheritdoc}
*/
public function configurationForm() {
$options = $this->getHierarchicalFields();
@@ -54,19 +51,7 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
}
/**
- * Submit callback for the form returned by configurationForm().
- *
- * This method should both return the new options and set them internally.
- *
- * @param array $form
- * The form returned by configurationForm().
- * @param array $values
- * The part of the $form_state['values'] array corresponding to this form.
- * @param array $form_state
- * The complete form state.
- *
- * @return array
- * The new options array for this callback.
+ * {@inheritdoc}
*/
public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
// Change the saved type of fields in the index, if necessary.
@@ -74,7 +59,7 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
$fields = &$this->index->options['fields'];
$previous = drupal_map_assoc($this->options['fields']);
foreach ($values['fields'] as $field) {
- list($key, $prop) = explode(':', $field);
+ list($key) = explode(':', $field);
if (empty($previous[$field]) && isset($fields[$key]['type'])) {
$fields[$key]['type'] = 'list<' . search_api_extract_inner_type($fields[$key]['type']) . '>';
$change = TRUE;
@@ -82,7 +67,7 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
}
$new = drupal_map_assoc($values['fields']);
foreach ($previous as $field) {
- list($key, $prop) = explode(':', $field);
+ list($key) = explode(':', $field);
if (empty($new[$field]) && isset($fields[$key]['type'])) {
$w = $this->index->entityWrapper(NULL, FALSE);
if (isset($w->$key)) {
@@ -102,19 +87,11 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
}
/**
- * Alter items before indexing.
- *
- * Items which are removed from the array won't be indexed, but will be marked
- * as clean for future indexing. This could for instance be used to implement
- * some sort of access filter for security purposes (e.g., don't index
- * unpublished nodes or comments).
- *
- * @param array $items
- * An array of items to be altered, keyed by item IDs.
+ * {@inheritdoc}
*/
public function alterItems(array &$items) {
if (empty($this->options['fields'])) {
- return array();
+ return;
}
foreach ($items as $item) {
$wrapper = $this->index->entityWrapper($item, FALSE);
@@ -131,22 +108,13 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
$this->extractHierarchy($child, $prop, $values[$key]);
}
foreach ($values as $key => $value) {
- $item->$key = $value;
+ $item->$key = array_values($value);
}
}
}
/**
- * Declare the properties that are (or can be) added to items with this
- * callback. If a property with this name already exists for an entity it
- * will be overridden, so keep a clear namespace by prefixing the properties
- * with the module name if this is not desired.
- *
- * @see hook_entity_property_info()
- *
- * @return array
- * Information about all additional properties, as specified by
- * hook_entity_property_info() (only the inner "properties" array).
+ * {@inheritdoc}
*/
public function propertyInfo() {
if (empty($this->options['fields'])) {
@@ -188,7 +156,7 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
}
/**
- * Helper method for finding all hierarchical fields of an index's type.
+ * Finds all hierarchical fields for the current index.
*
* @return array
* An array containing all hierarchical fields of the index, structured as
diff --git a/includes/callback_add_url.inc b/includes/callback_add_url.inc
index 097fd41e..cc76b33a 100644
--- a/includes/callback_add_url.inc
+++ b/includes/callback_add_url.inc
@@ -1,12 +1,17 @@
&$item) {
+ foreach ($items as &$item) {
$url = $this->index->datasource()->getItemUrl($item);
if (!$url) {
$item->search_api_url = NULL;
diff --git a/includes/callback_add_viewed_entity.inc b/includes/callback_add_viewed_entity.inc
index 2adcad0e..bb2ae07d 100644
--- a/includes/callback_add_viewed_entity.inc
+++ b/includes/callback_add_viewed_entity.inc
@@ -1,5 +1,10 @@
index->getEntityType();
$mode = empty($this->options['mode']) ? 'full' : $this->options['mode'];
- foreach ($items as $id => &$item) {
+ foreach ($items as &$item) {
// Since we can't really know what happens in entity_view() and render(),
// we use try/catch. This will at least prevent some errors, even though
// it's no protection against fatal errors and the like.
try {
- $render = entity_view($type, array(entity_id($type, $item) => $item), $mode);
+ $render = entity_view($type, array(entity_id($type, $item) => $item), $mode, $item->search_api_language);
$text = render($render);
if (!$text) {
$item->search_api_viewed = NULL;
diff --git a/includes/callback_bundle_filter.inc b/includes/callback_bundle_filter.inc
index e1072b6b..cde8fe27 100644
--- a/includes/callback_bundle_filter.inc
+++ b/includes/callback_bundle_filter.inc
@@ -1,55 +1,113 @@
isMultiEntityIndex($index)) {
+ $info = entity_get_info();
+ foreach ($index->options['datasource']['types'] as $type) {
+ if (isset($info[$type]) && self::hasBundles($info[$type])) {
+ return TRUE;
+ }
+ }
+ return FALSE;
+ }
return $index->getEntityType() && ($info = entity_get_info($index->getEntityType())) && self::hasBundles($info);
}
+ /**
+ * {@inheritdoc}
+ */
public function alterItems(array &$items) {
- $info = entity_get_info($this->index->getEntityType());
- if (self::hasBundles($info) && isset($this->options['bundles'])) {
- $bundles = array_flip($this->options['bundles']);
- $default = (bool) $this->options['default'];
+ if (!$this->supportsIndex($this->index) || !isset($this->options['bundles'])) {
+ return;
+ }
+
+ $multi_entity = $this->isMultiEntityIndex();
+ if ($multi_entity) {
+ $bundle_prop = 'item_bundle';
+ }
+ else {
+ $info = entity_get_info($this->index->getEntityType());
$bundle_prop = $info['entity keys']['bundle'];
- foreach ($items as $id => $item) {
- if (isset($bundles[$item->$bundle_prop]) == $default) {
- unset($items[$id]);
- }
+ }
+
+ $bundles = array_flip($this->options['bundles']);
+ $default = (bool) $this->options['default'];
+
+ foreach ($items as $id => $item) {
+ // Ignore types that have no bundles.
+ if ($multi_entity && !self::hasBundles(entity_get_info($item->item_type))) {
+ continue;
+ }
+ if (isset($bundles[$item->$bundle_prop]) == $default) {
+ unset($items[$id]);
}
}
}
+ /**
+ * {@inheritdoc}
+ */
public function configurationForm() {
- $info = entity_get_info($this->index->getEntityType());
- if (self::hasBundles($info)) {
+ if ($this->supportsIndex($this->index)) {
$options = array();
- foreach ($info['bundles'] as $bundle => $bundle_info) {
- $options[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+ if ($this->isMultiEntityIndex()) {
+ $info = entity_get_info();
+ $unsupported_types = array();
+ foreach ($this->index->options['datasource']['types'] as $type) {
+ if (isset($info[$type]) && self::hasBundles($info[$type])) {
+ foreach ($info[$type]['bundles'] as $bundle => $bundle_info) {
+ $options["$type:$bundle"] = $info[$type]['label'] . ' » ' . $bundle_info['label'];
+ }
+ }
+ else {
+ $unsupported_types[] = isset($info[$type]['label']) ? $info[$type]['label'] : $type;
+ }
+ }
+ if ($unsupported_types) {
+ $form['unsupported_types']['#markup'] = '
' . t('The following entity types do not contain any bundles: @types. All items of those types will therefore be included in the index.', array('@types' => implode(', ', $unsupported_types))) . '
';
+ }
}
- $form = array(
- 'default' => array(
- '#type' => 'radios',
- '#title' => t('Which items should be indexed?'),
- '#default_value' => isset($this->options['default']) ? $this->options['default'] : 1,
- '#options' => array(
- 1 => t('All but those from one of the selected bundles'),
- 0 => t('Only those from the selected bundles'),
- ),
- ),
- 'bundles' => array(
- '#type' => 'select',
- '#title' => t('Bundles'),
- '#default_value' => isset($this->options['bundles']) ? $this->options['bundles'] : array(),
- '#options' => $options,
- '#size' => min(4, count($options)),
- '#multiple' => TRUE,
+ else {
+ $info = entity_get_info($this->index->getEntityType());
+ foreach ($info['bundles'] as $bundle => $bundle_info) {
+ $options[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+ }
+ }
+ if (!empty($this->index->options['datasource']['bundles'])) {
+ $form['message']['#markup'] = '
' . t("Note: This index is already restricted to certain bundles. If you use this data alteration, those will be reduced further. However, the index setting is better supported in the user interface and should therefore be prefered. For example, using this data alteration will not reduce the displayed total number of items to index (even though some of them will not be indexed). Consider creating a new index with appropriate bundle settings instead.") . '
';
+ $included_bundles = array_flip($this->index->options['datasource']['bundles']);
+ $options = array_intersect_key($options, $included_bundles);
+ }
+ $form['default'] = array(
+ '#type' => 'radios',
+ '#title' => t('Which items should be indexed?'),
+ '#default_value' => isset($this->options['default']) ? $this->options['default'] : 1,
+ '#options' => array(
+ 1 => t('All but those from one of the selected bundles'),
+ 0 => t('Only those from the selected bundles'),
),
);
+ $form['bundles'] = array(
+ '#type' => 'select',
+ '#title' => t('Bundles'),
+ '#default_value' => isset($this->options['bundles']) ? $this->options['bundles'] : array(),
+ '#options' => $options,
+ '#size' => min(4, count($options)),
+ '#multiple' => TRUE,
+ );
}
else {
$form = array(
@@ -62,8 +120,13 @@ class SearchApiAlterBundleFilter extends SearchApiAbstractAlterCallback {
}
/**
- * Helper method for figuring out if the entities with the given entity info
- * can be filtered by bundle.
+ * Determines whether a certain entity type has any bundles.
+ *
+ * @param array $entity_info
+ * The entity type's entity_get_info() array.
+ *
+ * @return bool
+ * TRUE if the entity type has bundles, FALSE otherwise.
*/
protected static function hasBundles(array $entity_info) {
return !empty($entity_info['entity keys']['bundle']) && !empty($entity_info['bundles']);
diff --git a/includes/callback_comment_access.inc b/includes/callback_comment_access.inc
new file mode 100644
index 00000000..e6273530
--- /dev/null
+++ b/includes/callback_comment_access.inc
@@ -0,0 +1,46 @@
+getEntityType() === 'comment';
+ }
+
+ /**
+ * Overrides SearchApiAlterNodeAccess::getNode().
+ *
+ * Returns the comment's node, instead of the item (i.e., the comment) itself.
+ */
+ protected function getNode($item) {
+ return node_load($item->nid);
+ }
+
+ /**
+ * Overrides SearchApiAlterNodeAccess::configurationFormSubmit().
+ *
+ * Doesn't index the comment's "Author".
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ $old_status = !empty($form_state['index']->options['data_alter_callbacks']['search_api_alter_comment_access']['status']);
+ $new_status = !empty($form_state['values']['callbacks']['search_api_alter_comment_access']['status']);
+
+ if (!$old_status && $new_status) {
+ $form_state['index']->options['fields']['status']['type'] = 'boolean';
+ }
+
+ return parent::configurationFormSubmit($form, $values, $form_state);
+ }
+
+}
diff --git a/includes/callback_language_control.inc b/includes/callback_language_control.inc
index 0ac481f7..d406d35d 100644
--- a/includes/callback_language_control.inc
+++ b/includes/callback_language_control.inc
@@ -1,5 +1,10 @@
name);
$native = $lang->native;
- $languages[$lang->language] = ($name == $native) ? $name : "$name ($native)";
+ $languages[$lang->language] = check_plain(($name == $native) ? $name : "$name ($native)");
if (!$lang->enabled) {
$languages[$lang->language] .= ' [' . t('disabled') . ']';
}
@@ -98,19 +89,7 @@ class SearchApiAlterLanguageControl extends SearchApiAbstractAlterCallback {
}
/**
- * Submit callback for the form returned by configurationForm().
- *
- * This method should both return the new options and set them internally.
- *
- * @param array $form
- * The form returned by configurationForm().
- * @param array $values
- * The part of the $form_state['values'] array corresponding to this form.
- * @param array $form_state
- * The complete form state.
- *
- * @return array
- * The new options array for this callback.
+ * {@inheritdoc}
*/
public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
$values['languages'] = array_filter($values['languages']);
@@ -118,15 +97,7 @@ class SearchApiAlterLanguageControl extends SearchApiAbstractAlterCallback {
}
/**
- * Alter items before indexing.
- *
- * Items which are removed from the array won't be indexed, but will be marked
- * as clean for future indexing. This could for instance be used to implement
- * some sort of access filter for security purposes (e.g., don't index
- * unpublished nodes or comments).
- *
- * @param array $items
- * An array of items to be altered, keyed by item IDs.
+ * {@inheritdoc}
*/
public function alterItems(array &$items) {
foreach ($items as $i => &$item) {
diff --git a/includes/callback_node_access.inc b/includes/callback_node_access.inc
index 5acc76c1..8bfab494 100644
--- a/includes/callback_node_access.inc
+++ b/includes/callback_node_access.inc
@@ -10,15 +10,9 @@
class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
/**
- * Check whether this data-alter callback is applicable for a certain index.
+ * Overrides SearchApiAbstractAlterCallback::supportsIndex().
*
* Returns TRUE only for indexes on nodes.
- *
- * @param SearchApiIndex $index
- * The index to check for.
- *
- * @return boolean
- * TRUE if the callback can run on the given index; FALSE otherwise.
*/
public function supportsIndex(SearchApiIndex $index) {
// Currently only node access is supported.
@@ -26,15 +20,9 @@ class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
}
/**
- * Declare the properties that are (or can be) added to items with this callback.
+ * Overrides SearchApiAbstractAlterCallback::propertyInfo().
*
* Adds the "search_api_access_node" property.
- *
- * @see hook_entity_property_info()
- *
- * @return array
- * Information about all additional properties, as specified by
- * hook_entity_property_info() (only the inner "properties" array).
*/
public function propertyInfo() {
return array(
@@ -47,15 +35,7 @@ class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
}
/**
- * Alter items before indexing.
- *
- * Items which are removed from the array won't be indexed, but will be marked
- * as clean for future indexing. This could for instance be used to implement
- * some sort of access filter for security purposes (e.g., don't index
- * unpublished nodes or comments).
- *
- * @param array $items
- * An array of items to be altered, keyed by item IDs.
+ * {@inheritdoc}
*/
public function alterItems(array &$items) {
static $account;
@@ -65,30 +45,39 @@ class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
$account = drupal_anonymous_user();
}
- foreach ($items as $nid => &$item) {
+ foreach ($items as $id => $item) {
+ $node = $this->getNode($item);
// Check whether all users have access to the node.
- if (!node_access('view', $item, $account)) {
+ if (!node_access('view', $node, $account)) {
// Get node access grants.
- $result = db_query('SELECT * FROM {node_access} WHERE (nid = 0 OR nid = :nid) AND grant_view = 1', array(':nid' => $item->nid));
+ $result = db_query('SELECT * FROM {node_access} WHERE (nid = 0 OR nid = :nid) AND grant_view = 1', array(':nid' => $node->nid));
- // Store all grants together with it's realms in the item.
+ // Store all grants together with their realms in the item.
foreach ($result as $grant) {
- if (!isset($items[$nid]->search_api_access_node)) {
- $items[$nid]->search_api_access_node = array();
- }
- $items[$nid]->search_api_access_node[] = "node_access_$grant->realm:$grant->gid";
+ $items[$id]->search_api_access_node[] = "node_access_{$grant->realm}:{$grant->gid}";
}
}
else {
// Add the generic view grant if we are not using node access or the
// node is viewable by anonymous users.
- $items[$nid]->search_api_access_node = array('node_access__all');
+ $items[$id]->search_api_access_node = array('node_access__all');
}
}
}
/**
- * Submit callback for the configuration form.
+ * Retrieves the node related to a search item.
+ *
+ * In the default implementation for nodes, the item is already the node.
+ * Subclasses may override this to easily provide node access checks for
+ * items related to nodes.
+ */
+ protected function getNode($item) {
+ return $item;
+ }
+
+ /**
+ * Overrides SearchApiAbstractAlterCallback::configurationFormSubmit().
*
* If the data alteration is being enabled, set "Published" and "Author" to
* "indexed", because both are needed for the node access filter.
diff --git a/includes/callback_role_filter.inc b/includes/callback_role_filter.inc
index ba126d1f..68b8d722 100644
--- a/includes/callback_role_filter.inc
+++ b/includes/callback_role_filter.inc
@@ -16,6 +16,9 @@ class SearchApiAlterRoleFilter extends SearchApiAbstractAlterCallback {
* This plugin only supports indexes containing users.
*/
public function supportsIndex(SearchApiIndex $index) {
+ if ($this->isMultiEntityIndex($index)) {
+ return in_array('user', $index->options['datasource']['types']);
+ }
return $index->getEntityType() == 'user';
}
@@ -23,10 +26,20 @@ class SearchApiAlterRoleFilter extends SearchApiAbstractAlterCallback {
* Implements SearchApiAlterCallbackInterface::alterItems().
*/
public function alterItems(array &$items) {
- $roles = $this->options['roles'];
+ $selected_roles = $this->options['roles'];
$default = (bool) $this->options['default'];
- foreach ($items as $id => $account) {
- $role_match = (count(array_diff_key($account->roles, $roles)) !== count($account->roles));
+ $multi_types = $this->isMultiEntityIndex($this->index);
+ foreach ($items as $id => $item) {
+ if ($multi_types) {
+ if ($item->item_type !== 'user') {
+ continue;
+ }
+ $item_roles = $item->user->roles;
+ }
+ else {
+ $item_roles = $item->roles;
+ }
+ $role_match = (count(array_diff_key($item_roles, $selected_roles)) !== count($item_roles));
if ($role_match === $default) {
unset($items[$id]);
}
diff --git a/includes/callback_user_content.inc b/includes/callback_user_content.inc
new file mode 100644
index 00000000..168f3ae1
--- /dev/null
+++ b/includes/callback_user_content.inc
@@ -0,0 +1,57 @@
+getEntityType() === 'user';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function propertyInfo() {
+ return array(
+ 'search_api_user_content' => array(
+ 'label' => t('User content'),
+ 'description' => t('The nodes created by this user'),
+ 'type' => 'list',
+ ),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alterItems(array &$items) {
+ $uids = array();
+ foreach ($items as $item) {
+ $uids[] = $item->uid;
+ }
+
+ $sql = 'SELECT nid, uid FROM {node} WHERE uid IN (:uids)';
+ $nids = db_query($sql, array(':uids' => $uids));
+ $user_nodes = array();
+ foreach ($nids as $row) {
+ $user_nodes[$row->uid][] = $row->nid;
+ }
+
+ foreach ($items as $item) {
+ $item->search_api_user_content = array();
+ if (!empty($user_nodes[$item->uid])) {
+ $item->search_api_user_content = $user_nodes[$item->uid];
+ }
+ }
+ }
+
+}
diff --git a/includes/callback_user_status.inc b/includes/callback_user_status.inc
new file mode 100644
index 00000000..541f1b2d
--- /dev/null
+++ b/includes/callback_user_status.inc
@@ -0,0 +1,31 @@
+getEntityType() == 'user';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alterItems(array &$items) {
+ foreach ($items as $id => $account) {
+ if (empty($account->status)) {
+ unset($items[$id]);
+ }
+ }
+ }
+
+}
diff --git a/includes/datasource.inc b/includes/datasource.inc
index ba0d2ba8..9661092c 100644
--- a/includes/datasource.inc
+++ b/includes/datasource.inc
@@ -18,46 +18,49 @@
* aware that indexes' numerical IDs can change due to feature reverts. It is
* therefore recommended to use search_api_index_update_datasource(), or similar
* code, in a hook_search_api_index_update() implementation.
- *
- * All methods of the data source may throw exceptions of type
- * SearchApiDataSourceException if any exception or error state is encountered.
*/
interface SearchApiDataSourceControllerInterface {
/**
- * Constructor for a data source controller.
+ * Constructs an SearchApiDataSourceControllerInterface object.
*
- * @param $type
+ * @param string $type
* The item type for which this controller is created.
*/
public function __construct($type);
/**
- * Return information on the ID field for this controller's type.
+ * Returns information on the ID field for this controller's type.
*
* @return array
* An associative array containing the following keys:
* - key: The property key for the ID field, as used in the item wrapper.
* - type: The type of the ID field. Has to be one of the types from
* search_api_field_types(). List types ("list<*>") are not allowed.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
*/
public function getIdFieldInfo();
/**
- * Load items of the type of this data source controller.
+ * Loads items of the type of this data source controller.
*
* @param array $ids
- * The IDs of the items to laod.
+ * The IDs of the items to load.
*
* @return array
* The loaded items, keyed by ID.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
*/
public function loadItems(array $ids);
/**
- * Get a metadata wrapper for the item type of this data source controller.
+ * Creates a metadata wrapper for this datasource controller's type.
*
- * @param $item
+ * @param mixed $item
* Unless NULL, an item of the item type for this controller to be wrapped.
* @param array $info
* Optionally, additional information that should be used for creating the
@@ -67,151 +70,182 @@ interface SearchApiDataSourceControllerInterface {
* A wrapper for the item type of this data source controller, according to
* the info array, and optionally loaded with the given data.
*
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ *
* @see entity_metadata_wrapper()
*/
public function getMetadataWrapper($item = NULL, array $info = array());
/**
- * Get the unique ID of an item.
+ * Retrieves the unique ID of an item.
*
- * @param $item
+ * @param mixed $item
* An item of this controller's type.
*
- * @return
+ * @return mixed
* Either the unique ID of the item, or NULL if none is available.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
*/
public function getItemId($item);
/**
- * Get a human-readable label for an item.
+ * Retrieves a human-readable label for an item.
*
- * @param $item
+ * @param mixed $item
* An item of this controller's type.
*
- * @return
+ * @return string|null
* Either a human-readable label for the item, or NULL if none is available.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
*/
public function getItemLabel($item);
/**
- * Get a URL at which the item can be viewed on the web.
+ * Retrieves a URL at which the item can be viewed on the web.
*
- * @param $item
+ * @param mixed $item
* An item of this controller's type.
*
- * @return
+ * @return array|null
* Either an array containing the 'path' and 'options' keys used to build
* the URL of the item, and matching the signature of url(), or NULL if the
* item has no URL of its own.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
*/
public function getItemUrl($item);
/**
- * Initialize tracking of the index status of items for the given indexes.
+ * Initializes tracking of the index status of items for the given indexes.
*
* All currently known items of this data source's type should be inserted
* into the tracking table for the given indexes, with status "changed". If
* items were already present, these should also be set to "changed" and not
* be inserted again.
*
- * @param array $indexes
+ * @param SearchApiIndex[] $indexes
* The SearchApiIndex objects for which item tracking should be initialized.
*
* @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * If any error state was encountered.
*/
public function startTracking(array $indexes);
/**
- * Stop tracking of the index status of items for the given indexes.
+ * Stops tracking of the index status of items for the given indexes.
*
* The tracking tables of the given indexes should be completely cleared.
*
- * @param array $indexes
+ * @param SearchApiIndex[] $indexes
* The SearchApiIndex objects for which item tracking should be stopped.
*
* @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * If any error state was encountered.
*/
public function stopTracking(array $indexes);
/**
- * Start tracking the index status for the given items on the given indexes.
+ * Starts tracking the index status for the given items on the given indexes.
*
* @param array $item_ids
* The IDs of new items to track.
- * @param array $indexes
+ * @param SearchApiIndex[] $indexes
* The indexes for which items should be tracked.
*
+ * @return SearchApiIndex[]|null
+ * All indexes for which any items were added; or NULL if items were added
+ * for all of them.
+ *
* @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * If any error state was encountered.
*/
public function trackItemInsert(array $item_ids, array $indexes);
/**
- * Set the tracking status of the given items to "changed"/"dirty".
+ * Sets the tracking status of the given items to "changed"/"dirty".
*
* Unless $dequeue is set to TRUE, this operation is ignored for items whose
* status is not "indexed".
*
- * @param $item_ids
+ * @param array|false $item_ids
* Either an array with the IDs of the changed items. Or FALSE to mark all
* items as changed for the given indexes.
- * @param array $indexes
+ * @param SearchApiIndex[] $indexes
* The indexes for which the change should be tracked.
- * @param $dequeue
- * If set to TRUE, also change the status of queued items.
+ * @param bool $dequeue
+ * (deprecated) If set to TRUE, also change the status of queued items.
+ * The concept of queued items will be removed in the Drupal 8 version of
+ * this module.
+ *
+ * @return SearchApiIndex[]|null
+ * All indexes for which any items were updated; or NULL if items were
+ * updated for all of them.
*
* @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * If any error state was encountered.
*/
public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE);
/**
- * Set the tracking status of the given items to "queued".
+ * Sets the tracking status of the given items to "queued".
*
* Queued items are not marked as "dirty" even when they are changed, and they
* are not returned by the getChangedItems() method.
*
- * @param $item_ids
+ * @param array|false $item_ids
* Either an array with the IDs of the queued items. Or FALSE to mark all
* items as queued for the given indexes.
* @param SearchApiIndex $index
* The index for which the items were queued.
*
* @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * If any error state was encountered.
+ *
+ * @deprecated
+ * As of Search API 1.10, the cron queue is not used for indexing anymore,
+ * therefore this method has become useless. It will be removed in the
+ * Drupal 8 version of this module.
*/
public function trackItemQueued($item_ids, SearchApiIndex $index);
/**
- * Set the tracking status of the given items to "indexed".
+ * Sets the tracking status of the given items to "indexed".
*
* @param array $item_ids
* The IDs of the indexed items.
- * @param SearchApiIndex $indexes
+ * @param SearchApiIndex $index
* The index on which the items were indexed.
*
* @throws SearchApiDataSourceException
- * If the index doesn't use the same item type as this controller.
+ * If any error state was encountered.
*/
public function trackItemIndexed(array $item_ids, SearchApiIndex $index);
/**
- * Stop tracking the index status for the given items on the given indexes.
+ * Stops tracking the index status for the given items on the given indexes.
*
* @param array $item_ids
* The IDs of the removed items.
- * @param array $indexes
+ * @param SearchApiIndex[] $indexes
* The indexes for which the deletions should be tracked.
*
+ * @return SearchApiIndex[]|null
+ * All indexes for which any items were deleted; or NULL if items were
+ * deleted for all of them.
+ *
* @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * If any error state was encountered.
*/
public function trackItemDelete(array $item_ids, array $indexes);
/**
- * Get a list of items that need to be indexed.
+ * Retrieves a list of items that need to be indexed.
*
* If possible, completely unindexed items should be returned before items
* that were indexed but later changed. Also, items that were changed longer
@@ -219,16 +253,19 @@ interface SearchApiDataSourceControllerInterface {
*
* @param SearchApiIndex $index
* The index for which changed items should be returned.
- * @param $limit
+ * @param int $limit
* The maximum number of items to return. Negative values mean "unlimited".
*
* @return array
* The IDs of items that need to be indexed for the given index.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
*/
public function getChangedItems(SearchApiIndex $index, $limit = -1);
/**
- * Get information on how many items have been indexed for a certain index.
+ * Retrieves information on how many items have been indexed for a certain index.
*
* @param SearchApiIndex $index
* The index whose index status should be returned.
@@ -240,22 +277,89 @@ interface SearchApiDataSourceControllerInterface {
* index.
*
* @throws SearchApiDataSourceException
- * If the index doesn't use the same item type as this controller.
+ * If any error state was encountered.
*/
public function getIndexStatus(SearchApiIndex $index);
/**
- * Get the entity type of items from this datasource.
+ * Retrieves the entity type of items from this datasource.
*
* @return string|null
* An entity type string if the items provided by this datasource are
* entities; NULL otherwise.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
*/
public function getEntityType();
+
+ /**
+ * Form constructor for configuring the datasource for a given index.
+ *
+ * @param array $form
+ * The form returned by configurationForm().
+ * @param array $form_state
+ * The form state. $form_state['index'] will contain the edited index. If
+ * this key is empty, then a new index is being created. In case of an edit,
+ * $form_state['index']->options['datasource'] contains the previous
+ * settings for the datasource.
+ *
+ * @return array|false
+ * A form array for configuring this callback, or FALSE if no configuration
+ * is possible.
+ */
+ public function configurationForm(array $form, array &$form_state);
+
+ /**
+ * Validation callback for the form returned by configurationForm().
+ *
+ * This method will only be called if that form was non-empty.
+ *
+ * @param array $form
+ * The form returned by configurationForm().
+ * @param array $values
+ * The part of the $form_state['values'] array corresponding to this form.
+ * @param array $form_state
+ * The complete form state.
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state);
+
+ /**
+ * Submit callback for the form returned by configurationForm().
+ *
+ * This method will only be called if that form was non-empty.
+ *
+ * Any necessary changes to the submitted values should be made, afterwards
+ * they will automatically be stored as the index's "datasource" options. The
+ * method can also be used by the datasource controller to react to the
+ * possible change in its settings.
+ *
+ * @param array $form
+ * The form returned by configurationForm().
+ * @param array $values
+ * The part of the $form_state['values'] array corresponding to this form.
+ * @param array $form_state
+ * The complete form state.
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state);
+
+ /**
+ * Returns a summary of an index's current datasource configuration.
+ *
+ * @param SearchApiIndex $index
+ * The index whose datasource configuration should be summarized.
+ *
+ * @return string|null
+ * A translated string describing the index's current datasource
+ * configuration. Or NULL, if there is no configuration (or no description
+ * is available).
+ */
+ public function getConfigurationSummary(SearchApiIndex $index);
+
}
/**
- * Default base class for the SearchApiDataSourceControllerInterface.
+ * Provides a default base class for datasource controllers.
*
* Contains default implementations for a number of methods which will be
* similar for most data sources. Concrete data sources can decide to extend
@@ -330,10 +434,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
protected $changedColumn = 'changed';
/**
- * Constructor for a data source controller.
- *
- * @param $type
- * The item type for which this controller is created.
+ * {@inheritdoc}
*/
public function __construct($type) {
$this->type = $type;
@@ -345,30 +446,14 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Get the entity type of items from this datasource.
- *
- * @return string|null
- * An entity type string if the items provided by this datasource are
- * entities; NULL otherwise.
+ * {@inheritdoc}
*/
public function getEntityType() {
return $this->entityType;
}
/**
- * Get a metadata wrapper for the item type of this data source controller.
- *
- * @param $item
- * Unless NULL, an item of the item type for this controller to be wrapped.
- * @param array $info
- * Optionally, additional information that should be used for creating the
- * wrapper. Uses the same format as entity_metadata_wrapper().
- *
- * @return EntityMetadataWrapper
- * A wrapper for the item type of this data source controller, according to
- * the info array, and optionally loaded with the given data.
- *
- * @see entity_metadata_wrapper()
+ * {@inheritdoc}
*/
public function getMetadataWrapper($item = NULL, array $info = array()) {
$info += $this->getPropertyInfo();
@@ -376,7 +461,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Get the property info for this item type.
+ * Retrieves the property info for this item type.
*
* This is a helper method for getMetadataWrapper() that can be used by
* subclasses to specify the property information to use when creating a
@@ -384,7 +469,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
*
* The data structure uses largely the format specified in
* hook_entity_property_info(). However, the first level of keys (containing
- * the entity types) is omitted, and the "property" key is called
+ * the entity types) is omitted, and the "properties" key is called
* "property info" instead. So, an example return value would look like this:
*
* @code
@@ -413,6 +498,9 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
* @return array
* Property information as specified by entity_metadata_wrapper().
*
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ *
* @see getMetadataWrapper()
* @see hook_entity_property_info()
*/
@@ -425,13 +513,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Get the unique ID of an item.
- *
- * @param $item
- * An item of this controller's type.
- *
- * @return
- * Either the unique ID of the item, or NULL if none is available.
+ * {@inheritdoc}
*/
public function getItemId($item) {
$id_info = $this->getIdFieldInfo();
@@ -445,13 +527,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Get a human-readable label for an item.
- *
- * @param $item
- * An item of this controller's type.
- *
- * @return
- * Either a human-readable label for the item, or NULL if none is available.
+ * {@inheritdoc}
*/
public function getItemLabel($item) {
$label = $this->getMetadataWrapper($item)->label();
@@ -459,33 +535,14 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Get a URL at which the item can be viewed on the web.
- *
- * @param $item
- * An item of this controller's type.
- *
- * @return
- * Either an array containing the 'path' and 'options' keys used to build
- * the URL of the item, and matching the signature of url(), or NULL if the
- * item has no URL of its own.
+ * {@inheritdoc}
*/
public function getItemUrl($item) {
return NULL;
}
/**
- * Initialize tracking of the index status of items for the given indexes.
- *
- * All currently known items of this data source's type should be inserted
- * into the tracking table for the given indexes, with status "changed". If
- * items were already present, these should also be set to "changed" and not
- * be inserted again.
- *
- * @param array $indexes
- * The SearchApiIndex objects for which item tracking should be initialized.
- *
- * @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * {@inheritdoc}
*/
public function startTracking(array $indexes) {
if (!$this->table) {
@@ -499,27 +556,23 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Helper method that can be used by subclasses instead of implementing startTracking().
- *
* Returns the IDs of all items that are known for this controller's type.
*
+ * Helper method that can be used by subclasses instead of implementing
+ * startTracking().
+ *
* @return array
* An array containing all item IDs for this type.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
*/
protected function getAllItemIds() {
throw new SearchApiDataSourceException(t('Items not known for type @type.', array('@type' => $this->type)));
}
/**
- * Stop tracking of the index status of items for the given indexes.
- *
- * The tracking tables of the given indexes should be completely cleared.
- *
- * @param array $indexes
- * The SearchApiIndex objects for which item tracking should be stopped.
- *
- * @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * {@inheritdoc}
*/
public function stopTracking(array $indexes) {
if (!$this->table) {
@@ -529,28 +582,24 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
// will mostly be called with only one index.
foreach ($indexes as $index) {
$this->checkIndex($index);
- $query = db_delete($this->table)
+ db_delete($this->table)
->condition($this->indexIdColumn, $index->id)
->execute();
}
}
/**
- * Start tracking the index status for the given items on the given indexes.
- *
- * @param array $item_ids
- * The IDs of new items to track.
- * @param array $indexes
- * The indexes for which items should be tracked.
- *
- * @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * {@inheritdoc}
*/
public function trackItemInsert(array $item_ids, array $indexes) {
- if (!$this->table) {
+ if (!$this->table || $item_ids === array()) {
return;
}
+ foreach ($indexes as $index) {
+ $this->checkIndex($index);
+ }
+
// Since large amounts of items can overstrain the database, only add items
// in chunks.
foreach (array_chunk($item_ids, 1000) as $chunk) {
@@ -558,7 +607,6 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
->fields(array($this->itemIdColumn, $this->indexIdColumn, $this->changedColumn));
foreach ($chunk as $item_id) {
foreach ($indexes as $index) {
- $this->checkIndex($index);
$insert->values(array(
$this->itemIdColumn => $item_id,
$this->indexIdColumn => $index->id,
@@ -571,60 +619,53 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Set the tracking status of the given items to "changed"/"dirty".
- *
- * Unless $dequeue is set to TRUE, this operation is ignored for items whose
- * status is not "indexed".
- *
- * @param $item_ids
- * Either an array with the IDs of the changed items. Or FALSE to mark all
- * items as changed for the given indexes.
- * @param array $indexes
- * The indexes for which the change should be tracked.
- * @param $dequeue
- * If set to TRUE, also change the status of queued items.
- *
- * @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * {@inheritdoc}
*/
public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE) {
- if (!$this->table) {
- return;
+ if (!$this->table || $item_ids === array()) {
+ return NULL;
}
- $index_ids = array();
+
+ $indexes_by_id = array();
foreach ($indexes as $index) {
$this->checkIndex($index);
- $index_ids[] = $index->id;
+ $update = db_update($this->table)
+ ->fields(array(
+ $this->changedColumn => REQUEST_TIME,
+ ))
+ ->condition($this->indexIdColumn, $index->id)
+ ->condition($this->changedColumn, 0, $dequeue ? '<=' : '=');
+ if ($item_ids !== FALSE) {
+ $update->condition($this->itemIdColumn, $item_ids, 'IN');
+ }
+ $update->execute();
+ $indexes_by_id[$index->id] = $index;
}
- $update = db_update($this->table)
- ->fields(array(
- $this->changedColumn => REQUEST_TIME,
- ))
- ->condition($this->indexIdColumn, $index_ids, 'IN')
- ->condition($this->changedColumn, 0, $dequeue ? '<=' : '=');
+
+ // Determine and return the indexes with any changed items. If $item_ids is
+ // FALSE, all items are marked as changed and, thus, all indexes will be
+ // affected (unless they don't have any items, but no real point in treating
+ // that special case).
if ($item_ids !== FALSE) {
- $update->condition($this->itemIdColumn, $item_ids, 'IN');
+ $indexes_with_items = db_select($this->table, 't')
+ ->fields('t', array($this->indexIdColumn))
+ ->distinct()
+ ->condition($this->indexIdColumn, array_keys($indexes_by_id), 'IN')
+ ->condition($this->itemIdColumn, $item_ids, 'IN')
+ ->execute()
+ ->fetchCol();
+ return array_intersect_key($indexes_by_id, array_flip($indexes_with_items));
}
- $update->execute();
+
+ return NULL;
}
/**
- * Set the tracking status of the given items to "queued".
- *
- * Queued items are not marked as "dirty" even when they are changed, and they
- * are not returned by the getChangedItems() method.
- *
- * @param $item_ids
- * Either an array with the IDs of the queued items. Or FALSE to mark all
- * items as queued for the given indexes.
- * @param SearchApiIndex $index
- * The index for which the items were queued.
- *
- * @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * {@inheritdoc}
*/
public function trackItemQueued($item_ids, SearchApiIndex $index) {
- if (!$this->table) {
+ $this->checkIndex($index);
+ if (!$this->table || $item_ids === array()) {
return;
}
$update = db_update($this->table)
@@ -639,18 +680,10 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Set the tracking status of the given items to "indexed".
- *
- * @param array $item_ids
- * The IDs of the indexed items.
- * @param SearchApiIndex $indexes
- * The index on which the items were indexed.
- *
- * @throws SearchApiDataSourceException
- * If the index doesn't use the same item type as this controller.
+ * {@inheritdoc}
*/
public function trackItemIndexed(array $item_ids, SearchApiIndex $index) {
- if (!$this->table) {
+ if (!$this->table || $item_ids === array()) {
return;
}
$this->checkIndex($index);
@@ -664,45 +697,30 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Stop tracking the index status for the given items on the given indexes.
- *
- * @param array $item_ids
- * The IDs of the removed items.
- * @param array $indexes
- * The indexes for which the deletions should be tracked.
- *
- * @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * {@inheritdoc}
*/
public function trackItemDelete(array $item_ids, array $indexes) {
- if (!$this->table) {
- return;
+ if (!$this->table || $item_ids === array()) {
+ return NULL;
}
- $index_ids = array();
+
+ $ret = array();
+
foreach ($indexes as $index) {
$this->checkIndex($index);
- $index_ids[] = $index->id;
+ $delete = db_delete($this->table)
+ ->condition($this->indexIdColumn, $index->id)
+ ->condition($this->itemIdColumn, $item_ids, 'IN');
+ if ($delete->execute()) {
+ $ret[] = $index;
+ }
}
- db_delete($this->table)
- ->condition($this->itemIdColumn, $item_ids, 'IN')
- ->condition($this->indexIdColumn, $index_ids, 'IN')
- ->execute();
+
+ return $ret;
}
/**
- * Get a list of items that need to be indexed.
- *
- * If possible, completely unindexed items should be returned before items
- * that were indexed but later changed. Also, items that were changed longer
- * ago should be favored.
- *
- * @param SearchApiIndex $index
- * The index for which changed items should be returned.
- * @param $limit
- * The maximum number of items to return. Negative values mean "unlimited".
- *
- * @return array
- * The IDs of items that need to be indexed for the given index.
+ * {@inheritdoc}
*/
public function getChangedItems(SearchApiIndex $index, $limit = -1) {
if ($limit == 0) {
@@ -710,7 +728,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
$this->checkIndex($index);
$select = db_select($this->table, 't');
- $select->addField('t', 'item_id');
+ $select->addField('t', $this->itemIdColumn);
$select->condition($this->indexIdColumn, $index->id);
$select->condition($this->changedColumn, 0, '>');
$select->orderBy($this->changedColumn, 'ASC');
@@ -721,16 +739,7 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Get information on how many items have been indexed for a certain index.
- *
- * @param SearchApiIndex $index
- * The index whose index status should be returned.
- *
- * @return array
- * An associative array containing two keys (in this order):
- * - indexed: The number of items already indexed in their latest version.
- * - total: The total number of items that have to be indexed for this
- * index.
+ * {@inheritdoc}
*/
public function getIndexStatus(SearchApiIndex $index) {
if (!$this->table) {
@@ -752,13 +761,42 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
}
/**
- * Helper method for ensuring that an index uses the same item type as this controller.
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfigurationSummary(SearchApiIndex $index) {
+ return NULL;
+ }
+
+ /**
+ * Checks whether the given index is valid for this datasource controller.
+ *
+ * Helper method used by various methods in this class. By default only checks
+ * whether the types match.
*
* @param SearchApiIndex $index
* The index to check.
*
* @throws SearchApiDataSourceException
- * If the index doesn't use the same type as this controller.
+ * If the index doesn't fit to this datasource controller.
*/
protected function checkIndex(SearchApiIndex $index) {
if ($index->item_type != $this->type) {
diff --git a/includes/datasource_entity.inc b/includes/datasource_entity.inc
index 6f15ec54..bbca0aa4 100644
--- a/includes/datasource_entity.inc
+++ b/includes/datasource_entity.inc
@@ -6,50 +6,79 @@
*/
/**
- * Data source for all entities known to the Entity API.
+ * Represents a datasource for all entities known to the Entity API.
*/
class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceController {
/**
- * Return information on the ID field for this controller's type.
+ * Entity type info for this type.
*
- * @return array
- * An associative array containing the following keys:
- * - key: The property key for the ID field, as used in the item wrapper.
- * - type: The type of the ID field. Has to be one of the types from
- * search_api_field_types(). List types ("list<*>") are not allowed.
+ * @var array
+ */
+ protected $entityInfo;
+
+ /**
+ * The ID key of this entity type, if any.
+ *
+ * @var string|null
+ */
+ protected $idKey;
+
+ /**
+ * The bundle key of this entity type, if any.
+ *
+ * @var string|null
+ */
+ protected $bundleKey;
+
+ /**
+ * Cached return values for getBundles(), keyed by index machine name.
+ *
+ * @var array
+ */
+ protected $bundles = array();
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct($type) {
+ parent::__construct($type);
+
+ $this->entityInfo = entity_get_info($this->entityType);
+ if (!empty($this->entityInfo['entity keys']['id'])) {
+ $this->idKey = $this->entityInfo['entity keys']['id'];
+ }
+ if (!empty($this->entityInfo['entity keys']['bundle'])) {
+ $this->bundleKey = $this->entityInfo['entity keys']['bundle'];
+ }
+ }
+
+ /**
+ * {@inheritdoc}
*/
public function getIdFieldInfo() {
- $info = entity_get_info($this->entityType);
$properties = entity_get_property_info($this->entityType);
- if (empty($info['entity keys']['id'])) {
- throw new SearchApiDataSourceException(t("Entity type @type doesn't specify an ID key.", array('@type' => $info['label'])));
+ if (!$this->idKey) {
+ throw new SearchApiDataSourceException(t("Entity type @type doesn't specify an ID key.", array('@type' => $this->entityInfo['label'])));
}
- $field = $info['entity keys']['id'];
- if (empty($properties['properties'][$field]['type'])) {
- throw new SearchApiDataSourceException(t("Entity type @type doesn't specify a type for the @prop property.", array('@type' => $info['label'], '@prop' => $field)));
+ if (empty($properties['properties'][$this->idKey]['type'])) {
+ throw new SearchApiDataSourceException(t("Entity type @type doesn't specify a type for the @prop property.", array('@type' => $this->entityInfo['label'], '@prop' => $this->idKey)));
}
- $type = $properties['properties'][$field]['type'];
+ $type = $properties['properties'][$this->idKey]['type'];
if (search_api_is_list_type($type)) {
- throw new SearchApiDataSourceException(t("Entity type @type uses list field @prop as its ID.", array('@type' => $info['label'], '@prop' => $field)));
+ throw new SearchApiDataSourceException(t("Entity type @type uses list field @prop as its ID.", array('@type' => $this->entityInfo['label'], '@prop' => $this->idKey)));
}
if ($type == 'token') {
$type = 'string';
}
return array(
- 'key' => $field,
+ 'key' => $this->idKey,
'type' => $type,
);
}
/**
- * Load items of the type of this data source controller.
- *
- * @param array $ids
- * The IDs of the items to laod.
- *
- * @return array
- * The loaded items, keyed by ID.
+ * {@inheritdoc}
*/
public function loadItems(array $ids) {
$items = entity_load($this->entityType, $ids);
@@ -65,32 +94,14 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
}
/**
- * Get a metadata wrapper for the item type of this data source controller.
- *
- * @param $item
- * Unless NULL, an item of the item type for this controller to be wrapped.
- * @param array $info
- * Optionally, additional information that should be used for creating the
- * wrapper. Uses the same format as entity_metadata_wrapper().
- *
- * @return EntityMetadataWrapper
- * A wrapper for the item type of this data source controller, according to
- * the info array, and optionally loaded with the given data.
- *
- * @see entity_metadata_wrapper()
+ * {@inheritdoc}
*/
public function getMetadataWrapper($item = NULL, array $info = array()) {
return entity_metadata_wrapper($this->entityType, $item, $info);
}
/**
- * Get the unique ID of an item.
- *
- * @param $item
- * An item of this controller's type.
- *
- * @return
- * Either the unique ID of the item, or NULL if none is available.
+ * {@inheritdoc}
*/
public function getItemId($item) {
$id = entity_id($this->entityType, $item);
@@ -98,13 +109,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
}
/**
- * Get a human-readable label for an item.
- *
- * @param $item
- * An item of this controller's type.
- *
- * @return
- * Either a human-readable label for the item, or NULL if none is available.
+ * {@inheritdoc}
*/
public function getItemLabel($item) {
$label = entity_label($this->entityType, $item);
@@ -112,15 +117,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
}
/**
- * Get a URL at which the item can be viewed on the web.
- *
- * @param $item
- * An item of this controller's type.
- *
- * @return
- * Either an array containing the 'path' and 'options' keys used to build
- * the URL of the item, and matching the signature of url(), or NULL if the
- * item has no URL of its own.
+ * {@inheritdoc}
*/
public function getItemUrl($item) {
if ($this->entityType == 'file') {
@@ -137,18 +134,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
}
/**
- * Initialize tracking of the index status of items for the given indexes.
- *
- * All currently known items of this data source's type should be inserted
- * into the tracking table for the given indexes, with status "changed". If
- * items were already present, these should also be set to "changed" and not
- * be inserted again.
- *
- * @param array $indexes
- * The SearchApiIndex objects for which item tracking should be initialized.
- *
- * @throws SearchApiDataSourceException
- * If any of the indexes doesn't use the same item type as this controller.
+ * {@inheritdoc}
*/
public function startTracking(array $indexes) {
if (!$this->table) {
@@ -158,24 +144,60 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
// all items again without any key conflicts.
$this->stopTracking($indexes);
- $entity_info = entity_get_info($this->entityType);
-
- if (!empty($entity_info['base table'])) {
+ if (!empty($this->entityInfo['base table']) && $this->idKey) {
// Use a subselect, which will probably be much faster than entity_load().
// Assumes that all entities use the "base table" property and the
// "entity keys[id]" in the same way as the default controller.
- $id_field = $entity_info['entity keys']['id'];
- $table = $entity_info['base table'];
+ $table = $this->entityInfo['base table'];
- // We could also use a single insert (with a JOIN in the nested query),
+ // We could also use a single insert (with a UNION in the nested query),
// but this method will be mostly called with a single index, anyways.
foreach ($indexes as $index) {
// Select all entity ids.
$query = db_select($table, 't');
- $query->addField('t', $id_field, 'item_id');
+ $query->addField('t', $this->idKey, 'item_id');
$query->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
$query->addExpression('1', 'changed');
+ if ($bundles = $this->getIndexBundles($index)) {
+ $bundle_column = $this->bundleKey;
+ if (!db_field_exists($table, $bundle_column)) {
+ if ($this->entityType == 'taxonomy_term') {
+ $bundle_column = 'vid';
+ $bundles = db_query('SELECT vid FROM {taxonomy_vocabulary} WHERE machine_name IN (:bundles)', array(':bundles' => $bundles))->fetchCol();
+ }
+ elseif ($this->entityType == 'flagging') {
+ $bundle_column = 'fid';
+ $bundles = db_query('SELECT fid FROM {flag} WHERE name IN (:bundles)', array(':bundles' => $bundles))->fetchCol();
+ }
+ elseif ($this->entityType == 'comment') {
+ // Comments are significantly more complicated, since they don't
+ // store their bundle explicitly in their database table. Instead,
+ // we need to get all the nodes from the enabled types and filter
+ // by those.
+ $bundle_column = 'nid';
+ $node_types = array();
+ foreach ($bundles as $bundle) {
+ if (substr($bundle, 0, 13) === 'comment_node_') {
+ $node_types[] = substr($bundle, 13);
+ }
+ }
+ if ($node_types) {
+ $bundles = db_query('SELECT nid FROM {node} WHERE type IN (:bundles)', array(':bundles' => $node_types))->fetchCol();
+ }
+ else {
+ continue;
+ }
+ }
+ else {
+ $this->startTrackingFallback(array($index->machine_name => $index));
+ continue;
+ }
+ }
+ if ($bundles) {
+ $query->condition($bundle_column, $bundles);
+ }
+ }
// INSERT ... SELECT ...
db_insert($this->table)
@@ -184,23 +206,165 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
}
}
else {
- // In the absence of a 'base table', use the slow entity_load().
- parent::startTracking($indexes);
+ $this->startTrackingFallback($indexes);
+ }
+ }
+
+ /**
+ * Initializes tracking of the index status of items for the given indexes.
+ *
+ * Fallback for when the items cannot directly be loaded into
+ * {search_api_item} via "INSERT INTO … SELECT …".
+ *
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which item tracking should be initialized.
+ *
+ * @throws SearchApiDataSourceException
+ * Thrown if any error state was encountered.
+ *
+ * @see SearchApiEntityDataSourceController::startTracking()
+ */
+ protected function startTrackingFallback(array $indexes) {
+ // In the absence of a 'base table', use the slower way of retrieving the
+ // items and inserting them "manually". For each index we get the item IDs
+ // (since selected bundles might differ) and insert all of them as new.
+ foreach ($indexes as $index) {
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', $this->entityType);
+ if ($bundles = $this->getIndexBundles($index)) {
+ $query->entityCondition('bundle', $bundles);
+ }
+ $result = $query->execute();
+ $ids = !empty($result[$this->entityType]) ? array_keys($result[$this->entityType]) : array();
+ if ($ids) {
+ $this->trackItemInsert($ids, array($index));
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function trackItemInsert(array $item_ids, array $indexes) {
+ $ret = array();
+
+ foreach ($indexes as $index_id => $index) {
+ $ids = $item_ids;
+ if ($bundles = $this->getIndexBundles($index)) {
+ $ids = drupal_map_assoc($ids);
+ foreach (entity_load($this->entityType, $ids) as $id => $entity) {
+ if (empty($bundles[$entity->{$this->bundleKey}])) {
+ unset($ids[$id]);
+ }
+ }
+ }
+ if ($ids) {
+ parent::trackItemInsert($ids, array($index));
+ $ret[$index_id] = $index;
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ $options = $this->getAvailableBundles();
+ if (!$options) {
+ return FALSE;
+ }
+ $form['bundles'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Bundles'),
+ '#description' => t('Restrict the entity bundles that will be included in this index. Leave blank to include all bundles. This setting cannot be changed for enabled indexes.'),
+ '#options' => array_map('check_plain', $options),
+ '#attributes' => array('class' => array('search-api-checkboxes-list')),
+ '#disabled' => !empty($form_state['index']) && $form_state['index']->enabled,
+ );
+ if (!empty($form_state['index']->options['datasource'])) {
+ $form['bundles']['#default_value'] = drupal_map_assoc($form_state['index']->options['datasource']['bundles']);
}
+ return $form;
}
/**
- * Helper method that can be used by subclasses instead of implementing startTracking().
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ if (!empty($values['bundles'])) {
+ $values['bundles'] = array_keys(array_filter($values['bundles']));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfigurationSummary(SearchApiIndex $index) {
+ if ($bundles = $this->getIndexBundles($index)) {
+ $args['!bundles'] = implode(', ', array_intersect_key($this->getAvailableBundles(), $bundles));
+ return format_plural(count($bundles), 'Indexed bundle: !bundles.', 'Indexed bundles: !bundles.', $args);
+ }
+ return NULL;
+ }
+
+ /**
+ * Retrieves the available bundles for this entity type.
*
- * Returns the IDs of all items that are known for this controller's type.
+ * @return array
+ * An array (which might be empty) mapping this entity type's bundle keys to
+ * their labels.
+ */
+ protected function getAvailableBundles() {
+ if (!$this->bundleKey || empty($this->entityInfo['bundles'])) {
+ return array();
+ }
+ $bundles = array();
+ foreach ($this->entityInfo['bundles'] as $bundle => $bundle_info) {
+ $bundles[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+ }
+ return $bundles;
+ }
+
+ /**
+ * Computes the bundles that should be indexed for an index.
*
- * Will be used when the entity type doesn't specify a "base table".
+ * @param SearchApiIndex $index
+ * The index for which to check.
*
* @return array
- * An array containing all item IDs for this type.
+ * An array containing all bundles that should be included in this index, as
+ * both the keys and values. An empty array means all current bundles should
+ * be included.
+ *
+ * @throws SearchApiException
+ * If the index doesn't belong to this datasource controller.
*/
- protected function getAllItemIds() {
- return array_keys(entity_load($this->entityType));
+ protected function getIndexBundles(SearchApiIndex $index) {
+ $this->checkIndex($index);
+
+ if (!isset($this->bundles[$index->machine_name])) {
+ $this->bundles[$index->machine_name] = array();
+ if (!empty($index->options['datasource']['bundles'])) {
+ // We retrieve the available bundles here to check whether all of them
+ // are included by the index's setting. In this case, we return an empty
+ // array, too, to save on complexity.
+ // On the other hand, we still want to return deleted bundles since we
+ // do not want to suddenly include all bundles when all selected bundles
+ // were deleted.
+ $available = $this->getAvailableBundles();
+ foreach ($index->options['datasource']['bundles'] as $bundle) {
+ $this->bundles[$index->machine_name][$bundle] = $bundle;
+ unset($available[$bundle]);
+ }
+ if (!$available) {
+ $this->bundles[$index->machine_name] = array();
+ }
+ }
+ }
+
+ return $this->bundles[$index->machine_name];
}
}
diff --git a/includes/datasource_external.inc b/includes/datasource_external.inc
index 1128f173..c3a9f1fc 100644
--- a/includes/datasource_external.inc
+++ b/includes/datasource_external.inc
@@ -49,7 +49,7 @@ class SearchApiExternalDataSourceController extends SearchApiAbstractDataSourceC
* loadable, specify a function here.
*
* @param array $ids
- * The IDs of the items to laod.
+ * The IDs of the items to load.
*
* @return array
* The loaded items, keyed by ID.
diff --git a/includes/datasource_multiple.inc b/includes/datasource_multiple.inc
new file mode 100644
index 00000000..ea164963
--- /dev/null
+++ b/includes/datasource_multiple.inc
@@ -0,0 +1,360 @@
+ 'item_id',
+ 'type' => 'string',
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function loadItems(array $ids) {
+ $ids_by_type = array();
+ foreach ($ids as $id) {
+ list($type, $entity_id) = explode('/', $id);
+ $ids_by_type[$type][$entity_id] = $id;
+ }
+
+ $items = array();
+ foreach ($ids_by_type as $type => $type_ids) {
+ foreach (entity_load($type, array_keys($type_ids)) as $entity_id => $entity) {
+ $id = $type_ids[$entity_id];
+ $item = (object) array($type => $entity);
+ $item->item_id = $id;
+ $item->item_type = $type;
+ $item->item_entity_id = $entity_id;
+ $item->item_bundle = NULL;
+ // Add the item language so the "search_api_language" field will work
+ // correctly.
+ $item->language = isset($entity->language) ? $entity->language : NULL;
+ try {
+ list(, , $bundle) = entity_extract_ids($type, $entity);
+ $item->item_bundle = $bundle ? "$type:$bundle" : NULL;
+ }
+ catch (EntityMalformedException $e) {
+ // Will probably make problems at some other place, but for extracting
+ // the bundle it is really not critical enough to fail on – just
+ // ignore this exception.
+ }
+ $items[$id] = $item;
+ unset($type_ids[$entity_id]);
+ }
+ if ($type_ids) {
+ search_api_track_item_delete($type, array_keys($type_ids));
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getPropertyInfo() {
+ $info = array(
+ 'item_id' => array(
+ 'label' => t('ID'),
+ 'description' => t('The combined ID of the item, containing both entity type and entity ID.'),
+ 'type' => 'token',
+ ),
+ 'item_type' => array(
+ 'label' => t('Entity type'),
+ 'description' => t('The entity type of the item.'),
+ 'type' => 'token',
+ 'options list' => 'search_api_entity_type_options_list',
+ ),
+ 'item_entity_id' => array(
+ 'label' => t('Entity ID'),
+ 'description' => t('The entity ID of the item.'),
+ 'type' => 'token',
+ ),
+ 'item_bundle' => array(
+ 'label' => t('Bundle'),
+ 'description' => t('The bundle of the item, if applicable.'),
+ 'type' => 'token',
+ 'options list' => 'search_api_combined_bundle_options_list',
+ ),
+ 'item_label' => array(
+ 'label' => t('Label'),
+ 'description' => t('The label of the item.'),
+ 'type' => 'text',
+ // Since this needs a bit more computation than the others, we don't
+ // include it always when loading the item but use a getter callback.
+ 'getter callback' => 'search_api_get_multi_type_item_label',
+ ),
+ );
+
+ foreach ($this->getSelectedEntityTypeOptions() as $type => $label) {
+ $info[$type] = array(
+ 'label' => $label,
+ 'description' => t('The indexed entity, if it is of type %type.', array('%type' => $label)),
+ 'type' => $type,
+ );
+ }
+
+ return array('property info' => $info);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemId($item) {
+ return isset($item->item_id) ? $item->item_id : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemLabel($item) {
+ return search_api_get_multi_type_item_label($item);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItemUrl($item) {
+ if ($item->item_type == 'file') {
+ return array(
+ 'path' => file_create_url($item->file->uri),
+ 'options' => array(
+ 'entity_type' => 'file',
+ 'entity' => $item,
+ ),
+ );
+ }
+ $url = entity_uri($item->item_type, $item->{$item->item_type});
+ return $url ? $url : NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function startTracking(array $indexes) {
+ if (!$this->table) {
+ return;
+ }
+ // We first clear the tracking table for all indexes, so we can just insert
+ // all items again without any key conflicts.
+ $this->stopTracking($indexes);
+
+ foreach ($indexes as $index) {
+ $types = $this->getEntityTypes($index);
+
+ // Wherever possible, use a sub-select instead of the much slower
+ // entity_load().
+ foreach ($types as $type) {
+ $entity_info = entity_get_info($type);
+
+ if (!empty($entity_info['base table'])) {
+ // Assumes that all entities use the "base table" property and the
+ // "entity keys[id]" in the same way as the default controller.
+ $id_field = $entity_info['entity keys']['id'];
+ $table = $entity_info['base table'];
+
+ // Select all entity ids.
+ $query = db_select($table, 't');
+ $query->addExpression("CONCAT(:prefix, t.$id_field)", 'item_id', array(':prefix' => $type . '/'));
+ $query->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
+ $query->addExpression('1', 'changed');
+
+ // INSERT ... SELECT ...
+ db_insert($this->table)
+ ->from($query)
+ ->execute();
+
+ unset($types[$type]);
+ }
+ }
+
+ // In the absence of a "base table", use the slow entity_load().
+ if ($types) {
+ foreach ($types as $type) {
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', $type);
+ $result = $query->execute();
+ $ids = !empty($result[$type]) ? array_keys($result[$type]) : array();
+ if ($ids) {
+ foreach ($ids as $i => $id) {
+ $ids[$i] = $type . '/' . $id;
+ }
+ $this->trackItemInsert($ids, array($index), TRUE);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Starts tracking the index status for the given items on the given indexes.
+ *
+ * @param array $item_ids
+ * The IDs of new items to track.
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which items should be tracked.
+ * @param bool $skip_type_check
+ * (optional) If TRUE, don't check whether the type matches the index's
+ * datasource configuration. Internal use only.
+ *
+ * @return SearchApiIndex[]|null
+ * All indexes for which any items were added; or NULL if items were added
+ * for all of them.
+ *
+ * @throws SearchApiDataSourceException
+ * If any error state was encountered.
+ */
+ public function trackItemInsert(array $item_ids, array $indexes, $skip_type_check = FALSE) {
+ $ret = array();
+
+ foreach ($indexes as $index_id => $index) {
+ $ids = drupal_map_assoc($item_ids);
+
+ if (!$skip_type_check) {
+ $types = $this->getEntityTypes($index);
+ foreach ($ids as $id) {
+ list($type) = explode('/', $id);
+ if (!isset($types[$type])) {
+ unset($ids[$id]);
+ }
+ }
+ }
+
+ if ($ids) {
+ parent::trackItemInsert($ids, array($index));
+ $ret[$index_id] = $index;
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ $form['types'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Entity types'),
+ '#description' => t('Select the entity types which should be included in this index.'),
+ '#options' => array_map('check_plain', search_api_entity_type_options_list()),
+ '#attributes' => array('class' => array('search-api-checkboxes-list')),
+ '#disabled' => !empty($form_state['index']),
+ '#required' => TRUE,
+ );
+ if (!empty($form_state['index']->options['datasource']['types'])) {
+ $form['types']['#default_value'] = $this->getEntityTypes($form_state['index']);
+ }
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+ if (!empty($values['types'])) {
+ $values['types'] = array_keys(array_filter($values['types']));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfigurationSummary(SearchApiIndex $index) {
+ if ($type_labels = $this->getSelectedEntityTypeOptions($index)) {
+ $args['!types'] = implode(', ', $type_labels);
+ return format_plural(count($type_labels), 'Indexed entity types: !types.', 'Indexed entity types: !types.', $args);
+ }
+ return NULL;
+ }
+
+ /**
+ * Retrieves the index for which the current method was called.
+ *
+ * Very ugly method which uses the stack trace to find the right object.
+ *
+ * @return SearchApiIndex
+ * The active index.
+ *
+ * @throws SearchApiException
+ * Thrown if the active index could not be determined.
+ */
+ protected function getCallingIndex() {
+ foreach (debug_backtrace() as $trace) {
+ if (isset($trace['object']) && $trace['object'] instanceof SearchApiIndex) {
+ return $trace['object'];
+ }
+ }
+ // If there's only a single index on the site, it's also easy.
+ $indexes = search_api_index_load_multiple(FALSE);
+ if (count($indexes) === 1) {
+ return reset($indexes);
+ }
+ throw new SearchApiException('Could not determine the active index of the datasource.');
+ }
+
+ /**
+ * Returns the entity types for which this datasource is configured.
+ *
+ * Depends on the index from which this method is (indirectly) called.
+ *
+ * @param SearchApiIndex $index
+ * (optional) The index for which to get the enabled entity types. If not
+ * given, will be determined automatically.
+ *
+ * @return string[]
+ * The machine names of the datasource's enabled entity types, as both keys
+ * and values.
+ *
+ * @throws SearchApiException
+ * Thrown if the active index could not be determined.
+ */
+ protected function getEntityTypes(SearchApiIndex $index = NULL) {
+ if (!$index) {
+ $index = $this->getCallingIndex();
+ }
+ if (isset($index->options['datasource']['types'])) {
+ return drupal_map_assoc($index->options['datasource']['types']);
+ }
+ return array();
+ }
+
+ /**
+ * Returns the selected entity type options for this datasource.
+ *
+ * Depends on the index from which this method is (indirectly) called.
+ *
+ * @param SearchApiIndex $index
+ * (optional) The index for which to get the enabled entity types. If not
+ * given, will be determined automatically.
+ *
+ * @return string[]
+ * An associative array, mapping the machine names of the enabled entity
+ * types to their labels.
+ *
+ * @throws SearchApiException
+ * Thrown if the active index could not be determined.
+ */
+ protected function getSelectedEntityTypeOptions(SearchApiIndex $index = NULL) {
+ return array_intersect_key(search_api_entity_type_options_list(), $this->getEntityTypes($index));
+ }
+
+}
diff --git a/includes/exception.inc b/includes/exception.inc
index 4e7a0c83..2e4f7903 100644
--- a/includes/exception.inc
+++ b/includes/exception.inc
@@ -1,5 +1,10 @@
enabled) {
- $this->queueItems();
- }
- $server = $this->server();
- if ($server) {
- // Tell the server about the new index.
- if ($server->enabled) {
+ try {
+ if ($server = $this->server()) {
+ // Tell the server about the new index.
$server->addIndex($this);
+ if ($this->enabled) {
+ $this->queueItems();
+ }
}
- else {
- $tasks = variable_get('search_api_tasks', array());
- // When we add or remove an index, we can ignore all other tasks.
- $tasks[$server->machine_name][$this->machine_name] = array('add');
- variable_set('search_api_tasks', $tasks);
- }
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
}
}
@@ -197,19 +201,13 @@ class SearchApiIndex extends Entity {
* Execute necessary tasks when the index is removed from the database.
*/
public function postDelete() {
- if ($server = $this->server()) {
- if ($server->enabled) {
+ try {
+ if ($server = $this->server()) {
$server->removeIndex($this);
}
- // Once the index is deleted, servers won't be able to tell whether it was
- // read-only. Therefore, we prefer to err on the safe side and don't call
- // the server method at all if the index is read-only and the server
- // currently disabled.
- elseif (empty($this->read_only)) {
- $tasks = variable_get('search_api_tasks', array());
- $tasks[$server->machine_name][$this->machine_name] = array('remove');
- variable_set('search_api_tasks', $tasks);
- }
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
}
// Stop tracking entities for indexing.
@@ -221,7 +219,12 @@ class SearchApiIndex extends Entity {
*/
public function queueItems() {
if (!$this->read_only) {
- $this->datasource()->startTracking(array($this));
+ try {
+ $this->datasource()->startTracking(array($this));
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
}
}
@@ -229,15 +232,20 @@ class SearchApiIndex extends Entity {
* Remove all records of entities to index.
*/
public function dequeueItems() {
- $this->datasource()->stopTracking(array($this));
- _search_api_empty_cron_queue($this);
+ try {
+ $this->datasource()->stopTracking(array($this));
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
}
/**
- * Saves this index to the database, either creating a new record or updating
- * an existing one.
+ * Saves this index to the database.
*
- * @return
+ * Either creates a new record or updates the existing one with the same ID.
+ *
+ * @return int|false
* Failure to save the index will return FALSE. Otherwise, SAVED_NEW or
* SAVED_UPDATED is returned depending on the operation performed. $this->id
* will be set if a new index was inserted.
@@ -246,15 +254,25 @@ class SearchApiIndex extends Entity {
if (empty($this->description)) {
$this->description = NULL;
}
- if (empty($this->server)) {
+ $server = FALSE;
+ if (!empty($this->server)) {
+ $server = search_api_server_load($this->server);
+ if (!$server) {
+ $vars['%server'] = $this->server;
+ $vars['%index'] = $this->name;
+ watchdog('search_api', 'Unknown server %server specified for index %index.', $vars, WATCHDOG_ERROR);
+ }
+ }
+ if (!$server) {
$this->server = NULL;
$this->enabled = FALSE;
}
- // This will also throw an exception if the server doesn't exist – which is good.
- elseif (!$this->server(TRUE)->enabled) {
- $this->enabled = FALSE;
+ if (!empty($this->options['fields'])) {
+ ksort($this->options['fields']);
}
+ $this->resetCaches();
+
return parent::save();
}
@@ -267,7 +285,7 @@ class SearchApiIndex extends Entity {
* @param array $fields
* The new field values.
*
- * @return
+ * @return int|false
* SAVE_UPDATED on success, FALSE on failure, 0 if the fields already had
* the specified values.
*/
@@ -296,7 +314,7 @@ class SearchApiIndex extends Entity {
/**
* Schedules this search index for re-indexing.
*
- * @return
+ * @return bool
* TRUE on success, FALSE on failure.
*/
public function reindex() {
@@ -311,7 +329,7 @@ class SearchApiIndex extends Entity {
/**
* Clears this search index and schedules all of its items for re-indexing.
*
- * @return
+ * @return bool
* TRUE on success, FALSE on failure.
*/
public function clear() {
@@ -319,19 +337,11 @@ class SearchApiIndex extends Entity {
return TRUE;
}
- $server = $this->server();
- if ($server->enabled) {
- $server->deleteItems('all', $this);
+ try {
+ $this->server()->deleteItems('all', $this);
}
- else {
- $tasks = variable_get('search_api_tasks', array());
- // If the index was cleared or newly added since the server was last enabled, we don't need to do anything.
- if (!isset($tasks[$server->machine_name][$this->machine_name])
- || (array_search('add', $tasks[$server->machine_name][$this->machine_name]) === FALSE
- && array_search('clear', $tasks[$server->machine_name][$this->machine_name]) === FALSE)) {
- $tasks[$server->machine_name][$this->machine_name][] = 'clear';
- variable_set('search_api_tasks', $tasks);
- }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
}
_search_api_index_reindex($this);
@@ -377,7 +387,12 @@ class SearchApiIndex extends Entity {
* otherwise.
*/
public function getEntityType() {
- return $this->datasource()->getEntityType();
+ try {
+ return $this->datasource()->getEntityType();
+ }
+ catch (SearchApiException $e) {
+ return NULL;
+ }
}
/**
@@ -412,7 +427,7 @@ class SearchApiIndex extends Entity {
* SearchApiQueryInterface::__construct().
*
* @throws SearchApiException
- * If the index is currently disabled.
+ * If the index is currently disabled or its server doesn't exist.
*
* @return SearchApiQueryInterface
* A query object for searching this index.
@@ -424,17 +439,21 @@ class SearchApiIndex extends Entity {
return $this->server()->query($this, $options);
}
-
/**
- * Indexes items on this index. Will return an array of IDs of items that
- * should be marked as indexed – i.e., items that were either rejected by a
- * data-alter callback or were successfully indexed.
+ * Indexes items on this index.
+ *
+ * Will return an array of IDs of items that should be marked as indexed –
+ * i.e., items that were either rejected by a data-alter callback or were
+ * successfully indexed.
*
* @param array $items
- * An array of items to index.
+ * An array of items to index, of this index's item type.
*
* @return array
* An array of the IDs of all items that should be marked as indexed.
+ *
+ * @throws SearchApiException
+ * If an error occurred during indexing.
*/
public function index(array $items) {
if ($this->read_only) {
@@ -745,12 +764,14 @@ class SearchApiIndex extends Entity {
* "additional fields" key.
*/
public function getFields($only_indexed = TRUE, $get_additional = FALSE) {
+ global $language;
+
$only_indexed = $only_indexed ? 1 : 0;
$get_additional = $get_additional ? 1 : 0;
// First, try the static cache and the persistent cache bin.
if (empty($this->fields[$only_indexed][$get_additional])) {
- $cid = $this->getCacheId() . "-$only_indexed-$get_additional";
+ $cid = $this->getCacheId() . "-$only_indexed-$get_additional-{$language->language}";
$cache = cache_get($cid);
if ($cache) {
$this->fields[$only_indexed][$get_additional] = $cache->data;
@@ -915,7 +936,12 @@ class SearchApiIndex extends Entity {
$i = $only_indexed ? 1 : 0;
if (!isset($this->fulltext_fields[$i])) {
$this->fulltext_fields[$i] = array();
- $fields = $only_indexed ? $this->options['fields'] : $this->getFields(FALSE);
+ if ($only_indexed) {
+ $fields = isset($this->options['fields']) ? $this->options['fields'] : array();
+ }
+ else {
+ $fields = $this->getFields(FALSE);
+ }
foreach ($fields as $key => $field) {
if (search_api_is_text_type($field['type'])) {
$this->fulltext_fields[$i][] = $key;
@@ -952,12 +978,18 @@ class SearchApiIndex extends Entity {
* @return EntityMetadataWrapper
* A wrapper for the item type of this index, optionally loaded with the
* given data and having additional fields according to the data alterations
- * of this index.
+ * of this index (if $alter wasn't set to FALSE).
*/
public function entityWrapper($item = NULL, $alter = TRUE) {
- $info['property info alter'] = $alter ? array($this, 'propertyInfoAlter') : '_search_api_wrapper_add_all_properties';
- $info['property defaults']['property info alter'] = '_search_api_wrapper_add_all_properties';
- return $this->datasource()->getMetadataWrapper($item, $info);
+ try {
+ $info['property info alter'] = $alter ? array($this, 'propertyInfoAlter') : '_search_api_wrapper_add_all_properties';
+ $info['property defaults']['property info alter'] = '_search_api_wrapper_add_all_properties';
+ return $this->datasource()->getMetadataWrapper($item, $info);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ return entity_metadata_wrapper($this->item_type);
+ }
}
/**
@@ -972,16 +1004,24 @@ class SearchApiIndex extends Entity {
* @see SearchApiDataSourceControllerInterface::loadItems()
*/
public function loadItems(array $ids) {
- return $this->datasource()->loadItems($ids);
+ try {
+ return $this->datasource()->loadItems($ids);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ return array();
+ }
}
/**
- * Reset internal static caches.
+ * Reset internal caches.
*
* Should be used when things like fields or data alterations change to avoid
* using stale data.
*/
public function resetCaches() {
+ cache_clear_all($this->getCacheId(''), 'cache', TRUE);
+
$this->datasource = NULL;
$this->server_object = NULL;
$this->callbacks = NULL;
diff --git a/includes/processor.inc b/includes/processor.inc
index 1774bf19..4baedbbd 100644
--- a/includes/processor.inc
+++ b/includes/processor.inc
@@ -1,5 +1,10 @@
options['fields']));
}
foreach ($fields as $name => $field) {
- $field_options[$name] = $field['name'];
+ $field_options[$name] = check_plain($field['name']);
if (!empty($default_fields[$name]) || (!isset($this->options['fields']) && $this->testField($name, $field))) {
$default_fields[$name] = $name;
}
@@ -385,13 +390,15 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
}
/**
+ * Determines whether to process data from the given field.
+ *
* @param $name
* The field's machine name.
* @param array $field
* The field's information.
*
- * @return
- * TRUE, iff the field should be processed.
+ * @return bool
+ * TRUE, if the field should be processed, FALSE otherwise.
*/
protected function testField($name, array $field) {
if (empty($this->options['fields'])) {
@@ -401,8 +408,13 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
}
/**
- * @return
- * TRUE, iff the type should be processed.
+ * Determines whether fields of the given type should normally be processed.
+ *
+ * Defaults to processing text types, but can easily be overridden by
+ * subclasses.
+ *
+ * @return bool
+ * TRUE, if the type should be processed, FALSE otherwise.
*/
protected function testType($type) {
return search_api_is_text_type($type, array('text', 'tokens'));
diff --git a/includes/processor_highlight.inc b/includes/processor_highlight.inc
index f5d463d5..04be02b6 100644
--- a/includes/processor_highlight.inc
+++ b/includes/processor_highlight.inc
@@ -19,13 +19,26 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
*/
protected static $boundary;
+ /**
+ * PREG regular expression for splitting words.
+ *
+ * @var string
+ */
+ protected static $split;
+
/**
* {@inheritdoc}
*/
public function __construct(SearchApiIndex $index, array $options = array()) {
parent::__construct($index, $options);
- self::$boundary = '(?:(?<=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . PREG_CLASS_CJK . '])|(?=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . PREG_CLASS_CJK . ']))';
+ $cjk = '\x{1100}-\x{11FF}\x{3040}-\x{309F}\x{30A1}-\x{318E}' .
+ '\x{31A0}-\x{31B7}\x{31F0}-\x{31FF}\x{3400}-\x{4DBF}\x{4E00}-\x{9FCF}' .
+ '\x{A000}-\x{A48F}\x{A4D0}-\x{A4FD}\x{A960}-\x{A97F}\x{AC00}-\x{D7FF}' .
+ '\x{F900}-\x{FAFF}\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}\x{FF66}-\x{FFDC}' .
+ '\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}';
+ self::$boundary = '(?:(?<=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . '])|(?=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . ']))';
+ self::$split = '/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . ']+/iu';
}
/**
@@ -38,6 +51,8 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
'excerpt' => TRUE,
'excerpt_length' => 256,
'highlight' => 'always',
+ 'highlight_partial' => FALSE,
+ 'exclude_fields' => array(),
);
$form['prefix'] = array(
@@ -72,6 +87,22 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
),
),
);
+ // Exclude certain fulltext fields.
+ $fields = $this->index->getFields();
+ $fulltext_fields = array();
+ foreach ($this->index->getFulltextFields() as $field) {
+ if (isset($fields[$field])) {
+ $fulltext_fields[$field] = check_plain($fields[$field]['name'] . ' (' . $field . ')');
+ }
+ }
+ $form['exclude_fields'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Exclude fields from excerpt'),
+ '#description' => t('Exclude certain fulltext fields from being displayed in the excerpt.'),
+ '#options' => $fulltext_fields,
+ '#default_value' => $this->options['exclude_fields'],
+ '#attributes' => array('class' => array('search-api-checkboxes-list')),
+ );
$form['highlight'] = array(
'#type' => 'select',
'#title' => t('Highlight returned field data'),
@@ -84,6 +115,13 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
'#default_value' => $this->options['highlight'],
);
+ $form['highlight_partial'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Highlight partial matches'),
+ '#description' => t('When enabled, matches in parts of words will be highlighted as well.'),
+ '#default_value' => $this->options['highlight_partial'],
+ );
+
return $form;
}
@@ -91,21 +129,29 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
* {@inheritdoc}
*/
public function configurationFormValidate(array $form, array &$values, array &$form_state) {
- // Overridden so $form['fields'] is not checked.
+ $values['exclude_fields'] = array_filter($values['exclude_fields']);
}
/**
* {@inheritdoc}
*/
public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
- if (!$response['result count'] || !($keys = $this->getKeywords($query))) {
+ if (empty($response['results']) || !($keys = $this->getKeywords($query))) {
return;
}
+ $fulltext_fields = $this->index->getFulltextFields();
+ if (!empty($this->options['exclude_fields'])) {
+ $fulltext_fields = drupal_map_assoc($fulltext_fields);
+ foreach ($this->options['exclude_fields'] as $field) {
+ unset($fulltext_fields[$field]);
+ }
+ }
+
foreach ($response['results'] as $id => &$result) {
if ($this->options['excerpt']) {
$text = array();
- $fields = $this->getFulltextFields($response['results'], $id);
+ $fields = $this->getFulltextFields($response['results'], $id, $fulltext_fields);
foreach ($fields as $data) {
if (is_array($data)) {
$text = array_merge($text, $data);
@@ -114,18 +160,20 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
$text[] = $data;
}
}
- $result['excerpt'] = $this->createExcerpt(implode("\n\n", $text), $keys);
+
+ $result['excerpt'] = $this->createExcerpt($this->flattenArrayValues($text), $keys);
}
if ($this->options['highlight'] != 'never') {
- $fields = $this->getFulltextFields($response['results'], $id, $this->options['highlight'] == 'always');
+ $fields = $this->getFulltextFields($response['results'], $id, $fulltext_fields, $this->options['highlight'] == 'always');
foreach ($fields as $field => $data) {
+ $result['fields'][$field] = array('#sanitize_callback' => FALSE);
if (is_array($data)) {
foreach ($data as $i => $text) {
- $result['fields'][$field][$i] = $this->highlightField($text, $keys);
+ $result['fields'][$field]['#value'][$i] = $this->highlightField($text, $keys);
}
}
else {
- $result['fields'][$field] = $this->highlightField($data, $keys);
+ $result['fields'][$field]['#value'] = $this->highlightField($data, $keys);
}
}
}
@@ -135,11 +183,13 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
/**
* Retrieves the fulltext data of a result.
*
- * @param array $result
- * All results returned in the search.
+ * @param array $results
+ * All results returned in the search, by reference.
* @param int|string $i
* The index in the results array of the result whose data should be
* returned.
+ * @param array $fulltext_fields
+ * The fulltext fields from which the excerpt should be created.
* @param bool $load
* TRUE if the item should be loaded if necessary, FALSE if only fields
* already returned in the results should be used.
@@ -148,20 +198,21 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
* An array containing fulltext field names mapped to the text data
* contained in them for the given result.
*/
- protected function getFulltextFields(array &$results, $i, $load = TRUE) {
+ protected function getFulltextFields(array &$results, $i, array $fulltext_fields, $load = TRUE) {
+ global $language;
$data = array();
- // Act as if $load is TRUE if we have a loaded item.
- $load |= !empty($result['entity']);
$result = &$results[$i];
+ // Act as if $load is TRUE if we have a loaded item.
+ $load |= !empty($result['entity']);
$result += array('fields' => array());
- $fulltext_fields = $this->index->getFulltextFields();
// We only need detailed fields data if $load is TRUE.
$fields = $load ? $this->index->getFields() : array();
$needs_extraction = array();
+ $returned_fields = search_api_get_sanitized_field_values(array_intersect_key($result['fields'], array_flip($fulltext_fields)));
foreach ($fulltext_fields as $field) {
- if (array_key_exists($field, $result['fields'])) {
- $data[$field] = $result['fields'][$field];
+ if (array_key_exists($field, $returned_fields)) {
+ $data[$field] = $returned_fields[$field];
}
elseif ($load) {
$needs_extraction[$field] = $fields[$field];
@@ -183,7 +234,8 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
return $data;
}
$wrapper = $this->index->entityWrapper($result['entity'], FALSE);
- $extracted = search_api_extract_fields($wrapper, $needs_extraction);
+ $wrapper->language($language->language);
+ $extracted = search_api_extract_fields($wrapper, $needs_extraction, array('sanitize' => TRUE));
foreach ($extracted as $field => $info) {
if (isset($info['value'])) {
@@ -212,7 +264,7 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
return $this->flattenKeysArray($keys);
}
- $keywords = preg_split('/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . PREG_CLASS_CJK . ']+/iu', $keys);
+ $keywords = preg_split(self::$split, $keys);
// Assure there are no duplicates. (This is actually faster than
// array_unique() by a factor of 3 to 4.)
$keywords = drupal_map_assoc(array_filter($keywords));
@@ -277,11 +329,10 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
// If the sum of all fragments is too short, we look for second occurrences.
$ranges = array();
$included = array();
- $foundkeys = array();
$length = 0;
- $workkeys = $keys;
- while ($length < $this->options['excerpt_length'] && count($workkeys)) {
- foreach ($workkeys as $k => $key) {
+ $work_keys = $keys;
+ while ($length < $this->options['excerpt_length'] && $work_keys) {
+ foreach ($work_keys as $k => $key) {
if ($length >= $this->options['excerpt_length']) {
break;
}
@@ -293,8 +344,15 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
// Locate a keyword (position $p, always >0 because $text starts with a
// space).
$p = 0;
- if (preg_match('/' . self::$boundary . $key . self::$boundary . '/iu', $text, $match, PREG_OFFSET_CAPTURE, $included[$key])) {
- $p = $match[0][1];
+ if (empty($this->options['highlight_partial'])) {
+ $regex = '/' . self::$boundary . preg_quote($key, '/') . self::$boundary . '/iu';
+ if (preg_match($regex, $text, $match, PREG_OFFSET_CAPTURE, $included[$key])) {
+ $p = $match[0][1];
+ }
+ }
+ else {
+ $function = function_exists('mb_stripos') ? 'mb_stripos' : 'stripos';
+ $p = $function($text, $key, $included[$key]);
}
// Now locate a space in front (position $q) and behind it (position $s),
// leaving about 60 characters extra before and after for context.
@@ -309,18 +367,13 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
$ranges[$q] = $p + $s;
$length += $p + $s - $q;
$included[$key] = $p + 1;
+ continue;
}
- else {
- unset($workkeys[$k]);
- }
- }
- else {
- unset($workkeys[$k]);
}
}
- else {
- unset($workkeys[$k]);
- }
+ // Unless we got a match above, we don't need to look for this key any
+ // more.
+ unset($work_keys[$k]);
}
}
@@ -363,7 +416,9 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
$text = (isset($newranges[0]) ? '' : $dots[0]) . implode($dots[1], $out) . $dots[2];
$text = check_plain($text);
- return $this->highlightField($text, $keys);
+ // Since we stripped the tags at the beginning, highlighting doesn't need to
+ // handle HTML anymore.
+ return $this->highlightField($text, $keys, FALSE);
}
/**
@@ -373,14 +428,59 @@ class SearchApiHighlight extends SearchApiAbstractProcessor {
* The text of the field.
* @param array $keys
* Search keywords entered by the user.
+ * @param bool $html
+ * Whether the text can contain HTML tags or not. In the former case, text
+ * inside tags (i.e., tag names and attributes) won't be highlighted.
*
* @return string
* The field's text with all occurrences of search keywords highlighted.
*/
- protected function highlightField($text, array $keys) {
+ protected function highlightField($text, array $keys, $html = TRUE) {
+ if (is_array($text)) {
+ $text = $this->flattenArrayValues($text);
+ }
+
+ if ($html) {
+ $texts = preg_split('#((?:?[[:alpha:]](?:[^>"\']*|"[^"]*"|\'[^\']\')*>)+)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+ for ($i = 0; $i < count($texts); $i += 2) {
+ $texts[$i] = $this->highlightField($texts[$i], $keys, FALSE);
+ }
+ return implode('', $texts);
+ }
+
+ $keys = implode('|', array_map('preg_quote', $keys, array_fill(0, count($keys), '/')));
+ // If "Highlight partial matches" is disabled, we only want to highlight
+ // matches that are complete words. Otherwise, we want all of them.
+ $boundary = empty($this->options['highlight_partial']) ? self::$boundary : '';
+ $regex = '/' . $boundary . '(?:' . $keys . ')' . $boundary . '/iu';
$replace = $this->options['prefix'] . '\0' . $this->options['suffix'];
- $text = preg_replace('/' . self::$boundary . '(' . implode('|', $keys) . ')' . self::$boundary . '/iu', $replace, ' ' . $text);
- return substr($text, 1);
+ $text = preg_replace($regex, $replace, ' ' . $text . ' ');
+ return substr($text, 1, -1);
+ }
+
+ /**
+ * Flattens a (possibly multidimensional) array into a string.
+ *
+ * @param array $array
+ * The array to flatten.
+ * @param string $glue
+ * (optional) The separator to insert between individual array items.
+ *
+ * @return string
+ * The glued string.
+ */
+ protected function flattenArrayValues(array $array, $glue = " \n\n ") {
+ $ret = array();
+ foreach ($array as $item) {
+ if (is_array($item)) {
+ $ret[] = $this->flattenArrayValues($item, $glue);
+ }
+ else {
+ $ret[] = $item;
+ }
+ }
+
+ return implode($glue, $ret);
}
}
diff --git a/includes/processor_html_filter.inc b/includes/processor_html_filter.inc
index f636e2f2..0cc4800d 100644
--- a/includes/processor_html_filter.inc
+++ b/includes/processor_html_filter.inc
@@ -1,5 +1,10 @@
parseText($text);
}
else {
- $value = strip_tags($text);
+ $value = html_entity_decode(strip_tags($text));
+ // Remove any multiple or leading/trailing spaces we might have introduced.
+ $value = preg_replace('/\s\s+/', ' ', trim($value));
}
}
@@ -104,13 +111,18 @@ class SearchApiHtmlFilter extends SearchApiAbstractProcessor {
$ret = array();
while (($pos = strpos($text, '<')) !== FALSE) {
if ($boost && $pos > 0) {
+ $token = html_entity_decode(substr($text, 0, $pos), ENT_QUOTES, 'UTF-8');
+ // Remove any multiple or leading/trailing spaces we might have introduced.
+ $token = preg_replace('/\s\s+/', ' ', trim($token));
$ret[] = array(
- 'value' => html_entity_decode(substr($text, 0, $pos), ENT_QUOTES, 'UTF-8'),
+ 'value' => $token,
'score' => $boost,
);
}
$text = substr($text, $pos + 1);
- preg_match('#^(/?)([-:_a-zA-Z]+)#', $text, $m);
+ if (!preg_match('#^(/?)([:_a-zA-Z][-:_a-zA-Z0-9.]*)#', $text, $m)) {
+ continue;
+ }
$text = substr($text, strpos($text, '>') + 1);
if ($m[1]) {
// Closing tag.
@@ -125,8 +137,11 @@ class SearchApiHtmlFilter extends SearchApiAbstractProcessor {
}
}
if ($text) {
+ $token = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
+ // Remove any multiple or leading/trailing spaces we might have introduced.
+ $token = preg_replace('/\s\s+/', ' ', trim($token));
$ret[] = array(
- 'value' => html_entity_decode($text, ENT_QUOTES, 'UTF-8'),
+ 'value' => $token,
'score' => $boost,
);
$text = '';
diff --git a/includes/processor_ignore_case.inc b/includes/processor_ignore_case.inc
index 476609d4..4a88e73d 100644
--- a/includes/processor_ignore_case.inc
+++ b/includes/processor_ignore_case.inc
@@ -1,5 +1,10 @@
url('http://snowball.tartarus.org/algorithms/english/stemmer.html'),
+ );
+ $form += array(
+ 'help' => array(
+ '#markup' => '
' . t('Optionally, provide an exclusion list to override the stemmer algorithm. (Read about the algorithm.)', $args) . '
',
+ ),
+ 'exceptions' => array(
+ '#type' => 'textarea',
+ '#title' => t('Exceptions'),
+ '#description' => t('Enter exceptions in the form of WORD=STEM, where "WORD" is the term entered and "STEM" is the resulting stem. List each exception on a separate line.'),
+ '#default_value' => "texan=texa",
+ ),
+ );
+
+ if (!empty($this->options['exceptions'])) {
+ $form['exceptions']['#default_value'] = $this->options['exceptions'];
+ }
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function process(&$value) {
+ // Load custom exceptions.
+ $exceptions = $this->getExceptions();
+
+ $words = preg_split('/[^\p{L}\p{N}]+/u', $value, -1 , PREG_SPLIT_DELIM_CAPTURE);
+ $stemmed = array();
+ foreach ($words as $i => $word) {
+ if ($i % 2 == 0 && strlen($word)) {
+ if (!isset($this->stems[$word])) {
+ $stem = new SearchApiPorter2($word, $exceptions);
+ $this->stems[$word] = $stem->stem();
+ }
+ $stemmed[] = $this->stems[$word];
+ }
+ else {
+ $stemmed[] = $word;
+ }
+ }
+ $value = implode(' ', $stemmed);
+ }
+
+ /**
+ * Retrieves the processor's configured exceptions.
+ *
+ * @return string[]
+ * An associative array of exceptions, with words as keys and stems as their
+ * replacements.
+ */
+ protected function getExceptions() {
+ if (!empty($this->options['exceptions'])) {
+ $exceptions = parse_ini_string($this->options['exceptions'], TRUE);
+ return is_array($exceptions) ? $exceptions : array();
+ }
+ return array();
+ }
+
+}
+
+/**
+ * Implements the Porter2 stemming algorithm.
+ *
+ * @see https://github.com/markfullmer/porter2
+ */
+class SearchApiPorter2 {
+
+ /**
+ * The word being stemmed.
+ *
+ * @var string
+ */
+ protected $word;
+
+ /**
+ * The R1 of the word.
+ *
+ * @var int
+ *
+ * @see http://snowball.tartarus.org/texts/r1r2.html.
+ */
+ protected $r1;
+
+ /**
+ * The R2 of the word.
+ *
+ * @var int
+ *
+ * @see http://snowball.tartarus.org/texts/r1r2.html.
+ */
+ protected $r2;
+
+ /**
+ * List of exceptions to be used.
+ *
+ * @var string[]
+ */
+ protected $exceptions = array();
+
+ /**
+ * Constructs a SearchApiPorter2 object.
+ *
+ * @param string $word
+ * The word to stem.
+ * @param string[] $custom_exceptions
+ * (optional) A custom list of exceptions.
+ */
+ public function __construct($word, $custom_exceptions = array()) {
+ $this->word = $word;
+ $this->exceptions = $custom_exceptions + array(
+ 'skis' => 'ski',
+ 'skies' => 'sky',
+ 'dying' => 'die',
+ 'lying' => 'lie',
+ 'tying' => 'tie',
+ 'idly' => 'idl',
+ 'gently' => 'gentl',
+ 'ugly' => 'ugli',
+ 'early' => 'earli',
+ 'only' => 'onli',
+ 'singly' => 'singl',
+ 'sky' => 'sky',
+ 'news' => 'news',
+ 'howe' => 'howe',
+ 'atlas' => 'atlas',
+ 'cosmos' => 'cosmos',
+ 'bias' => 'bias',
+ 'andes' => 'andes',
+ );
+
+ // Set initial y, or y after a vowel, to Y.
+ $inc = 0;
+ while ($inc <= $this->length()) {
+ if (substr($this->word, $inc, 1) === 'y' && ($inc == 0 || $this->isVowel($inc - 1))) {
+ $this->word = substr_replace($this->word, 'Y', $inc, 1);
+
+ }
+ $inc++;
+ }
+ // Establish the regions R1 and R2. See function R().
+ $this->r1 = $this->R(1);
+ $this->r2 = $this->R(2);
+ }
+
+ /**
+ * Computes the stem of the word.
+ *
+ * @return string
+ * The word's stem.
+ */
+ public function stem() {
+ // Ignore exceptions & words that are two letters or less.
+ if ($this->exceptions() || $this->length() <= 2) {
+ return strtolower($this->word);
+ }
+ else {
+ $this->step0();
+ $this->step1a();
+ $this->step1b();
+ $this->step1c();
+ $this->step2();
+ $this->step3();
+ $this->step4();
+ $this->step5();
+ }
+ return strtolower($this->word);
+ }
+
+ /**
+ * Determines whether the word is contained in our list of exceptions.
+ *
+ * If so, the $word property is changed to the stem listed in the exceptions.
+ *
+ * @return bool
+ * TRUE if the word was an exception, FALSE otherwise.
+ */
+ protected function exceptions() {
+ if (isset($this->exceptions[$this->word])) {
+ $this->word = $this->exceptions[$this->word];
+ return TRUE;
+ }
+ return FALSE;
+ }
+
+ /**
+ * Searches for the longest among the "s" suffixes and removes it.
+ *
+ * Implements step 0 of the Porter2 algorithm.
+ */
+ protected function step0() {
+ $found = FALSE;
+ $checks = array("'s'", "'s", "'");
+ foreach ($checks as $check) {
+ if (!$found && $this->hasEnding($check)) {
+ $this->removeEnding($check);
+ $found = TRUE;
+ }
+ }
+ }
+
+ /**
+ * Handles various suffixes, of which the longest is replaced.
+ *
+ * Implements step 1a of the Porter2 algorithm.
+ */
+ protected function step1a() {
+ $found = FALSE;
+ if ($this->hasEnding('sses')) {
+ $this->removeEnding('sses');
+ $this->addEnding('ss');
+ $found = TRUE;
+ }
+ $checks = array('ied', 'ies');
+ foreach ($checks as $check) {
+ if (!$found && $this->hasEnding($check)) {
+ $length = $this->length();
+ $this->removeEnding($check);
+ if ($length > 4) {
+ $this->addEnding('i');
+ }
+ else {
+ $this->addEnding('ie');
+ }
+ $found = TRUE;
+ }
+ }
+ if ($this->hasEnding('us') || $this->hasEnding('ss')) {
+ $found = TRUE;
+ }
+ // Delete if preceding word part has a vowel not immediately before the s.
+ if (!$found && $this->hasEnding('s') && $this->containsVowel(substr($this->word, 0, -2))) {
+ $this->removeEnding('s');
+ }
+ }
+
+ /**
+ * Handles various suffixes, of which the longest is replaced.
+ *
+ * Implements step 1b of the Porter2 algorithm.
+ */
+ protected function step1b() {
+ $exceptions = array(
+ 'inning',
+ 'outing',
+ 'canning',
+ 'herring',
+ 'earring',
+ 'proceed',
+ 'exceed',
+ 'succeed',
+ );
+ if (in_array($this->word, $exceptions)) {
+ return;
+ }
+ $checks = array('eedly', 'eed');
+ foreach ($checks as $check) {
+ if ($this->hasEnding($check)) {
+ if ($this->r1 !== $this->length()) {
+ $this->removeEnding($check);
+ $this->addEnding('ee');
+ }
+ return;
+ }
+ }
+ $checks = array('ingly', 'edly', 'ing', 'ed');
+ $second_endings = array('at', 'bl', 'iz');
+ foreach ($checks as $check) {
+ // If the ending is present and the previous part contains a vowel.
+ if ($this->hasEnding($check) && $this->containsVowel(substr($this->word, 0, -strlen($check)))) {
+ $this->removeEnding($check);
+ foreach ($second_endings as $ending) {
+ if ($this->hasEnding($ending)) {
+ $this->addEnding('e');
+ return;
+ }
+ }
+ // If the word ends with a double, remove the last letter.
+ $found = $this->removeDoubles();
+ // If the word is short, add e (so hop -> hope).
+ if (!$found && ($this->isShort())) {
+ $this->addEnding('e');
+ }
+ return;
+ }
+ }
+ }
+
+ /**
+ * Replaces suffix y or Y with i if after non-vowel not @ word begin.
+ *
+ * Implements step 1c of the Porter2 algorithm.
+ */
+ protected function step1c() {
+ if (($this->hasEnding('y') || $this->hasEnding('Y')) && $this->length() > 2 && !($this->isVowel($this->length() - 2))) {
+ $this->removeEnding('y');
+ $this->addEnding('i');
+ }
+ }
+
+ /**
+ * Implements step 2 of the Porter2 algorithm.
+ */
+ protected function step2() {
+ $checks = array(
+ "ization" => "ize",
+ "iveness" => "ive",
+ "fulness" => "ful",
+ "ational" => "ate",
+ "ousness" => "ous",
+ "biliti" => "ble",
+ "tional" => "tion",
+ "lessli" => "less",
+ "fulli" => "ful",
+ "entli" => "ent",
+ "ation" => "ate",
+ "aliti" => "al",
+ "iviti" => "ive",
+ "ousli" => "ous",
+ "alism" => "al",
+ "abli" => "able",
+ "anci" => "ance",
+ "alli" => "al",
+ "izer" => "ize",
+ "enci" => "ence",
+ "ator" => "ate",
+ "bli" => "ble",
+ "ogi" => "og",
+ );
+ foreach ($checks as $find => $replace) {
+ if ($this->hasEnding($find)) {
+ if ($this->inR1($find)) {
+ $this->removeEnding($find);
+ $this->addEnding($replace);
+ }
+ return;
+ }
+ }
+ if ($this->hasEnding('li')) {
+ if ($this->length() > 4 && $this->validLi($this->charAt(-3))) {
+ $this->removeEnding('li');
+ }
+ }
+ }
+
+ /**
+ * Implements step 3 of the Porter2 algorithm.
+ */
+ protected function step3() {
+ $checks = array(
+ 'ational' => 'ate',
+ 'tional' => 'tion',
+ 'alize' => 'al',
+ 'icate' => 'ic',
+ 'iciti' => 'ic',
+ 'ical' => 'ic',
+ 'ness' => '',
+ 'ful' => '',
+ );
+ foreach ($checks as $find => $replace) {
+ if ($this->hasEnding($find)) {
+ if ($this->inR1($find)) {
+ $this->removeEnding($find);
+ $this->addEnding($replace);
+ }
+ return;
+ }
+ }
+ if ($this->hasEnding('ative')) {
+ if ($this->inR2('ative')) {
+ $this->removeEnding('ative');
+ }
+ }
+ }
+
+ /**
+ * Implements step 4 of the Porter2 algorithm.
+ */
+ protected function step4() {
+ $checks = array(
+ 'ement',
+ 'ment',
+ 'ance',
+ 'ence',
+ 'able',
+ 'ible',
+ 'ant',
+ 'ent',
+ 'ion',
+ 'ism',
+ 'ate',
+ 'iti',
+ 'ous',
+ 'ive',
+ 'ize',
+ 'al',
+ 'er',
+ 'ic',
+ );
+ foreach ($checks as $check) {
+ // Among the suffixes, if found and in R2, delete.
+ if ($this->hasEnding($check)) {
+ if ($this->inR2($check)) {
+ if ($check !== 'ion' || in_array($this->charAt(-4), array('s', 't'))) {
+ $this->removeEnding($check);
+ }
+ }
+ return;
+ }
+ }
+ }
+
+ /**
+ * Implements step 5 of the Porter2 algorithm.
+ */
+ protected function step5() {
+ if ($this->hasEnding('e')) {
+ // Delete if in R2, or in R1 and not preceded by a short syllable.
+ if ($this->inR2('e') || ($this->inR1('e') && !$this->isShortSyllable($this->length() - 3))) {
+ $this->removeEnding('e');
+ }
+ return;
+ }
+ if ($this->hasEnding('l')) {
+ // Delete if in R2 and preceded by l.
+ if ($this->inR2('l') && $this->charAt(-2) == 'l') {
+ $this->removeEnding('l');
+ }
+ }
+ }
+
+ /**
+ * Removes certain double consonants from the word's end.
+ *
+ * @return bool
+ * TRUE if a match was found and removed, FALSE otherwise.
+ */
+ protected function removeDoubles() {
+ $found = FALSE;
+ $doubles = array('bb', 'dd', 'ff', 'gg', 'mm', 'nn', 'pp', 'rr', 'tt');
+ foreach ($doubles as $double) {
+ if (substr($this->word, -2) == $double) {
+ $this->word = substr($this->word, 0, -1);
+ $found = TRUE;
+ break;
+ }
+ }
+ return $found;
+ }
+
+ /**
+ * Checks whether a character is a vowel.
+ *
+ * @param int $position
+ * The character's position.
+ * @param string|null $word
+ * (optional) The word in which to check. Defaults to $this->word.
+ * @param string[] $additional
+ * (optional) Additional characters that should count as vowels.
+ *
+ * @return bool
+ * TRUE if the character is a vowel, FALSE otherwise.
+ */
+ protected function isVowel($position, $word = NULL, $additional = array()) {
+ if ($word === NULL) {
+ $word = $this->word;
+ }
+ $vowels = array_merge(array('a', 'e', 'i', 'o', 'u', 'y'), $additional);
+ return in_array($this->charAt($position, $word), $vowels);
+ }
+
+ /**
+ * Retrieves the character at the given position.
+ *
+ * @param int $position
+ * The 0-based index of the character. If a negative number is given, the
+ * position is counted from the end of the string.
+ * @param string|null $word
+ * (optional) The word from which to retrieve the character. Defaults to
+ * $this->word.
+ *
+ * @return string
+ * The character at the given position, or an empty string if the given
+ * position was illegal.
+ */
+ protected function charAt($position, $word = NULL) {
+ if ($word === NULL) {
+ $word = $this->word;
+ }
+ $length = strlen($word);
+ if (abs($position) >= $length) {
+ return '';
+ }
+ if ($position < 0) {
+ $position += $length;
+ }
+ return $word[$position];
+ }
+
+ /**
+ * Determines whether the word ends in a "vowel-consonant" suffix.
+ *
+ * Unless the word is only two characters long, it also checks that the
+ * third-last character is neither "w", "x" nor "Y".
+ *
+ * @param int|null $position
+ * (optional) If given, do not check the end of the word, but the character
+ * at the given position, and the next one.
+ *
+ * @return bool
+ * TRUE if the word has the described suffix, FALSE otherwise.
+ */
+ protected function isShortSyllable($position = NULL) {
+ if ($position === NULL) {
+ $position = $this->length() - 2;
+ }
+ // A vowel at the beginning of the word followed by a non-vowel.
+ if ($position === 0) {
+ return $this->isVowel(0) && !$this->isVowel(1);
+ }
+ // Vowel followed by non-vowel other than w, x, Y and preceded by
+ // non-vowel.
+ $additional = array('w', 'x', 'Y');
+ return !$this->isVowel($position - 1) && $this->isVowel($position) && !$this->isVowel($position + 1, NULL, $additional);
+ }
+
+ /**
+ * Determines whether the word is short.
+ *
+ * A word is called short if it ends in a short syllable and if R1 is null.
+ *
+ * @return bool
+ * TRUE if the word is short, FALSE otherwise.
+ */
+ protected function isShort() {
+ return $this->isShortSyllable() && $this->r1 == $this->length();
+ }
+
+ /**
+ * Determines the start of a certain "R" region.
+ *
+ * R is a region after the first non-vowel following a vowel, or end of word.
+ *
+ * @param int $type
+ * (optional) 1 or 2. If 2, then calculate the R after the R1.
+ *
+ * @return int
+ * The R position.
+ */
+ protected function R($type = 1) {
+ $inc = 1;
+ if ($type === 2) {
+ $inc = $this->r1;
+ }
+ elseif ($this->length() > 5) {
+ $prefix_5 = substr($this->word, 0, 5);
+ if ($prefix_5 === 'gener' || $prefix_5 === 'arsen') {
+ return 5;
+ }
+ if ($this->length() > 6 && substr($this->word, 0, 6) === 'commun') {
+ return 6;
+ }
+ }
+
+ while ($inc <= $this->length()) {
+ if (!$this->isVowel($inc) && $this->isVowel($inc - 1)) {
+ $position = $inc;
+ break;
+ }
+ $inc++;
+ }
+ if (!isset($position)) {
+ $position = $this->length();
+ }
+ else {
+ // We add one, as this is the position AFTER the first non-vowel.
+ $position++;
+ }
+ return $position;
+ }
+
+ /**
+ * Checks whether the given string is contained in R1.
+ *
+ * @param string $string
+ * The string.
+ *
+ * @return bool
+ * TRUE if the string is in R1, FALSE otherwise.
+ */
+ protected function inR1($string) {
+ $r1 = substr($this->word, $this->r1);
+ return strpos($r1, $string) !== FALSE;
+ }
+
+ /**
+ * Checks whether the given string is contained in R2.
+ *
+ * @param string $string
+ * The string.
+ *
+ * @return bool
+ * TRUE if the string is in R2, FALSE otherwise.
+ */
+ protected function inR2($string) {
+ $r2 = substr($this->word, $this->r2);
+ return strpos($r2, $string) !== FALSE;
+ }
+
+ /**
+ * Determines the string length of the current word.
+ *
+ * @return int
+ * The string length of the current word.
+ */
+ protected function length() {
+ return strlen($this->word);
+ }
+
+ /**
+ * Checks whether the word ends with the given string.
+ *
+ * @param string $string
+ * The string.
+ *
+ * @return bool
+ * TRUE if the word ends with the given string, FALSE otherwise.
+ */
+ protected function hasEnding($string) {
+ $length = strlen($string);
+ if ($length > $this->length()) {
+ return FALSE;
+ }
+ return (substr_compare($this->word, $string, -1 * $length, $length) === 0);
+ }
+
+ /**
+ * Appends a given string to the current word.
+ *
+ * @param string $string
+ * The ending to append.
+ */
+ protected function addEnding($string) {
+ $this->word = $this->word . $string;
+ }
+
+ /**
+ * Removes a given string from the end of the current word.
+ *
+ * Does not check whether the ending is actually there.
+ *
+ * @param string $string
+ * The ending to remove.
+ */
+ protected function removeEnding($string) {
+ $this->word = substr($this->word, 0, -strlen($string));
+ }
+
+ /**
+ * Checks whether the given string contains a vowel.
+ *
+ * @param string $string
+ * The string to check.
+ *
+ * @return bool
+ * TRUE if the string contains a vowel, FALSE otherwise.
+ */
+ protected function containsVowel($string) {
+ $inc = 0;
+ $return = FALSE;
+ while ($inc < strlen($string)) {
+ if ($this->isVowel($inc, $string)) {
+ $return = TRUE;
+ break;
+ }
+ $inc++;
+ }
+ return $return;
+ }
+
+ /**
+ * Checks whether the given string is a valid -li prefix.
+ *
+ * @param string $string
+ * The string to check.
+ *
+ * @return bool
+ * TRUE if the given string is a valid -li prefix, FALSE otherwise.
+ */
+ protected function validLi($string) {
+ return in_array($string, array(
+ 'c',
+ 'd',
+ 'e',
+ 'g',
+ 'h',
+ 'k',
+ 'm',
+ 'n',
+ 'r',
+ 't',
+ ));
+ }
+
+}
diff --git a/includes/processor_stopwords.inc b/includes/processor_stopwords.inc
index 70866a2e..00918716 100644
--- a/includes/processor_stopwords.inc
+++ b/includes/processor_stopwords.inc
@@ -1,5 +1,10 @@
array(
'#type' => 'textfield',
- '#title' => t('Stopwords file URI'),
- '#title' => t('Enter the URI of your stopwords.txt file'),
+ '#title' => t('Stopwords file'),
'#description' => t('This must be a stream-type description like public://stopwords/stopwords.txt or http://example.com/stopwords.txt or private://stopwords.txt.'),
),
'stopwords' => array(
@@ -43,13 +47,8 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
public function configurationFormValidate(array $form, array &$values, array &$form_state) {
parent::configurationFormValidate($form, $values, $form_state);
- $stopwords = trim($values['stopwords']);
$uri = $values['file'];
- if (empty($stopwords) && empty($uri)) {
- $el = $form['file'];
- form_error($el, $el['#title'] . ': ' . t('At stopwords file or words are required.'));
- }
- if (!empty($uri) && !file_get_contents($uri)) {
+ if (!empty($uri) && !@file_get_contents($uri)) {
$el = $form['file'];
form_error($el, t('Stopwords file') . ': ' . t('The file %uri is not readable or does not exist.', array('%uri' => $uri)));
}
@@ -57,7 +56,7 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
public function process(&$value) {
$stopwords = $this->getStopWords();
- if (empty($stopwords) && !is_string($value)) {
+ if (empty($stopwords) || !is_string($value)) {
return;
}
$words = preg_split('/\s+/', $value);
@@ -87,7 +86,9 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
}
/**
- * @return
+ * Retrieves the processor's configured stopwords.
+ *
+ * @return array
* An array whose keys are the stopwords set in either the file or the text
* field.
*/
@@ -105,4 +106,4 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
$this->stopwords = array_flip(array_merge($file_words, $form_words));
return $this->stopwords;
}
-}
\ No newline at end of file
+}
diff --git a/includes/processor_tokenizer.inc b/includes/processor_tokenizer.inc
index f3972266..14834df0 100644
--- a/includes/processor_tokenizer.inc
+++ b/includes/processor_tokenizer.inc
@@ -1,5 +1,10 @@
t('Multiple terms'),
- 'description' => t('The query is interpreted as multiple keywords seperated by spaces. ' .
- 'Keywords containing spaces may be "quoted". Quoted keywords must still be seperated by spaces.'),
+ 'description' => t('The query is interpreted as multiple keywords separated by spaces. ' .
+ 'Keywords containing spaces may be "quoted". Quoted keywords must still be separated by spaces.'),
);
// @todo Add fourth mode for complicated expressions, e.g.: »"vanilla ice" OR (love NOT hate)«
return $modes;
@@ -461,7 +503,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
return array('#conjunction' => $this->options['conjunction'], $keys);
case 'terms':
- $ret = explode(' ', $keys);
+ $ret = preg_split('/\s+/u', $keys);
$quoted = FALSE;
$str = '';
foreach ($ret as $k => $v) {
@@ -503,9 +545,9 @@ class SearchApiQuery implements SearchApiQueryInterface {
/**
* {@inheritdoc}
*/
- public function createFilter($conjunction = 'AND') {
+ public function createFilter($conjunction = 'AND', $tags = array()) {
$filter_class = $this->options['filter class'];
- return new $filter_class($conjunction);
+ return new $filter_class($conjunction, $tags);
}
/**
@@ -558,6 +600,10 @@ class SearchApiQuery implements SearchApiQueryInterface {
'search_api_relevance' => array('type' => 'decimal'),
'search_api_id' => array('type' => 'integer'),
);
+ if ($this->getIndex()->server()->supportsFeature('search_api_random_sort')) {
+ $fields['search_api_random'] = array('type' => 'integer');
+ }
+
if (empty($fields[$field])) {
throw new SearchApiException(t('Trying to sort on unknown field @field.', array('@field' => $field)));
}
@@ -616,6 +662,9 @@ class SearchApiQuery implements SearchApiQueryInterface {
*
* @param array $languages
* The languages for which results should be returned.
+ *
+ * @throws SearchApiException
+ * If there was a logical error in the combination of filters and languages.
*/
protected function addLanguages(array $languages) {
if (array_search(LANGUAGE_NONE, $languages) === FALSE) {
@@ -689,6 +738,9 @@ class SearchApiQuery implements SearchApiQueryInterface {
public function postExecute(array &$results) {
// Postprocess results.
$this->index->postprocessSearchResults($results, $this);
+
+ // Let modules alter the results.
+ drupal_alter('search_api_results', $results, $this);
}
/**
@@ -776,6 +828,61 @@ class SearchApiQuery implements SearchApiQueryInterface {
}
}
+ /**
+ * Implements the magic __clone() method to clone the filter, too.
+ */
+ public function __clone() {
+ $this->filter = clone $this->filter;
+ }
+
+ /**
+ * Implements the magic __toString() method to simplify debugging.
+ */
+ public function __toString() {
+ $ret = 'Index: ' . $this->index->machine_name . "\n";
+ $ret .= 'Keys: ' . str_replace("\n", "\n ", var_export($this->orig_keys, TRUE)) . "\n";
+ if (isset($this->keys)) {
+ $ret .= 'Parsed keys: ' . str_replace("\n", "\n ", var_export($this->keys, TRUE)) . "\n";
+ $ret .= 'Searched fields: ' . (isset($this->fields) ? implode(', ', $this->fields) : '[ALL]') . "\n";
+ }
+ if ($filter = (string) $this->filter) {
+ $filter = str_replace("\n", "\n ", $filter);
+ $ret .= "Filters:\n $filter\n";
+ }
+ if ($this->sort) {
+ $sort = array();
+ foreach ($this->sort as $field => $order) {
+ $sort[] = "$field $order";
+ }
+ $ret .= 'Sorting: ' . implode(', ', $sort) . "\n";
+ }
+ $options = $this->sanitizeOptions($this->options);
+ $options = str_replace("\n", "\n ", var_export($options, TRUE));
+ $ret .= 'Options: ' . $options . "\n";
+ return $ret;
+ }
+
+ /**
+ * Sanitizes an array of options in a way that plays nice with var_export().
+ *
+ * @param array $options
+ * An array of options.
+ *
+ * @return array
+ * The sanitized options.
+ */
+ protected function sanitizeOptions(array $options) {
+ foreach ($options as $key => $value) {
+ if (is_object($value)) {
+ $options[$key] = 'object (' . get_class($value) . ')';
+ }
+ elseif (is_array($value)) {
+ $options[$key] = $this->sanitizeOptions($value);
+ }
+ }
+ return $options;
+ }
+
}
/**
@@ -790,9 +897,13 @@ interface SearchApiQueryFilterInterface {
* Constructs a new filter that uses the specified conjunction.
*
* @param string $conjunction
- * The conjunction to use for this filter - either 'AND' or 'OR'.
+ * (optional) The conjunction to use for this filter - either 'AND' or 'OR'.
+ * @param array $tags
+ * (optional) An arbitrary set of tags. Can be used to identify this filter
+ * down the line if necessary. This is primarily used by the facet system
+ * to support OR facet queries.
*/
- public function __construct($conjunction = 'AND');
+ public function __construct($conjunction = 'AND', array $tags = array());
/**
* Sets this filter's conjunction.
@@ -851,11 +962,33 @@ interface SearchApiQueryFilterInterface {
* Return all conditions and nested filters contained in this filter.
*
* @return array
- * An array containing this filter's subfilters. Each of these is either an
- * array (field, value, operator), or another SearchApiFilter object.
+ * An array containing this filter's subfilters. Each of these is either a
+ * condition, represented as a numerically indexed array with the arguments
+ * of a previous SearchApiQueryFilterInterface::condition() call (field,
+ * value, operator); or a nested filter, represented by a
+ * SearchApiQueryFilterInterface filter object.
*/
public function &getFilters();
+ /**
+ * Checks whether a certain tag was set on this filter.
+ *
+ * @param string $tag
+ * A tag to check for.
+ *
+ * @return bool
+ * TRUE if the tag was set for this filter, FALSE otherwise.
+ */
+ public function hasTag($tag);
+
+ /**
+ * Retrieves the tags set on this filter.
+ *
+ * @return array
+ * The tags associated with this filter, as both the array keys and values.
+ */
+ public function &getTags();
+
}
/**
@@ -883,9 +1016,10 @@ class SearchApiQueryFilter implements SearchApiQueryFilterInterface {
/**
* {@inheritdoc}
*/
- public function __construct($conjunction = 'AND') {
+ public function __construct($conjunction = 'AND', array $tags = array()) {
$this->setConjunction($conjunction);
$this->filters = array();
+ $this->tags = drupal_map_assoc($tags);
}
/**
@@ -926,4 +1060,53 @@ class SearchApiQueryFilter implements SearchApiQueryFilterInterface {
return $this->filters;
}
+ /**
+ * {@inheritdoc}
+ */
+ public function hasTag($tag) {
+ return isset($this->tags[$tag]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function &getTags() {
+ // Tags can sometimes be NULL for old serialized query filter objects.
+ if (!isset($this->tags)) {
+ $this->tags = array();
+ }
+ return $this->tags;
+ }
+
+ /**
+ * Implements the magic __clone() method to clone nested filters, too.
+ */
+ public function __clone() {
+ foreach ($this->filters as $i => $filter) {
+ if (is_object($filter)) {
+ $this->filters[$i] = clone $filter;
+ }
+ }
+ }
+
+ /**
+ * Implements the magic __toString() method to simplify debugging.
+ */
+ public function __toString() {
+ // Special case for a single, nested filter:
+ if (count($this->filters) == 1 && is_object($this->filters[0])) {
+ return (string) $this->filters[0];
+ }
+ $ret = array();
+ foreach ($this->filters as $filter) {
+ if (is_object($filter)) {
+ $ret[] = "[\n " . str_replace("\n", "\n ", (string) $filter) . "\n ]";
+ }
+ else {
+ $ret[] = "$filter[0] $filter[2] " . str_replace("\n", "\n ", var_export($filter[1], TRUE));
+ }
+ }
+ return $ret ? ' ' . implode("\n{$this->conjunction}\n ", $ret) : '';
+ }
+
}
diff --git a/includes/server_entity.inc b/includes/server_entity.inc
index 0436171f..6ab8bd79 100644
--- a/includes/server_entity.inc
+++ b/includes/server_entity.inc
@@ -1,5 +1,10 @@
ensureProxy();
return $this->proxy->configurationForm($form, $form_state);
}
+ /**
+ * Validation callback for the form returned by configurationForm().
+ *
+ * @see SearchApiServiceInterface::configurationFormValidate()
+ */
public function configurationFormValidate(array $form, array &$values, array &$form_state) {
$this->ensureProxy();
return $this->proxy->configurationFormValidate($form, $values, $form_state);
}
+ /**
+ * Submit callback for the form returned by configurationForm().
+ *
+ * @see SearchApiServiceInterface::configurationFormSubmit()
+ */
public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
$this->ensureProxy();
return $this->proxy->configurationFormSubmit($form, $values, $form_state);
}
+ /**
+ * Determines whether this service class supports a given feature.
+ *
+ * @see SearchApiServiceInterface::supportsFeature()
+ */
public function supportsFeature($feature) {
$this->ensureProxy();
return $this->proxy->supportsFeature($feature);
}
+ /**
+ * Displays this server's settings.
+ *
+ * @see SearchApiServiceInterface::viewSettings()
+ */
public function viewSettings() {
$this->ensureProxy();
return $this->proxy->viewSettings();
}
+ /**
+ * Reacts to the server's creation.
+ *
+ * @see SearchApiServiceInterface::postCreate()
+ */
public function postCreate() {
$this->ensureProxy();
return $this->proxy->postCreate();
}
+ /**
+ * Notifies this server that its fields are about to be updated.
+ *
+ * @see SearchApiServiceInterface::postUpdate()
+ */
public function postUpdate() {
$this->ensureProxy();
return $this->proxy->postUpdate();
}
+ /**
+ * Notifies this server that it is about to be deleted from the database.
+ *
+ * @see SearchApiServiceInterface::preDelete()
+ */
public function preDelete() {
$this->ensureProxy();
return $this->proxy->preDelete();
}
+ /**
+ * Adds a new index to this server.
+ *
+ * If an exception in the service class implementation of this method occurs,
+ * it will be caught and the operation saved as an pending server task.
+ *
+ * @see SearchApiServiceInterface::addIndex()
+ * @see search_api_server_tasks_add()
+ */
public function addIndex(SearchApiIndex $index) {
$this->ensureProxy();
- return $this->proxy->addIndex($index);
+ try {
+ $this->proxy->addIndex($index);
+ }
+ catch (SearchApiException $e) {
+ $vars = array(
+ '%server' => $this->name,
+ '%index' => $index->name,
+ );
+ watchdog_exception('search_api', $e, '%type while adding index %index to server %server: !message in %function (line %line of %file).', $vars);
+ search_api_server_tasks_add($this, __FUNCTION__, $index);
+ }
}
+ /**
+ * Notifies the server that the field settings for the index have changed.
+ *
+ * If the service class implementation of the method returns TRUE, this will
+ * automatically take care of marking the items on the index for re-indexing.
+ *
+ * If an exception in the service class implementation of this method occurs,
+ * it will be caught and the operation saved as an pending server task.
+ *
+ * @see SearchApiServiceInterface::fieldsUpdated()
+ * @see search_api_server_tasks_add()
+ */
public function fieldsUpdated(SearchApiIndex $index) {
$this->ensureProxy();
- return $this->proxy->fieldsUpdated($index);
+ try {
+ if ($this->proxy->fieldsUpdated($index)) {
+ _search_api_index_reindex($index);
+ return TRUE;
+ }
+ }
+ catch (SearchApiException $e) {
+ $vars = array(
+ '%server' => $this->name,
+ '%index' => $index->name,
+ );
+ watchdog_exception('search_api', $e, '%type while updating the fields of index %index on server %server: !message in %function (line %line of %file).', $vars);
+ search_api_server_tasks_add($this, __FUNCTION__, $index, isset($index->original) ? $index->original : NULL);
+ }
+ return FALSE;
}
+ /**
+ * Removes an index from this server.
+ *
+ * If an exception in the service class implementation of this method occurs,
+ * it will be caught and the operation saved as an pending server task.
+ *
+ * @see SearchApiServiceInterface::removeIndex()
+ * @see search_api_server_tasks_add()
+ */
public function removeIndex($index) {
+ // When removing an index from a server, it doesn't make any sense anymore to
+ // delete items from it, or react to other changes.
+ search_api_server_tasks_delete(NULL, $this, $index);
+
$this->ensureProxy();
- return $this->proxy->removeIndex($index);
+ try {
+ $this->proxy->removeIndex($index);
+ }
+ catch (SearchApiException $e) {
+ $vars = array(
+ '%server' => $this->name,
+ '%index' => is_object($index) ? $index->name : $index,
+ );
+ watchdog_exception('search_api', $e, '%type while removing index %index from server %server: !message in %function (line %line of %file).', $vars);
+ search_api_server_tasks_add($this, __FUNCTION__, $index);
+ }
}
+ /**
+ * Indexes the specified items.
+ *
+ * @see SearchApiServiceInterface::indexItems()
+ */
public function indexItems(SearchApiIndex $index, array $items) {
$this->ensureProxy();
return $this->proxy->indexItems($index, $items);
}
+ /**
+ * Deletes indexed items from this server.
+ *
+ * If an exception in the service class implementation of this method occurs,
+ * it will be caught and the operation saved as an pending server task.
+ *
+ * @see SearchApiServiceInterface::deleteItems()
+ * @see search_api_server_tasks_add()
+ */
public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
$this->ensureProxy();
- return $this->proxy->deleteItems($ids, $index);
+ try {
+ $this->proxy->deleteItems($ids, $index);
+ }
+ catch (SearchApiException $e) {
+ $vars = array(
+ '%server' => $this->name,
+ );
+ watchdog_exception('search_api', $e, '%type while deleting items from server %server: !message in %function (line %line of %file).', $vars);
+ search_api_server_tasks_add($this, __FUNCTION__, $index, $ids);
+ }
}
+ /**
+ * Creates a query object for searching on an index lying on this server.
+ *
+ * @see SearchApiServiceInterface::query()
+ */
public function query(SearchApiIndex $index, $options = array()) {
$this->ensureProxy();
return $this->proxy->query($index, $options);
}
+ /**
+ * Executes a search on the server represented by this object.
+ *
+ * @see SearchApiServiceInterface::search()
+ */
public function search(SearchApiQueryInterface $query) {
$this->ensureProxy();
return $this->proxy->search($query);
}
+ /**
+ * Retrieves additional information for the server, if available.
+ *
+ * Retrieving such information is only supported if the service class supports
+ * the "search_api_service_extra" feature.
+ *
+ * @return array
+ * An array containing additional, service class-specific information about
+ * the server.
+ *
+ * @see SearchApiAbstractService::getExtraInformation()
+ */
+ public function getExtraInformation() {
+ if ($this->proxy->supportsFeature('search_api_service_extra')) {
+ return $this->proxy->getExtraInformation();
+ }
+ return array();
+ }
+
}
diff --git a/includes/service.inc b/includes/service.inc
index c6edc957..046e5d39 100644
--- a/includes/service.inc
+++ b/includes/service.inc
@@ -1,10 +1,20 @@
- * listing all relevant settings is preferred.
+ * Displays this server's settings.
+ *
+ * Output can be HTML or a render array, a
listing all relevant settings
+ * is preferred.
*/
public function viewSettings();
/**
+ * Reacts to the server's creation.
+ *
* Called once, when the server is first created. Allows it to set up its
* necessary infrastructure.
*/
public function postCreate();
/**
- * Notifies this server that its fields are about to be updated. The server's
- * $original property can be used to inspect the old property values.
+ * Notifies this server that its fields are about to be updated.
*
- * @return
+ * The server's $original property can be used to inspect the old property
+ * values.
+ *
+ * @return bool
* TRUE, if the update requires reindexing of all content on the server.
*/
public function postUpdate();
/**
- * Notifies this server that it is about to be deleted from the database and
- * should therefore clean up, if appropriate.
+ * Notifies this server that it is about to be deleted from the database.
+ *
+ * This should execute any necessary cleanup operations.
*
* Note that you shouldn't call the server's save() method, or any
* methods that might do that, from inside of this method as the server isn't
@@ -112,18 +136,21 @@ interface SearchApiServiceInterface {
public function preDelete();
/**
- * Add a new index to this server.
+ * Adds a new index to this server.
*
* If the index was already added to the server, the object should treat this
* as if removeIndex() and then addIndex() were called.
*
* @param SearchApiIndex $index
* The index to add.
+ *
+ * @throws SearchApiException
+ * If an error occurred while adding the index.
*/
public function addIndex(SearchApiIndex $index);
/**
- * Notify the server that the field settings for the index have changed.
+ * Notifies the server that the field settings for the index have changed.
*
* If any user action is necessary as a result of this, the method should
* use drupal_set_message() to notify the user.
@@ -134,11 +161,14 @@ interface SearchApiServiceInterface {
* @return bool
* TRUE, if this change affected the server in any way that forces it to
* re-index the content. FALSE otherwise.
+ *
+ * @throws SearchApiException
+ * If an error occurred while reacting to the change of fields.
*/
public function fieldsUpdated(SearchApiIndex $index);
/**
- * Remove an index from this server.
+ * Removes an index from this server.
*
* This might mean that the index has been deleted, or reassigned to a
* different server. If you need to distinguish between these cases, inspect
@@ -152,11 +182,14 @@ interface SearchApiServiceInterface {
* @param $index
* Either an object representing the index to remove, or its machine name
* (if the index was completely deleted).
+ *
+ * @throws SearchApiException
+ * If an error occurred while removing the index.
*/
public function removeIndex($index);
/**
- * Index the specified items.
+ * Indexes the specified items.
*
* @param SearchApiIndex $index
* The search index for which items should be indexed.
@@ -187,7 +220,7 @@ interface SearchApiServiceInterface {
public function indexItems(SearchApiIndex $index, array $items);
/**
- * Delete items from an index on this server.
+ * Deletes indexed items from this server.
*
* Might be either used to delete some items (given by their ids) from a
* specified index, or all items from that index, or all items from all
@@ -200,11 +233,14 @@ interface SearchApiServiceInterface {
* @param SearchApiIndex $index
* The index from which items should be deleted, or NULL if all indexes on
* this server should be cleared (then, $ids has to be 'all').
+ *
+ * @throws SearchApiException
+ * If an error occurred while trying to delete the items.
*/
public function deleteItems($ids = 'all', SearchApiIndex $index = NULL);
/**
- * Create a query object for searching on an index lying on this server.
+ * Creates a query object for searching on an index lying on this server.
*
* @param SearchApiIndex $index
* The index to search on.
@@ -334,6 +370,30 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
return $output ? "
\n$output
" : '';
}
+ /**
+ * Returns additional, service-specific information about this server.
+ *
+ * If a service class implements this method and supports the
+ * "search_api_service_extra" option, this method will be used to add extra
+ * information to the server's "View" tab.
+ *
+ * In the default theme implementation this data will be output in a table
+ * with two columns along with other, generic information about the server.
+ *
+ * @return array
+ * An array of additional server information, with each piece of information
+ * being an associative array with the following keys:
+ * - label: The human-readable label for this data.
+ * - info: The information, as HTML.
+ * - status: (optional) The status associated with this information. One of
+ * "info", "ok", "warning" or "error". Defaults to "info".
+ *
+ * @see supportsFeature()
+ */
+ public function getExtraInformation() {
+ return array();
+ }
+
/**
* Implements SearchApiServiceInterface::__construct().
*
@@ -360,7 +420,15 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
public function preDelete() {
$indexes = search_api_index_load_multiple(FALSE, array('server' => $this->server->machine_name));
foreach ($indexes as $index) {
- $this->removeIndex($index);
+ // removeIndex() might throw exceptions, but this method mustn't.
+ try {
+ $this->removeIndex($index);
+ }
+ catch (SearchApiException $e) {
+ $variables['%index'] = $index->name;
+ $variables['%server'] = $this->server->name;
+ watchdog_exception('search_api', $e, '%type while trying to remove index %index from deleted server %server: !message in %function (line %line of %file).', $variables);
+ }
}
}
diff --git a/search_api.admin.css b/search_api.admin.css
index 0c49e057..b82798df 100644
--- a/search_api.admin.css
+++ b/search_api.admin.css
@@ -1,44 +1,229 @@
+/**
+ * @file
+ * Styles for Search API admin pages.
+ */
-td.search-api-status {
+/*
+ * OVERVIEW
+ */
+
+.search-api-overview td.search-api-status {
+ text-align: center;
+}
+
+.search-api-overview td {
+ vertical-align: top;
+}
+
+/*
+ * VIEW SERVER
+ */
+
+.search-api-server-summary ul.inline {
+ margin: 0;
+}
+
+.search-api-server-summary ul.inline li {
+ padding-left: 0;
+}
+
+/*
+ * VIEW INDEX
+ */
+.search-api-limit,
+.search-api-batch-size {
text-align: center;
}
-div.search-api-edit-menu {
+.search-api-index-status .progress .filled {
+ background: #0074BD none;
+}
+
+/*
+ * DROPBUTTONS
+ *
+ * (Largely copied from D8's dropbutton.css.)
+ */
+
+/**
+ * When a dropbutton has only one option, it is simply a button.
+ */
+.dropbutton-wrapper,
+.dropbutton-wrapper div {
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+.js .dropbutton-wrapper {
+ display: block;
+ min-height: 2em;
+ position: relative;
+}
+
+.js .dropbutton-wrapper,
+.js .dropbutton-widget {
+ max-width: 100%;
+}
+
+@media screen and (max-width: 600px) {
+ .js .dropbutton-wrapper {
+ width: 100%;
+ }
+}
+
+.js .dropbutton-widget {
position: absolute;
- background-color: white;
- color: black;
- z-index: 999;
- border: 1px solid black;
- -moz-border-radius: 4px;
- -webkit-border-radius: 4px;
- -khtml-border-radius: 4px;
- border-radius: 4px;
}
-div.search-api-edit-menu ul {
- margin: 0 0.5em;
+/* UL styles are over-scoped in core, so this selector needs weight parity. */
+.js .dropbutton-widget .dropbutton {
+ list-style-image: none;
+ list-style-type: none;
+ margin: 0;
+ overflow: hidden;
padding: 0;
}
-div.search-api-edit-menu ul li {
- padding: 0;
- list-style-type: none;
+.js .dropbutton li,
+.js .dropbutton a {
display: block;
}
-div.search-api-edit-menu.collapsed {
+/**
+ * The dropbutton styling.
+ *
+ * A dropbutton is a widget that displays a list of action links as a button
+ * with a primary action. Secondary actions are hidden behind a click on a
+ * twisty arrow.
+ *
+ * The arrow is created using border on a zero-width, zero-height span.
+ * The arrow inherits the link color, but can be overridden with border colors.
+ */
+.js .dropbutton-multiple .dropbutton-widget {
+ padding-right: 2em; /* LTR */
+}
+
+.js[dir="rtl"] .dropbutton-multiple .dropbutton-widget {
+ padding-left: 2em;
+ padding-right: 0;
+}
+
+.dropbutton-multiple.open,
+.dropbutton-multiple.open .dropbutton-widget {
+ max-width: none;
+}
+
+.dropbutton-multiple.open {
+ z-index: 100;
+}
+
+.dropbutton-multiple .dropbutton .secondary-action {
display: none;
}
-.search-api-alter-add-aggregation-fields,
-.search-api-checkboxes-list {
- max-height: 12em;
- overflow: auto;
+.dropbutton-multiple.open .dropbutton .secondary-action {
+ display: block;
+}
+
+.dropbutton-toggle {
+ bottom: 0;
+ display: block;
+ position: absolute;
+ right: 0; /* LTR */
+ text-indent: 110%;
+ top: 0;
+ white-space: nowrap;
+ width: 2em;
+}
+
+[dir="rtl"] .dropbutton-toggle {
+ left: 0;
+ right: auto;
+}
+
+.dropbutton-toggle button {
+ background: none;
+ border: 0;
+ cursor: pointer;
+ display: block;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ width: 100%;
}
-/* Workaround for http://drupal.org/node/1015798 */
-.vertical-tabs fieldset div.fieldset-wrapper fieldset legend {
+.dropbutton-arrow {
+ border-bottom-color: transparent;
+ border-left-color: transparent;
+ border-right-color: transparent;
+ border-style: solid;
+ border-width: 0.3333em 0.3333em 0;
display: block;
- margin-bottom: 2em;
+ height: 0;
+ line-height: 0;
+ position: absolute;
+ right: 40%; /* 0.6667em; */
+ /* LTR */
+ top: 50%;
+ margin-top: -0.1666em;
+ width: 0;
+ overflow: hidden;
+}
+
+[dir="rtl"] .dropbutton-arrow {
+ left: 0.6667em;
+ right: auto;
+}
+
+.dropbutton-multiple.open .dropbutton-arrow {
+ border-bottom: 0.3333em solid;
+ border-top-color: transparent;
+ top: 0.6667em;
}
+.js .dropbutton-widget {
+ background-color: white;
+ border: 1px solid #CCC;
+}
+
+.js .dropbutton-widget:hover {
+ border-color: #B8B8B8;
+}
+
+.dropbutton .dropbutton-action > * {
+ padding: 0.1em 0.5em;
+ white-space: nowrap;
+}
+
+.dropbutton .secondary-action {
+ border-top: 1px solid #E8E8E8;
+}
+
+.dropbutton-multiple .dropbutton {
+ border-right: 1px solid #E8E8E8; /* LTR */
+}
+
+[dir="rtl"] .dropbutton-multiple .dropbutton {
+ border-left: 1px solid #E8E8E8;
+ border-right: 0 none;
+}
+
+.dropbutton-multiple .dropbutton .dropbutton-action > * {
+ margin-right: 0.25em; /* LTR */
+}
+
+[dir="rtl"] .dropbutton-multiple .dropbutton .dropbutton-action > * {
+ margin-left: 0.25em;
+ margin-right: 0;
+}
+
+/*
+ * MISC
+ */
+
+.search-api-alter-add-aggregation-fields,
+.search-api-checkboxes-list {
+ max-height: 12em;
+ overflow: auto;
+}
diff --git a/search_api.admin.inc b/search_api.admin.inc
index 48e0aeb6..c1b1930c 100644
--- a/search_api.admin.inc
+++ b/search_api.admin.inc
@@ -1,7 +1,14 @@
server][$index->machine_name] = $index;
@@ -46,15 +54,10 @@ function search_api_admin_overview() {
'#title' => t('disabled'),
);
$t_disabled['class'] = array('search-api-status');
- $t_enable = t('enable');
- $t_disable = t('disable');
- $t_edit = t('edit');
+ $t_enable = t('Enable');
$pre_server = 'admin/config/search/search_api/server';
$pre_index = 'admin/config/search/search_api/index';
$enable = '/enable';
- $disable = '/disable';
- $edit = '/edit';
- $edit_link_options['attributes']['class'][] = 'search-api-edit-menu-toggle';
foreach ($servers as $server) {
$url = $pre_server . '/' . $server->machine_name;
$row = array();
@@ -64,10 +67,21 @@ function search_api_admin_overview() {
}
$row[] = $t_server;
$row[] = l($server->name, $url);
- $row[] = $server->enabled ? l($t_disable, $url . $disable) : l($t_enable, $url . $enable, array('query' => array('token' => drupal_get_token($server->machine_name))));
- $row[] = l($t_edit, $url . $edit);
- $row[] = _search_api_admin_delete_link($server);
- $rows[] = $row;
+ $links = array();
+ // The "Enable" function has no menu link, since a token is required. We add
+ // it as the first link, since it will most likely be the most useful link
+ // for a disabled server. (Same for indexes below.)
+ if (!$server->enabled) {
+ $links[] = array(
+ 'title' => $t_enable,
+ 'href' => $url . $enable,
+ 'query' => array('token' => drupal_get_token($server->machine_name))
+ );
+ }
+ $links = array_merge($links, menu_contextual_links('search-api-server', $pre_server, array($server->machine_name)));
+ $row[] = theme('search_api_dropbutton', array('links' => $links));
+ $rows[] = _search_api_deep_copy($row);
+
if (!empty($indexes[$server->machine_name])) {
foreach ($indexes[$server->machine_name] as $index) {
$url = $pre_index . '/' . $index->machine_name;
@@ -76,18 +90,20 @@ function search_api_admin_overview() {
if ($show_config_status) {
$row[] = theme('entity_status', array('status' => $index->status));
}
- $row[] = '';
+ $row[] = ' ';
$row[] = $t_index;
$row[] = l($index->name, $url);
- $row[] = $index->enabled
- ? l($t_disable, $url . $disable)
- : ($server->enabled ? l($t_enable, $url . $enable, array('query' => array('token' => drupal_get_token($index->machine_name)))) : '');
- $row[] = l($t_edit, $url . $edit, $edit_link_options) .
- '
';
+ // If $info is not set, there are no service classes. Display an error message
+ // telling the user how to change that and return an empty form.
+ if (!isset($info)) {
+ drupal_set_message(t('There are no service classes available for the Search API. Please install a module that provides a service class to proceed.', array('@url' => url('https://www.drupal.org/node/1254698'))), 'error');
+ return array();
+ }
+
$form['submit'] = array(
'#type' => 'submit',
'#value' => t('Create server'),
@@ -240,15 +268,16 @@ function search_api_admin_add_server(array $form, array &$form_state) {
}
/**
- * AJAX callback that just returns the "options" array of the already built form
- * array.
+ * Form AJAX handler for search_api_admin_add_server().
+ *
+ * Just returns the "options" array of the already built form array.
*/
function search_api_admin_add_server_ajax_callback(array $form, array &$form_state) {
return $form['options'];
}
/**
- * Form validation callback for adding a server.
+ * Form validation handler for adding a server.
*
* Validates the machine name and calls the service class' validation handler.
*/
@@ -274,12 +303,13 @@ function search_api_admin_add_server_validate(array $form, array &$form_state) {
return;
}
$form_state['values']['options']['service'] = $service;
- $values = isset($form_state['values']['options']['form']) ? $form_state['values']['options']['form'] : array();
- $service->configurationFormValidate($form['options']['form'], $values, $form_state);
+ if (!empty($form_state['values']['options']['form'])) {
+ $service->configurationFormValidate($form['options']['form'], $form_state['values']['options']['form'], $form_state);
+ }
}
/**
- * Form submit callback for adding a server.
+ * Form submission handler for adding a server.
*/
function search_api_admin_add_server_submit(array $form, array &$form_state) {
form_state_values_clean($form_state);
@@ -308,19 +338,15 @@ function search_api_admin_add_server_submit(array $form, array &$form_state) {
}
/**
- * Title callback for viewing or editing a server or index.
- */
-function search_api_admin_item_title($object) {
- return $object->name;
-}
-
-/**
- * Displays a server's details.
+ * Page callback: Displays information about a server.
*
* @param SearchApiServer $server
* The server to display.
- * @param $action
- * One of 'enable', 'disable', 'delete'; or NULL if the server is only viewed.
+ * @param string|null $action
+ * (optional) An action to execute for the server. One of 'enable', 'disable'
+ * or 'clear'.
+ *
+ * @see search_api_menu()
*/
function search_api_admin_server_view(SearchApiServer $server, $action = NULL) {
if (!empty($action)) {
@@ -340,7 +366,7 @@ function search_api_admin_server_view(SearchApiServer $server, $action = NULL) {
}
else {
$ret = drupal_get_form('search_api_admin_confirm', 'server', $action, $server);
- if ($ret) {
+ if (!empty($ret['actions'])) {
return $ret;
}
}
@@ -349,22 +375,41 @@ function search_api_admin_server_view(SearchApiServer $server, $action = NULL) {
drupal_set_title(search_api_admin_item_title($server));
$class = search_api_get_service_info($server->class);
$options = $server->viewSettings();
- return array(
+ $indexes = array();
+ foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+ if (!$indexes) {
+ $indexes['#theme'] = 'links';
+ $indexes['#attributes']['class'] = array('inline');
+ }
+ $indexes['#links'][] = array(
+ 'title' => $index->name,
+ 'href' => 'admin/config/search/search_api/index/' . $index->machine_name,
+ );
+ }
+ $render['view'] = array(
'#theme' => 'search_api_server',
'#id' => $server->id,
'#name' => $server->name,
'#machine_name' => $server->machine_name,
'#description' => $server->description,
'#enabled' => $server->enabled,
+ '#class_id' => $server->class,
'#class_name' => $class['name'],
'#class_description' => $class['description'],
+ '#indexes' => $indexes,
'#options' => $options,
'#status' => $server->status,
+ '#extra' => $server->getExtraInformation(),
);
+ $render['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
+ if ($server->enabled) {
+ $render['form'] = drupal_get_form('search_api_server_status_form', $server);
+ }
+ return $render;
}
/**
- * Theme function for displaying a server.
+ * Returns HTML for displaying a server.
*
* @param array $variables
* An associative array containing:
@@ -373,69 +418,154 @@ function search_api_admin_server_view(SearchApiServer $server, $action = NULL) {
* - machine_name: The server's machine name.
* - description: The server's description.
* - enabled: Boolean indicating whether the server is enabled.
+ * - class_id: The used service class' ID.
* - class_name: The used service class' display name.
* - class_description: The used service class' description.
+ * - indexes: A list of indexes associated with this server, either as an HTML
+ * string or a render array.
* - options: An HTML string or render array containing information about the
* server's service-specific settings.
* - status: The entity configuration status (in database, in code, etc.).
+ * - extra: An array of additional server information in the format specified
+ * by SearchApiAbstractService::getExtraInformation().
+ *
+ * @return string
+ * HTML for displaying a server.
+ *
+ * @ingroup themeable
*/
function theme_search_api_server(array $variables) {
- extract($variables);
+ $machine_name = $variables['machine_name'];
+ $description = $variables['description'];
+ $enabled = $variables['enabled'];
+ $class_id = $variables['class_id'];
+ $class_name = $variables['class_name'];
+ $indexes = $variables['indexes'];
+ $options = $variables['options'];
+ $status = $variables['status'];
+ $extra = $variables['extra'];
+
+ // First, output the index description if there is one set.
$output = '';
- $output .= '
' . check_plain($name) . '
' . "\n";
+ if ($description) {
+ $output .= '
' . nl2br(check_plain($description)) . '
';
+ }
- $output .= '
' . "\n";
+ // Then, display a table summarizing the index's status.
+ $rows = array();
+ // Create a row template with references so we don't have to deal with the
+ // complicated structure for each individual row.
+ $row = array(
+ 'data' => array(
+ array('header' => TRUE),
+ '',
+ ),
+ 'class' => array(''),
+ );
+ $label = & $row['data'][0]['data'];
+ $info = & $row['data'][1];
+ $class = & $row['class'][0];
- $output .= '
';
+ $theme['rows'] = $rows;
+ $theme['attributes']['class'][] = 'search-api-summary';
+ $theme['attributes']['class'][] = 'search-api-server-summary';
+ $theme['attributes']['class'][] = 'system-status-report';
+ $output .= theme('table', $theme);
return $output;
}
/**
- * Edit a server's settings.
+ * Form constructor for completely clearing a server.
+ *
+ * @param SearchApiServer $server
+ * The server for which the form is displayed.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_server_status_form_submit()
+ */
+function search_api_server_status_form(array $form, array &$form_state, SearchApiServer $server) {
+ $form_state['server'] = $server;
+
+ $form['clear'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete all indexed data on this server'),
+ );
+
+ return $form;
+}
+
+/**
+* Form submission handler for search_api_server_status_form().
+*/
+function search_api_server_status_form_submit(array $form, array &$form_state) {
+ $server_id = $form_state['server']->machine_name;
+ $form_state['redirect'] = "admin/config/search/search_api/server/$server_id/clear";
+}
+
+/**
+ * Form constructor for editing a server's settings.
*
* @param SearchApiServer $server
* The server to edit.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_server_edit_validate()
+ * @see search_api_admin_server_edit_submit()
*/
function search_api_admin_server_edit(array $form, array &$form_state, SearchApiServer $server) {
$form_state['server'] = $server;
@@ -473,23 +603,36 @@ function search_api_admin_server_edit(array $form, array &$form_state, SearchApi
$form['options']['#title'] = $class['name'];
$form['options']['#description'] = $class['description'];
- $form['submit'] = array(
+ $form['actions']['#type'] = 'actions';
+ $form['actions']['submit'] = array(
'#type' => 'submit',
'#value' => t('Save settings'),
);
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#submit' => array('search_api_admin_form_delete_submit'),
+ '#limit_validation_errors' => array(),
+ );
return $form;
}
/**
- * Validation function for search_api_admin_server_edit.
+ * Form validation handler for search_api_admin_server_edit().
+ *
+ * @see search_api_admin_server_edit_submit()
*/
function search_api_admin_server_edit_validate(array $form, array &$form_state) {
- $form_state['server']->configurationFormValidate($form['options']['form'], $form_state['values']['options']['form'], $form_state);
+ if (!empty($form['options']['form']) && !empty($form_state['values']['options']['form'])) {
+ $form_state['server']->configurationFormValidate($form['options']['form'], $form_state['values']['options']['form'], $form_state);
+ }
}
/**
- * Submit function for search_api_admin_server_edit.
+ * Form submission handler for search_api_admin_server_edit().
+ *
+ * @see search_api_admin_server_edit_validate()
*/
function search_api_admin_server_edit_submit(array $form, array &$form_state) {
form_state_values_clean($form_state);
@@ -507,89 +650,154 @@ function search_api_admin_server_edit_submit(array $form, array &$form_state) {
}
/**
- * Form callback showing a form for adding an index.
+ * Form submission handler for search_api_admin_server_edit().
+ *
+ * Handles the 'Delete' button on the server and index edit forms.
+ *
+ * @see search_api_admin_server_edit()
+ * @see search_api_admin_index_edit()
+ */
+function search_api_admin_form_delete_submit($form, &$form_state) {
+ $destination = array();
+ if (isset($_GET['destination'])) {
+ $destination = drupal_get_destination();
+ unset($_GET['destination']);
+ }
+ if (isset($form_state['server'])) {
+ $server = $form_state['server'];
+ $form_state['redirect'] = array('admin/config/search/search_api/server/' . $server->machine_name . '/delete', array('query' => $destination));
+ }
+ elseif (isset($form_state['index'])) {
+ $index = $form_state['index'];
+ $form_state['redirect'] = array('admin/config/search/search_api/index/' . $index->machine_name . '/delete', array('query' => $destination));
+ }
+}
+
+/**
+ * Form constructor for adding an index.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_add_index_ajax_callback()
+ * @see search_api_admin_add_index_validate()
+ * @see search_api_admin_add_index_submit()
*/
function search_api_admin_add_index(array $form, array &$form_state) {
drupal_set_title(t('Add index'));
+ $old_type = empty($form_state['values']['item_type']) ? '' : $form_state['values']['item_type'];
+
$form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
$form['#tree'] = TRUE;
- $form['name'] = array(
- '#type' => 'textfield',
- '#title' => t('Index name'),
- '#maxlength' => 50,
- '#required' => TRUE,
- );
- $form['machine_name'] = array(
- '#type' => 'machine_name',
- '#maxlength' => 50,
- '#machine_name' => array(
- 'exists' => 'search_api_index_load',
- ),
- );
+ if (empty($form_state['step_one'])) {
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Index name'),
+ '#maxlength' => 50,
+ '#required' => TRUE,
+ );
- $form['item_type'] = array(
- '#type' => 'select',
- '#title' => t('Item type'),
- '#description' => t('Select the type of items that will be indexed in this index. ' .
- 'This setting cannot be changed afterwards.'),
- '#options' => array(),
- '#required' => TRUE,
- );
- foreach (search_api_get_item_type_info() as $type => $info) {
- $form['item_type']['#options'][$type] = $info['name'];
- }
- $form['enabled'] = array(
- '#type' => 'checkbox',
- '#title' => t('Enabled'),
- '#description' => t('This will only take effect if the selected server is also enabled.'),
- '#default_value' => TRUE,
- );
- $form['description'] = array(
- '#type' => 'textarea',
- '#title' => t('Index description'),
- );
- $form['server'] = array(
- '#type' => 'select',
- '#title' => t('Server'),
- '#description' => t('Select the server this index should reside on.'),
- '#default_value' => '',
- '#options' => array('' => t('< No server >'))
- );
- $servers = search_api_server_load_multiple(FALSE);
- // List enabled servers first.
- foreach ($servers as $server) {
- if ($server->enabled) {
- $form['server']['#options'][$server->machine_name] = $server->name;
+ $form['machine_name'] = array(
+ '#type' => 'machine_name',
+ '#maxlength' => 50,
+ '#machine_name' => array(
+ 'exists' => 'search_api_index_load',
+ ),
+ );
+
+ $form['item_type'] = array(
+ '#type' => 'select',
+ '#title' => t('Item type'),
+ '#description' => t('Select the type of items that will be indexed in this index. ' .
+ 'This setting cannot be changed afterwards.'),
+ '#options' => array(),
+ '#required' => TRUE,
+ '#ajax' => array(
+ 'callback' => 'search_api_admin_add_index_ajax_callback',
+ 'wrapper' => 'search-api-datasource-options',
+ ),
+ );
+ $form['datasource'] = array();
+ foreach (search_api_get_item_type_info() as $type => $info) {
+ $form['item_type']['#options'][$type] = $info['name'];
+ }
+ $form['enabled'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enabled'),
+ '#description' => t('This will only take effect if you also select a server for the index.'),
+ '#default_value' => TRUE,
+ );
+ $form['description'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Index description'),
+ );
+ $form['server'] = array(
+ '#type' => 'select',
+ '#title' => t('Server'),
+ '#description' => t('Select the server this index should reside on.'),
+ '#default_value' => '',
+ '#options' => array('' => t('< No server >'))
+ );
+ $servers = search_api_server_load_multiple(FALSE, array('enabled' => 1));
+ // List enabled servers first.
+ foreach ($servers as $server) {
+ if ($server->enabled) {
+ $form['server']['#options'][$server->machine_name] = $server->name;
+ }
+ }
+ foreach ($servers as $server) {
+ if (!$server->enabled) {
+ $form['server']['#options'][$server->machine_name] = t('@server_name (disabled)', array('@server_name' => $server->name));
+ }
}
+ $form['read_only'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Read only'),
+ '#description' => t('Do not write to this index or track the status of items in this index.'),
+ '#default_value' => FALSE,
+ );
+ $form['options']['index_directly'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Index items immediately'),
+ '#description' => t('Immediately index new or updated items instead of waiting for the next cron run. ' .
+ 'This might have serious performance drawbacks and is generally not advised for larger sites.'),
+ '#default_value' => FALSE,
+ );
+ $form['options']['cron_limit'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Cron batch size'),
+ '#description' => t('Set how many items will be indexed at once when indexing items during a cron run. ' .
+ '"0" means that no items will be indexed by cron for this index, "-1" means that cron should index all items at once.'),
+ '#default_value' => SEARCH_API_DEFAULT_CRON_LIMIT,
+ '#size' => 4,
+ '#attributes' => array('class' => array('search-api-cron-limit')),
+ '#element_validate' => array('element_validate_integer'),
+ );
}
- foreach ($servers as $server) {
- if (!$server->enabled) {
- $form['server']['#options'][$server->machine_name] = t('@server_name (disabled)', array('@server_name' => $server->name));
+ elseif (!$old_type) {
+ $old_type = $form_state['step_one']['item_type'];
+ }
+
+ if ($old_type) {
+ $datasource = search_api_get_datasource_controller($old_type);
+ $datasource_form = array();
+ $datasource_form = $datasource->configurationForm($datasource_form, $form_state);
+ if ($datasource_form) {
+ $form['datasource'] = $datasource_form;
+ $form['datasource']['#parents'] = array('options', 'datasource');
}
}
- $form['read_only'] = array(
- '#type' => 'checkbox',
- '#title' => t('Read only'),
- '#description' => t('Do not write to this index or track the status of items in this index.'),
- '#default_value' => FALSE,
+ $form['datasource']['#prefix'] = '
';
+ $form['datasource']['#suffix'] = '
';
+
+ $form['old_type'] = array(
+ '#type' => 'value',
+ '#value' => $old_type,
);
- $form['options']['index_directly'] = array(
- '#type' => 'checkbox',
- '#title' => t('Index items immediately'),
- '#description' => t('Immediately index new or updated items instead of waiting for the next cron run. ' .
- 'This might have serious performance drawbacks and is generally not advised for larger sites.'),
- '#default_value' => FALSE,
- );
- $form['options']['cron_limit'] = array(
- '#type' => 'textfield',
- '#title' => t('Cron batch size'),
- '#description' => t('Set how many items will be indexed at once when indexing items during a cron run. ' .
- '"0" means that no items will be indexed by cron for this index, "-1" means that cron should index all items at once.'),
- '#default_value' => SEARCH_API_DEFAULT_CRON_LIMIT,
- '#size' => 4,
- '#attributes' => array('class' => array('search-api-cron-limit')),
+ $form['datasource_config'] = array(
+ '#type' => 'value',
+ '#value' => !empty($datasource_form),
);
$form['submit'] = array(
@@ -601,31 +809,70 @@ function search_api_admin_add_index(array $form, array &$form_state) {
}
/**
- * Validation callback for search_api_admin_add_index.
+ * AJAX submit callback for search_api_admin_add_index().
+ *
+ * Used for displaying the matching datasource configuration form for the
+ * selected item type.
+ */
+function search_api_admin_add_index_ajax_callback(array $form, array &$form_state) {
+ return $form['datasource'];
+}
+
+/**
+ * Form validation handler for search_api_admin_add_index().
+ *
+ * @see search_api_admin_add_index_submit()
*/
function search_api_admin_add_index_validate(array $form, array &$form_state) {
- $name = $form_state['values']['machine_name'];
+ $values = $form_state['values'];
+ $name = $values['machine_name'];
if (is_numeric($name)) {
form_set_error('machine_name', t('The machine name must not be a pure number.'));
}
- $cron_limit = $form_state['values']['options']['cron_limit'];
- if ($cron_limit != '' . ((int) $cron_limit)) {
- // We don't enforce stricter rules and treat all negative values as -1.
- form_set_error('options[cron_limit]', t('The cron batch size must be an integer.'));
+ if (!$values['datasource_config'] || empty($values['item_type']) || $values['item_type'] != $values['old_type']) {
+ return;
}
+ $datasource = search_api_get_datasource_controller($values['item_type']);
+ $datasource->configurationFormValidate($form['datasource'], $form_state['values']['options']['datasource'], $form_state);
}
/**
- * Submit callback for search_api_admin_add_index.
+ * Form submission handler for search_api_admin_add_index().
+ *
+ * @see search_api_admin_add_index_validate()
*/
function search_api_admin_add_index_submit(array $form, array &$form_state) {
form_state_values_clean($form_state);
-
$values = $form_state['values'];
- // Validation of whether the server of an enabled index is also enabled is
- // done in the *_insert() function.
+ if (!empty($form_state['step_one'])) {
+ $values += $form_state['step_one'];
+ unset($form_state['step_one']);
+ }
+
+ // The type was changed (or the form submitted without JS for the first time).
+ // If the new type has a configuration form, we have to display it now.
+ $datasource = search_api_get_datasource_controller($values['item_type']);
+ if ($values['item_type'] != $values['old_type']) {
+ $datasource_form = array();
+ if ($datasource->configurationForm($datasource_form, $form_state)) {
+ unset($values['options']['datasource']);
+ $form_state['step_one'] = $values;
+ $form_state['rebuild'] = TRUE;
+ drupal_set_message(t('Please specify further configuration options.'));
+ return;
+ }
+ }
+
+ // If the current type has a configuration form, call the datasource
+ // controller's config submit callback.
+ if ($values['datasource_config']) {
+ $datasource->configurationFormSubmit($form['datasource'], $values['options']['datasource'], $form_state);
+ }
+
+ // Validation of whether a server is set for the index is done in the
+ // SearchApiIndex::save() method.
search_api_index_insert($values);
drupal_set_message(t('The index was successfully created. Please set up its indexed fields now.'));
@@ -633,16 +880,17 @@ function search_api_admin_add_index_submit(array $form, array &$form_state) {
}
/**
- * Displays an index' details.
+ * Page callback for displaying an index's status.
*
* @param SearchApiIndex $index
* The index to display.
+ * @param string|null $action
+ * (optional) An action to execute for the index. One of "reindex", "clear",
+ * "enable" or "disable". For "disable", a confirm dialog will be shown.
+ *
+ * @see search_api_menu()
*/
-function search_api_admin_index_view(SearchApiIndex $index = NULL, $action = NULL) {
- if (empty($index)) {
- return MENU_NOT_FOUND;
- }
-
+function search_api_admin_index_view(SearchApiIndex $index, $action = NULL) {
if (!empty($action)) {
if ($action == 'enable') {
if (isset($_GET['token']) && drupal_valid_token($_GET['token'], $index->machine_name)) {
@@ -660,32 +908,54 @@ function search_api_admin_index_view(SearchApiIndex $index = NULL, $action = NUL
}
else {
$ret = drupal_get_form('search_api_admin_confirm', 'index', $action, $index);
- if ($ret) {
+ if (!empty($ret['actions'])) {
return $ret;
}
}
}
- $ret = array(
+ $status = search_api_index_status($index);
+ try {
+ $server = $index->server();
+ }
+ catch (SearchApiException $e) {
+ $server = NULL;
+ $vars['%server'] = $index->server;
+ $message = t('The index has an unknown server (ID: %server) set. Please check the index settings.', $vars);
+ drupal_set_message($message, 'error');
+ }
+ $ret['view'] = array(
'#theme' => 'search_api_index',
'#id' => $index->id,
'#name' => $index->name,
'#machine_name' => $index->machine_name,
'#description' => $index->description,
'#item_type' => $index->item_type,
+ '#datasource_config' => $index->datasource()->getConfigurationSummary($index),
'#enabled' => $index->enabled,
- '#server' => $index->server(),
+ '#server' => $server,
'#options' => $index->options,
'#fields' => $index->getFields(),
+ '#indexed_items' => $status['indexed'],
+ '#on_server' => NULL,
+ '#total_items' => $status['total'],
'#status' => $index->status,
'#read_only' => $index->read_only,
);
-
+ try{
+ $ret['view']['#on_server'] = _search_api_get_items_on_server($index);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ if ($index->enabled && !$index->read_only) {
+ $ret['form'] = drupal_get_form('search_api_admin_index_status_form', $index, $status);
+ }
return $ret;
}
/**
- * Theme function for displaying an index.
+ * Returns HTML for a search index.
*
* @param array $variables
* An associative array containing:
@@ -694,308 +964,320 @@ function search_api_admin_index_view(SearchApiIndex $index = NULL, $action = NUL
* - machine_name: The index' machine name.
* - description: The index' description.
* - item_type: The type of items stored in this index.
+ * - datasource_config: A summary of the datasource's configuration.
* - enabled: Boolean indicating whether the index is enabled.
* - server: The server this index currently rests on, if any.
* - options: The index' options, like cron limit.
* - fields: All indexed fields of the index.
* - indexed_items: The number of items already indexed in their latest
* version on this index.
+ * - on_server: The number of items actually indexed on the server. NULL if
+ * the search for finding out the item count failed.
* - total_items: The total number of items that have to be indexed for this
* index.
* - status: The entity configuration status (in database, in code, etc.).
* - read_only: Boolean indicating whether this index is read only.
+ *
+ * @return string
+ * HTML for a search index.
+ *
+ * @ingroup themeable
*/
function theme_search_api_index(array $variables) {
- extract($variables);
-
+ $machine_name = $variables['machine_name'];
+ $description = $variables['description'];
+ $enabled = $variables['enabled'];
+ $item_type = $variables['item_type'];
+ $datasource_config = $variables['datasource_config'];
+ $server = $variables['server'];
+ $options = $variables['options'];
+ $status = $variables['status'];
+ $indexed_items = $variables['indexed_items'];
+ $on_server = $variables['on_server'];
+ $total_items = $variables['total_items'];
+
+ // First, output the index description if there is one set.
$output = '';
- $output .= '
' . check_plain($name) . '
' . "\n";
+ if ($description) {
+ $output .= '
' . nl2br(check_plain($description)) . '
';
+ }
- $output .= '
' . "\n";
+ // Then, display a table summarizing the index's status.
+ $rows = array();
+ // Create a row template with references so we don't have to deal with the
+ // complicated structure for each individual row.
+ $row = array(
+ 'data' => array(
+ array('header' => TRUE),
+ '',
+ ),
+ 'class' => array(''),
+ );
+ $label = &$row['data'][0]['data'];
+ $info = &$row['data'][1];
+ $class = &$row['class'][0];
- $output .= '
' . "\n";
+ if (!isset($on_server)) {
+ $info = t('An error occurred while trying to determine the server index status. Please check the logs for details.');
+ $class = 'error';
+ }
+ else {
+ $vars['@url'] = url('https://drupal.org/node/2009804#server-index-status');
+ $info = format_plural($on_server, 'There is 1 item indexed on the server for this index. (More information)', 'There are @count items indexed on the server for this index. (More information)', $vars);
+ $class = '';
+ }
+ $label = t('Server index status');
+ $rows[] = _search_api_deep_copy($row);
}
- $output .= '
',
+ );
return $form;
}
/**
- * Validation function for search_api_admin_index_status_form.
+ * Form validation handler for search_api_admin_index_status_form().
+ *
+ * @see search_api_admin_index_status_form_submit()
*/
function search_api_admin_index_status_form_validate(array $form, array &$form_state) {
- if ($form_state['values']['op'] == t('Index now') && !$form_state['values']['limit']) {
- form_set_error('number', t('You have to set the number of items to index. Set to -1 for indexing all items.'));
+ $values = $form_state['values'];
+ if ($values['op'] == t('Index now')) {
+ $all_lower = drupal_strtolower($values['all']);
+ foreach (array('limit', 'batch_size') as $field) {
+ $val = trim($values[$field]);
+ if (drupal_strtolower($val) == $all_lower) {
+ $val = -1;
+ }
+ elseif (!$val || !is_numeric($val) || ((int) $val) != $val) {
+ form_error($form['index'][$field], t('Enter a non-zero integer. Use "-1" or "@all" for "all items".', array('@all' => $values['all'])));
+ }
+ else {
+ $val = (int) $val;
+ }
+ $form_state['values'][$field] = $val;
+ }
}
}
/**
- * Submit function for search_api_admin_index_status_form.
+ * Form submission handler for search_api_admin_index_status_form().
+ *
+ * @see search_api_admin_index_status_form_validate()
*/
function search_api_admin_index_status_form_submit(array $form, array &$form_state) {
- $redirect = &$form_state['redirect'];
$values = $form_state['values'];
$index = $form_state['index'];
- $pre = 'admin/config/search/search_api/index/' . $index->machine_name;
+ $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name;
+
+ // There is a Form API bug here that will let a user submit the form via the
+ // "Index now" button even if it is disabled, and then just set "op" to the
+ // value of an arbitrary other button. We therefore have to take care to spot
+ // this case ourselves.
+ if ($form_state['input']['op'] == t('Index now') && !empty($form['index']['button']['#disabled'])) {
+ drupal_set_message(t('All items have already been indexed.'), 'warning');
+ return;
+ }
+
switch ($values['op']) {
- case t('Enable'):
- $redirect = array(
- $pre . '/enable',
- array('query' => array('token' => drupal_get_token($index->machine_name))),
- );
- break;
- case t('Disable'):
- $redirect = $pre . '/disable';
- break;
case t('Index now'):
if (!_search_api_batch_indexing_create($index, $values['batch_size'], $values['limit'], $values['remaining'])) {
drupal_set_message(t("Couldn't create a batch, please check the batch size and limit."), 'warning');
}
- $redirect = $pre . '/status';
break;
- case t('Re-index content'):
- if ($index->reindex()) {
- drupal_set_message(t('The index was successfully scheduled for re-indexing.'));
- }
- else {
- drupal_set_message(t('An error has occurred while performing the desired action. Check the logs for details.'), 'error');
- }
- $redirect = $pre . '/status';
- break;
- case t('Clear index'):
- if ($index->clear()) {
- drupal_set_message(t('The index was successfully cleared.'));
- }
- else {
- drupal_set_message(t('An error has occurred while performing the desired action. Check the logs for details.'), 'error');
- }
- $redirect = $pre . '/status';
+
+ case t('Queue all items for reindexing'):
+ $form_state['redirect'] .= '/reindex';
break;
- default:
- throw new SearchApiException(t('Unknown action.'));
+ case t('Clear all indexed data'):
+ $form_state['redirect'] .= '/clear';
+ break;
}
}
/**
- * Edit an index' settings.
+ * Form constructor for editing an index's settings.
*
* @param SearchApiIndex $index
* The index to edit.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_index_edit_validate()
+ * @see search_api_admin_index_edit_submit()
*/
function search_api_admin_index_edit(array $form, array &$form_state, SearchApiIndex $index) {
$form_state['index'] = $index;
@@ -1009,12 +1291,21 @@ function search_api_admin_index_edit(array $form, array &$form_state, SearchApiI
'#default_value' => $index->name,
'#required' => TRUE,
);
+ try {
+ $enabled_fixed = !$index->server();
+ }
+ catch (Exception $e) {
+ watchdog_exception('search_api', $e);
+ // The exception only occurs if the index is disabled, and for an unknown
+ // server we of course want do prevent the index from being enabled.
+ $enabled_fixed = TRUE;
+ }
$form['enabled'] = array(
'#type' => 'checkbox',
'#title' => t('Enabled'),
'#default_value' => $index->enabled,
- // Can't enable an index lying on a disabled server, or no server at all.
- '#disabled' => !$index->enabled && (!$index->server() || !$index->server()->enabled),
+ // Can't enable an index that's not lying on any server.
+ '#disabled' => $enabled_fixed,
);
$form['description'] = array(
'#type' => 'textarea',
@@ -1028,18 +1319,20 @@ function search_api_admin_index_edit(array $form, array &$form_state, SearchApiI
'#default_value' => $index->server,
'#options' => array('' => t('< No server >'))
);
- $servers = search_api_server_load_multiple(FALSE);
+ $servers = search_api_server_load_multiple(FALSE, array('enabled' => 1));
// List enabled servers first.
foreach ($servers as $server) {
- if ($server->enabled) {
- $form['server']['#options'][$server->machine_name] = $server->name;
- }
+ $form['server']['#options'][$server->machine_name] = $server->name;
}
- foreach ($servers as $server) {
- if (!$server->enabled) {
- $form['server']['#options'][$server->machine_name] = t('@server_name (disabled)', array('@server_name' => $server->name));
- }
+
+ $datasource_form = !empty($form['options']['datasource']) ? $form['options']['datasource'] : array();
+ $datasource_form = $index->datasource()->configurationForm($datasource_form, $form_state);
+ if ($datasource_form) {
+ $form['options']['datasource'] = $datasource_form;
+ $form['options']['datasource']['#type'] = 'fieldset';
+ $form['options']['datasource']['#title'] = t('Datasource options');
}
+
$form['read_only'] = array(
'#type' => 'checkbox',
'#title' => t('Read only'),
@@ -1070,22 +1363,48 @@ function search_api_admin_index_edit(array $form, array &$form_state, SearchApiI
),
);
- $form['submit'] = array(
+ $form['actions']['#type'] = 'actions';
+ $form['actions']['submit'] = array(
'#type' => 'submit',
'#value' => t('Save settings'),
);
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#submit' => array('search_api_admin_form_delete_submit'),
+ '#limit_validation_errors' => array(),
+ );
return $form;
}
/**
- * Submit callback for search_api_admin_index_edit.
+ * Form validation handler for search_api_admin_index_edit().
+ *
+ * @see search_api_admin_index_edit_submit()
+ */
+function search_api_admin_index_edit_validate(array $form, array &$form_state) {
+ if (!empty($form['options']['datasource'])) {
+ $form_state['values']['options'] += array('datasource' => array());
+ $form_state['index']->datasource()->configurationFormValidate($form['options']['datasource'], $form_state['values']['options']['datasource'], $form_state);
+ }
+}
+
+/**
+ * Form submission handler for search_api_admin_index_edit().
+ *
+ * @see search_api_admin_index_edit_validate()
*/
function search_api_admin_index_edit_submit(array $form, array &$form_state) {
form_state_values_clean($form_state);
-
$values = $form_state['values'];
+ /** @var SearchApiIndex $index */
$index = $form_state['index'];
+
+ if (!empty($form['options']['datasource'])) {
+ $index->datasource()->configurationFormSubmit($form['options']['datasource'], $values['options']['datasource'], $form_state);
+ }
+
$values['options'] += $index->options;
$ret = $index->update($values);
@@ -1099,13 +1418,17 @@ function search_api_admin_index_edit_submit(array $form, array &$form_state) {
}
/**
- * Edit an index' workflow (data alter callbacks, pre-/postprocessors, and their
- * order).
+ * Form constructor for editing an index's data alterations and processors.
*
* @param SearchApiIndex $index
* The index to edit.
+ *
+ * @ingroup forms
+ *
+ * @see search_api_admin_index_workflow_validate()
+ * @see search_api_admin_index_workflow_submit()
*/
-// Copied from filter_admin_format_form
+// Copied from filter_admin_format_form()
function search_api_admin_index_workflow(array $form, array &$form_state, SearchApiIndex $index) {
$callback_info = search_api_get_alter_callbacks();
$processor_info = search_api_get_processors();
@@ -1243,10 +1566,13 @@ function search_api_admin_index_workflow(array $form, array &$form_state, Search
$form['processors'] = array(
'#type' => 'fieldset',
'#title' => t('Processors'),
- '#description' => t('Select processors which will pre- and post-process data at index and search time, and their order. ' .
- 'Most processors will only influence fulltext fields, but refer to their individual descriptions for details regarding their effect.'),
+ '#description' => '
' . t("Select processors which will pre- and post-process data at index and search time, and their order. Most processors will only influence fulltext fields, but refer to their individual descriptions for details regarding their effect. Also, some processors shouldn't be used with more advanced search engines (like Solr or Elasticsearch), since the search engine already provides this functionality.") . '
' . t("Check the server's service class description for details.",
+ array('@server-url' => url('admin/config/search/search_api/server/' . $index->server . '/edit'))) . '
';
+ }
// Processor status.
$form['processors']['status'] = array(
@@ -1345,7 +1671,9 @@ function theme_search_api_admin_item_order(array $variables) {
}
/**
- * Validation callback for search_api_admin_index_workflow.
+ * Form validation handler for search_api_admin_index_workflow().
+ *
+ * @see search_api_admin_index_workflow_submit()
*/
function search_api_admin_index_workflow_validate(array $form, array &$form_state) {
// Call validation functions.
@@ -1362,16 +1690,18 @@ function search_api_admin_index_workflow_validate(array $form, array &$form_stat
}
/**
- * Submit callback for search_api_admin_index_workflow.
+ * Form submission handler for search_api_admin_index_workflow().
+ *
+ * @see search_api_admin_index_workflow_validate()
*/
function search_api_admin_index_workflow_submit(array $form, array &$form_state) {
$values = $form_state['values'];
unset($values['callbacks']['settings']);
unset($values['processors']['settings']);
$index = $form_state['index'];
+ $index_path = 'admin/config/search/search_api/index/' . $index->machine_name;
$options = empty($index->options) ? array() : $index->options;
- $fields_set = !empty($options['fields']);
// Store callback and processor settings.
foreach ($form_state['callbacks'] as $name => $callback) {
@@ -1389,6 +1719,7 @@ function search_api_admin_index_workflow_submit(array $form, array &$form_state)
foreach ($form_state['callbacks'] as $name => $callback) {
// Check whether callback status has changed.
if ($values['callbacks'][$name]['status'] == empty($options['data_alter_callbacks'][$name]['status'])) {
+ $callbacks_changed = TRUE;
if ($values['callbacks'][$name]['status']) {
// Callback was just enabled, add its fields.
$properties = $callback->propertyInfo();
@@ -1397,7 +1728,8 @@ function search_api_admin_index_workflow_submit(array $form, array &$form_state)
$type = $field['type'];
$inner = search_api_extract_inner_type($type);
if ($inner != 'token' && empty($types[$inner])) {
- // Someone apparently added a structure or entity as a property in a data-alter callback.
+ // Someone apparently added a structure or entity as a property in
+ // a data alteration.
continue;
}
if ($inner == 'token' || (search_api_is_text_type($inner) && !empty($field['options list']))) {
@@ -1414,16 +1746,6 @@ function search_api_admin_index_workflow_submit(array $form, array &$form_state)
}
}
}
- else {
- // Callback was just disabled, remove its fields.
- $properties = $callback->propertyInfo();
- if ($properties) {
- foreach ($properties as $key => $field) {
- unset($index->options['fields'][$key]);
- }
- }
-
- }
}
}
@@ -1437,26 +1759,26 @@ function search_api_admin_index_workflow_submit(array $form, array &$form_state)
uasort($index->options['data_alter_callbacks'], 'search_api_admin_element_compare');
uasort($index->options['processors'], 'search_api_admin_element_compare');
- // Reset the index's internal property cache to correctly incorporate the
- // new data alterations.
- $index->resetCaches();
+ // Re-calculate the fields, since they might have changed in hard-to-predict
+ // ways.
+ search_api_index_recalculate_fields(array($index));
$index->save();
$index->reindex();
- drupal_set_message(t("The search index' workflow was successfully edited. " .
- 'All content was scheduled for re-indexing so the new settings can take effect.'));
+ $vars = array('@url' => url($index_path));
+ drupal_set_message(t('The indexing workflow was successfully edited. All content was scheduled for re-indexing so the new settings can take effect.', $vars));
}
else {
drupal_set_message(t('No values were changed.'));
}
- $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name . '/workflow';
+ $form_state['redirect'] = $index_path . '/workflow';
}
/**
* Sort callback sorting array elements by their "weight" key, if present.
*
- * @see element_sort
+ * @see element_sort()
*/
function search_api_admin_element_compare($a, $b) {
$a_weight = (is_array($a) && isset($a['weight'])) ? $a['weight'] : 0;
@@ -1468,10 +1790,11 @@ function search_api_admin_element_compare($a, $b) {
}
/**
- * Select the indexed fields.
+ * Form constructor for setting the indexed fields.
*
- * @param SearchApiIndex $index
- * The index to edit.
+ * @ingroup forms
+ *
+ * @see search_api_admin_index_fields_submit()
*/
function search_api_admin_index_fields(array $form, array &$form_state, SearchApiIndex $index) {
$options = $index->getFields(FALSE, TRUE);
@@ -1480,10 +1803,17 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
// An array of option arrays for types, keyed by nesting level.
$types = array(0 => search_api_field_types());
- $fulltext_type = array(0 => 'text');
$entity_types = entity_get_info();
- $default_types = search_api_default_field_types();
- $boosts = drupal_map_assoc(array('0.1', '0.2', '0.3', '0.5', '0.8', '1.0', '2.0', '3.0', '5.0', '8.0', '13.0', '21.0'));
+ $boosts = drupal_map_assoc(array('0.0', '0.1', '0.2', '0.3', '0.5', '0.8', '1.0', '2.0', '3.0', '5.0', '8.0', '13.0', '21.0'));
+
+ $fulltext_types = array(0 => array('text'));
+ // Add all custom data types with fallback "text" to fulltext types as well.
+ foreach (search_api_get_data_type_info() as $id => $type) {
+ if ($type['fallback'] != 'text') {
+ continue;
+ }
+ $fulltext_types[0][] = $id;
+ }
$form_state['index'] = $index;
$form['#theme'] = 'search_api_admin_fields_table';
@@ -1497,8 +1827,8 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
'In any case, fields of type "Fulltext" will always be fulltext-searchable.'),
);
if ($index->server) {
- $form['description']['#description'] .= '
' . t('Check the ' . "server's service class description for details.",
- array('@server-url' => url('admin/config/search/search_api/server/' . $index->server))) . '
';
+ $form['description']['#description'] .= '
' . t("Check the server's service class description for details.",
+ array('@server-url' => url('admin/config/search/search_api/server/' . $index->server . '/edit'))) . '
';
}
foreach ($fields as $key => $info) {
$form['fields'][$key]['title']['#markup'] = check_plain($info['name']);
@@ -1518,17 +1848,20 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
'#default_value' => $info['indexed'],
);
if (empty($info['entity_type'])) {
- // Determine the correct type options (i.e., with the correct nesting level).
+ // Determine the correct type options (with the correct nesting level).
$level = search_api_list_nesting_level($info['type']);
if (empty($types[$level])) {
$type_prefix = str_repeat('list<', $level);
$type_suffix = str_repeat('>', $level);
$types[$level] = array();
foreach ($types[0] as $type => $name) {
- // We use the singular name for list types, since the user usually doesn't care about the nesting level.
+ // We use the singular name for list types, since the user usually
+ // doesn't care about the nesting level.
$types[$level][$type_prefix . $type . $type_suffix] = $name;
}
- $fulltext_type[$level] = $type_prefix . 'text' . $type_suffix;
+ foreach ($fulltext_types[0] as $type) {
+ $fulltext_types[$level][] = $type_prefix . $type . $type_suffix;
+ }
}
$css_key = '#edit-fields-' . drupal_clean_css_identifier($key);
$form['fields'][$key]['type'] = array(
@@ -1548,10 +1881,19 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
'#states' => array(
'visible' => array(
$css_key . '-indexed' => array('checked' => TRUE),
- $css_key . '-type' => array('value' => $fulltext_type[$level]),
),
),
);
+ // Only add the multiple visible states if the VERSION string is >= 7.14.
+ // See https://drupal.org/node/1464758.
+ if (version_compare(VERSION, '7.14', '>=')) {
+ foreach ($fulltext_types[$level] as $type) {
+ $form['fields'][$key]['boost']['#states']['visible'][$css_key . '-type'][] = array('value' => $type);
+ }
+ }
+ else {
+ $form['fields'][$key]['boost']['#states']['visible'][$css_key . '-type'] = array('value' => reset($fulltext_types[$level]));
+ }
}
else {
// This is an entity.
@@ -1605,6 +1947,7 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
);
if ($additional) {
+ asort($additional);
reset($additional);
$form['additional'] = array(
'#type' => 'fieldset',
@@ -1646,14 +1989,17 @@ function _search_api_admin_get_fields(SearchApiIndex $index, EntityMetadataWrapp
$added[$key] = TRUE;
}
- // Then we walk through all properties and look if they are already contained in one of the arrays.
- // Since this uses an iterative instead of a recursive approach, it is a bit complicated, with three arrays tracking the current depth.
+ // Then we walk through all properties and look if they are already contained
+ // in one of the arrays. Since this uses an iterative instead of a recursive
+ // approach, it is a bit complicated, with three arrays tracking the current
+ // depth.
- // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the user wrapper
+ // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the user
+ // wrapper
$wrappers = array('' => $wrapper);
// Display names for the prefixes
$prefix_names = array('' => '');
- // The list nesting level for entities with a certain prefix
+ // The list nesting level for entities with a certain prefix
$nesting_levels = array('' => 0);
$types = search_api_default_field_types();
@@ -1679,7 +2025,8 @@ function _search_api_admin_get_fields(SearchApiIndex $index, EntityMetadataWrapp
// We hide the complexity of multi-valued types from the user here.
$type = search_api_extract_inner_type($info['type']);
// Treat Entity API type "token" as our "string" type.
- // Also let text fields with limited options be of type "string" by default.
+ // Also let text fields with limited options be of type "string" by
+ // default.
if ($type == 'token' || ($type == 'text' && !empty($info['options list']))) {
// Inner type is changed to "string".
$type = 'string';
@@ -1754,6 +2101,9 @@ function _search_api_admin_get_fields(SearchApiIndex $index, EntityMetadataWrapp
* @param array $variables
* An associative array containing:
* - element: A render element representing the form.
+ *
+ * @return string
+ * The HTML for a field list form.
*/
function theme_search_api_admin_fields_table($variables) {
$form = $variables['element'];
@@ -1768,7 +2118,7 @@ function theme_search_api_admin_fields_table($variables) {
}
}
if (empty($form['fields'][$name]['description']['#value'])) {
- $rows[] = $row;
+ $rows[] = _search_api_deep_copy($row);
}
else {
$rows[] = array(
@@ -1794,11 +2144,12 @@ function theme_search_api_admin_fields_table($variables) {
}
/**
- * Submit function for search_api_admin_index_fields.
+ * Form submission handler for search_api_admin_index_fields().
*/
function search_api_admin_index_fields_submit(array $form, array &$form_state) {
$index = $form_state['index'];
$options = isset($index->options) ? $index->options : array();
+ $index_path = 'admin/config/search/search_api/index/' . $index->machine_name;
if ($form_state['values']['op'] == t('Save changes')) {
$fields = $form_state['values']['fields'];
$default_types = search_api_default_field_types();
@@ -1828,18 +2179,18 @@ function search_api_admin_index_fields_submit(array $form, array &$form_state) {
$ret = $index->update(array('options' => $options));
if ($ret) {
- drupal_set_message(t('The indexed fields were successfully changed. ' .
- 'The index was cleared and will have to be re-indexed with the new settings.'));
+ $vars = array('@url' => $index_path);
+ drupal_set_message(t('The indexed fields were successfully changed. The index was cleared and will have to be re-indexed with the new settings.', $vars));
}
else {
drupal_set_message(t('No values were changed.'));
}
if (isset($index->options['data_alter_callbacks']) || isset($index->options['processors'])) {
- $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name . '/fields';
+ $form_state['redirect'] = $index_path . '/fields';
}
else {
- drupal_set_message(t('Please set up the index workflow.'));
- $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name . '/workflow';
+ drupal_set_message(t('Please set up the indexing workflow.'));
+ $form_state['redirect'] = $index_path . '/workflow';
}
return;
}
@@ -1854,14 +2205,23 @@ function search_api_admin_index_fields_submit(array $form, array &$form_state) {
else {
drupal_set_message(t('No values were changed.'));
}
- $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name . '/fields';
+ $form_state['redirect'] = $index_path . '/fields';
}
-
/**
- * Helper function for displaying a generic confirmation form.
+ * Form constructor for a generic confirmation form.
+ *
+ * @param $type
+ * The type of entity (not the real "entity type"). Either "server" or
+ * "index".
+ * @param $action
+ * The action that would be executed for this entity after confirming. One of
+ * "reindex" ("index" type only), "clear", "disable" or "delete".
+ * @param Entity $entity
+ * The entity for which the action would be performed. Must have a "name"
+ * property.
*
- * @return
+ * @return array|false
* Either a form array, or FALSE if this combination of type and action is
* not supported.
*/
@@ -1869,15 +2229,24 @@ function search_api_admin_confirm(array $form, array &$form_state, $type, $actio
switch ($type) {
case 'server':
switch ($action) {
+ case 'clear':
+ $text = array(
+ t('Clear server @name', array('@name' => $entity->name)),
+ t('Do you really want to clear all indexed data from this server?'),
+ t('This will permanently remove all data currently indexed on this server. Before the data is reindexed, searches on the indexes associated with this server will not return any results. This action cannot be undone. Use with caution!'),
+ t("The server's indexed data was successfully cleared."),
+ );
+ break;
+
case 'disable':
$text = array(
t('Disable server @name', array('@name' => $entity->name)),
t('Do you really want to disable this server?'),
- t('This will disable both the server and all associated indexes. ' .
- "Searches on these indexes won't be available until they are re-enabled."),
+ t('This will disconnect all indexes from this server and disable them. Searches on these indexes will not be available until they are added to another server and re-enabled. All indexed data (except for read-only indexes) on this server will be cleared.'),
t('The server and its indexes were successfully disabled.'),
);
break;
+
case 'delete':
if ($entity->hasStatus(ENTITY_OVERRIDDEN)) {
$text = array(
@@ -1897,12 +2266,31 @@ function search_api_admin_confirm(array $form, array &$form_state, $type, $actio
);
}
break;
+
default:
return FALSE;
}
break;
case 'index':
switch ($action) {
+ case 'reindex':
+ $text = array(
+ t('Re-index index @name', array('@name' => $entity->name)),
+ t('Do you really want to queue all items on this index for re-indexing?'),
+ t('This will mark all items for this index to be marked as needing to be indexed. Searches on this index will continue to yield results while the items are being re-indexed. This action cannot be undone.'),
+ t('The index was successfully marked for re-indexing.'),
+ );
+ break;
+
+ case 'clear':
+ $text = array(
+ t('Clear index @name', array('@name' => $entity->name)),
+ t('Do you really want to clear the indexed data of this index?'),
+ t('This will remove all data currently indexed for this index. Before the data is reindexed, searches on the index will not return any results. This action cannot be undone.'),
+ t('The index was successfully cleared.'),
+ );
+ break;
+
case 'disable':
$text = array(
t('Disable index @name', array('@name' => $entity->name)),
@@ -1911,6 +2299,7 @@ function search_api_admin_confirm(array $form, array &$form_state, $type, $actio
t('The index was successfully disabled.'),
);
break;
+
case 'delete':
if ($entity->hasStatus(ENTITY_OVERRIDDEN)) {
$text = array(
@@ -1930,6 +2319,7 @@ function search_api_admin_confirm(array $form, array &$form_state, $type, $actio
);
}
break;
+
default:
return FALSE;
}
@@ -1970,8 +2360,16 @@ function search_api_admin_confirm_submit(array $form, array &$form_state) {
$action = $values['action'];
$id = $values['id'];
+ $success = FALSE;
$function = "search_api_{$type}_{$action}";
- if ($function($id)) {
+ try {
+ // Some actions, like disabling, can actually throw an exception.
+ $success = $function($id);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
+ if ($success) {
drupal_set_message($values['message']);
}
else {
diff --git a/search_api.admin.js b/search_api.admin.js
index d841ec16..9ff40ae0 100644
--- a/search_api.admin.js
+++ b/search_api.admin.js
@@ -1,7 +1,14 @@
+/**
+ * @file
+ * Javascript enhancements for the Search API admin pages.
+ */
-// Copied from filter.admin.js
(function ($) {
+/**
+ * Allows the re-ordering of enabled data alterations and processors.
+ */
+// Copied from filter.admin.js
Drupal.behaviors.searchApiStatus = {
attach: function (context, settings) {
$('.search-api-status-wrapper input.form-checkbox', context).once('search-api-status', function () {
@@ -43,19 +50,158 @@ Drupal.behaviors.searchApiStatus = {
}
};
-Drupal.behaviors.searchApiEditMenu = {
+/**
+ * Processes elements with the .dropbutton class on page load.
+ */
+Drupal.behaviors.searchApiDropButton = {
attach: function (context, settings) {
- $('.search-api-edit-menu-toggle', context).click(function (e) {
- $menu = $(this).parent().find('.search-api-edit-menu');
- if ($menu.is('.collapsed')) {
- $menu.removeClass('collapsed');
- }
- else {
- $menu.addClass('collapsed');
+ var $dropbuttons = $(context).find('.dropbutton-wrapper').once('dropbutton');
+ if ($dropbuttons.length) {
+ //$('.dropbutton-toggle', $dropbuttons).click(dropbuttonClickHandler);
+ // Initialize all buttons.
+ for (var i = 0, il = $dropbuttons.length; i < il; i++) {
+ DropButton.dropbuttons.push(new DropButton($dropbuttons[i], settings.dropbutton));
}
- return false;
- });
+ // Adds the delegated handler that will toggle dropdowns on click.
+ $('.dropbutton-toggle', $dropbuttons).click(dropbuttonClickHandler);
+ }
}
};
+/**
+ * Delegated callback for opening and closing dropbutton secondary actions.
+ */
+function dropbuttonClickHandler(e) {
+ e.preventDefault();
+ $(e.target).closest('.dropbutton-wrapper').toggleClass('open');
+}
+
+/**
+ * A DropButton presents an HTML list as a button with a primary action.
+ *
+ * All secondary actions beyond the first in the list are presented in a
+ * dropdown list accessible through a toggle arrow associated with the button.
+ *
+ * @param {jQuery} dropbutton
+ * A jQuery element.
+ *
+ * @param {Object} settings
+ * A list of options including:
+ * - {String} title: The text inside the toggle link element. This text is
+ * hidden from visual UAs.
+ */
+function DropButton(dropbutton, settings) {
+ // Merge defaults with settings.
+ var options = $.extend({'title': Drupal.t('List additional actions')}, settings);
+ var $dropbutton = $(dropbutton);
+ this.$dropbutton = $dropbutton;
+ this.$list = $dropbutton.find('.dropbutton');
+ // Find actions and mark them.
+ this.$actions = this.$list.find('li').addClass('dropbutton-action');
+
+ // Add the special dropdown only if there are hidden actions.
+ if (this.$actions.length > 1) {
+ // Identify the first element of the collection.
+ var $primary = this.$actions.slice(0, 1);
+ // Identify the secondary actions.
+ var $secondary = this.$actions.slice(1);
+ $secondary.addClass('secondary-action');
+ // Add toggle link.
+ $primary.after(Drupal.theme('dropbuttonToggle', options));
+ // Bind mouse events.
+ this.$dropbutton
+ .addClass('dropbutton-multiple')
+ /**
+ * Adds a timeout to close the dropdown on mouseleave.
+ */
+ .bind('mouseleave.dropbutton', $.proxy(this.hoverOut, this))
+ /**
+ * Clears timeout when mouseout of the dropdown.
+ */
+ .bind('mouseenter.dropbutton', $.proxy(this.hoverIn, this))
+ /**
+ * Similar to mouseleave/mouseenter, but for keyboard navigation.
+ */
+ .bind('focusout.dropbutton', $.proxy(this.focusOut, this))
+ .bind('focusin.dropbutton', $.proxy(this.focusIn, this));
+ }
+}
+
+/**
+ * Extend the DropButton constructor.
+ */
+$.extend(DropButton, {
+ /**
+ * Store all processed DropButtons.
+ *
+ * @type {Array}
+ */
+ dropbuttons: []
+});
+
+/**
+ * Extend the DropButton prototype.
+ */
+$.extend(DropButton.prototype, {
+ /**
+ * Toggle the dropbutton open and closed.
+ *
+ * @param {Boolean} show
+ * (optional) Force the dropbutton to open by passing true or to close by
+ * passing false.
+ */
+ toggle: function (show) {
+ var isBool = typeof show === 'boolean';
+ show = isBool ? show : !this.$dropbutton.hasClass('open');
+ this.$dropbutton.toggleClass('open', show);
+ },
+
+ hoverIn: function () {
+ // Clear any previous timer we were using.
+ if (this.timerID) {
+ window.clearTimeout(this.timerID);
+ }
+ },
+
+ hoverOut: function () {
+ // Wait half a second before closing.
+ this.timerID = window.setTimeout($.proxy(this, 'close'), 500);
+ },
+
+ open: function () {
+ this.toggle(true);
+ },
+
+ close: function () {
+ this.toggle(false);
+ },
+
+ focusOut: function (e) {
+ this.hoverOut.call(this, e);
+ },
+
+ focusIn: function (e) {
+ this.hoverIn.call(this, e);
+ }
+});
+
+$.extend(Drupal.theme, {
+ /**
+ * A toggle is an interactive element often bound to a click handler.
+ *
+ * @param {Object} options
+ * - {String} title: (optional) The HTML anchor title attribute and
+ * text for the inner span element.
+ *
+ * @return {String}
+ * A string representing a DOM fragment.
+ */
+ dropbuttonToggle: function (options) {
+ return '
';
+ }
+});
+
+// Expose constructor in the public space.
+Drupal.DropButton = DropButton;
+
})(jQuery);
diff --git a/search_api.api.php b/search_api.api.php
index 22f9acbf..d1740c6d 100644
--- a/search_api.api.php
+++ b/search_api.api.php
@@ -15,8 +15,6 @@
*
* Note: The ids should be valid PHP identifiers.
*
- * @see hook_search_api_service_info_alter()
- *
* @return array
* An associative array of search service classes, keyed by a unique
* identifier and containing associative arrays with the following keys:
@@ -24,9 +22,12 @@
* - description: A translated string to be shown to administrators when
* selecting a service class. Should contain all peculiarities of the
* service class, like field type support, supported features (like facets),
- * the "direct" parse mode and other specific things to keep in mind.
+ * the "direct" parse mode and other specific things to keep in mind. The
+ * text can contain HTML.
* - class: The service class, which has to implement the
* SearchApiServiceInterface interface.
+ *
+ * @see hook_search_api_service_info_alter()
*/
function hook_search_api_service_info() {
$services['example_some'] = array(
@@ -49,13 +50,14 @@ function hook_search_api_service_info() {
* Alter the Search API service info.
*
* Modules may implement this hook to alter the information that defines Search
- * API service. All properties that are available in
- * hook_search_api_service_info() can be altered here.
- *
- * @see hook_search_api_service_info()
+ * API services. All properties that are available in
+ * hook_search_api_service_info() can be altered here, with the addition of the
+ * "module" key specifying the module that originally defined the service class.
*
* @param array $service_info
* The Search API service info array, keyed by service id.
+ *
+ * @see hook_search_api_service_info()
*/
function hook_search_api_service_info_alter(array &$service_info) {
foreach ($service_info as $id => $info) {
@@ -125,7 +127,8 @@ function hook_search_api_item_type_info() {
*
* Modules may implement this hook to alter the information that defines an
* item type. All properties that are available in
- * hook_search_api_item_type_info() can be altered here.
+ * hook_search_api_item_type_info() can be altered here, with the addition of
+ * the "module" key specifying the module that originally defined the type.
*
* @param array $infos
* The item type info array, keyed by type identifier.
@@ -190,6 +193,8 @@ function hook_search_api_data_type_info_alter(array &$infos) {
}
/**
+ * Define available data alterations.
+ *
* Registers one or more callbacks that can be called at index time to add
* additional data to the indexed items (e.g. comments or attachments to nodes),
* alter the data in other forms or remove items from the array.
@@ -224,6 +229,21 @@ function hook_search_api_alter_callback_info() {
return $callbacks;
}
+/**
+ * Alter the available data alterations.
+ *
+ * @param array $callbacks
+ * The callback information to be altered, keyed by callback IDs.
+ *
+ * @see hook_search_api_alter_callback_info()
+ */
+function hook_search_api_alter_callback_info_alter(array &$callbacks) {
+ if (!empty($callbacks['example_random_alter'])) {
+ $callbacks['example_random_alter']['name'] = t('Even more random alteration');
+ $callbacks['example_random_alter']['class'] = 'ExampleUltraRandomAlter';
+ }
+}
+
/**
* Registers one or more processors. These are classes implementing the
* SearchApiProcessorInterface interface which can be used at index and search
@@ -259,6 +279,20 @@ function hook_search_api_processor_info() {
return $callbacks;
}
+/**
+ * Alter the available processors.
+ *
+ * @param array $processors
+ * The processor information to be altered, keyed by processor IDs.
+ *
+ * @see hook_search_api_processor_info()
+ */
+function hook_search_api_processor_info_alter(array &$processors) {
+ if (!empty($processors['example_processor'])) {
+ $processors['example_processor']['weight'] = -20;
+ }
+}
+
/**
* Allows you to log or alter the items that are indexed.
*
@@ -301,13 +335,33 @@ function hook_search_api_items_indexed(SearchApiIndex $index, array $item_ids) {
* Lets modules alter a search query before executing it.
*
* @param SearchApiQueryInterface $query
- * The SearchApiQueryInterface object representing the search query.
+ * The search query being executed.
*/
function hook_search_api_query_alter(SearchApiQueryInterface $query) {
// Exclude entities with ID 0. (Assume the ID field is always indexed.)
if ($query->getIndex()->getEntityType()) {
$info = entity_get_info($query->getIndex()->getEntityType());
- $query->condition($info['entity keys']['id'], 0, '!=');
+ $query->condition($info['entity keys']['id'], 0, '<>');
+ }
+}
+
+/**
+ * Alter the search results before they are returned.
+ *
+ * @param array $results
+ * The results returned by the server, which may be altered. The data
+ * structure is the same as returned by SearchApiQueryInterface::execute().
+ * @param SearchApiQueryInterface $query
+ * The search query that was executed.
+ */
+function hook_search_api_results_alter(array &$results, SearchApiQueryInterface $query) {
+ if ($query->getOption('search id') == 'search_api_views:my_search_view:page') {
+ // Log the number of results.
+ $vars = array(
+ '@keys' => $query->getOriginalKeys(),
+ '@num' => $results['result count'],
+ );
+ watchdog('my_module', 'Search view with query "@keys" had @num results.', $vars, WATCHDOG_DEBUG);
}
}
@@ -530,15 +584,15 @@ function hook_default_search_api_index_alter(array &$defaults) {
* This function will be called for fields of the specific data type to convert
* all individual values of the field to the correct format.
*
- * @param $value
+ * @param mixed $value
* The raw, single value, as extracted from an entity wrapper.
- * @param $original_type
+ * @param string $original_type
* The original Entity API type of the value.
- * @param $type
+ * @param string $type
* The custom data type to which the value should be converted. Can be ignored
* if the callback is only used for a single data type.
*
- * @return
+ * @return mixed|null
* The converted value, if a conversion could be executed. NULL otherwise.
*
* @see hook_search_api_data_type_info()
diff --git a/search_api.drush.inc b/search_api.drush.inc
index 8867995d..61957b9a 100644
--- a/search_api.drush.inc
+++ b/search_api.drush.inc
@@ -22,6 +22,32 @@ function search_api_drush_command() {
'aliases' => array('sapi-l'),
);
+ $items['search-api-enable'] = array(
+ 'description' => 'Enable one or all disabled search_api indexes.',
+ 'examples' => array(
+ 'drush searchapi-enable' => dt('Enable all disabled indexes.'),
+ 'drush sapi-en' => dt('Alias to enable all disabled indexes.'),
+ 'drush sapi-en 1' => dt('Enable index with the ID !id.', array('!id' => 1)),
+ ),
+ 'arguments' => array(
+ 'index_id' => dt('The numeric ID or machine name of an index to enable.'),
+ ),
+ 'aliases' => array('sapi-en'),
+ );
+
+ $items['search-api-disable'] = array(
+ 'description' => 'Disable one or all enabled search_api indexes.',
+ 'examples' => array(
+ 'drush searchapi-disable' => dt('Disable all enabled indexes.'),
+ 'drush sapi-dis' => dt('Alias to disable all enabled indexes.'),
+ 'drush sapi-dis 1' => dt('Disable index with the ID !id.', array('!id' => 1)),
+ ),
+ 'arguments' => array(
+ 'index_id' => dt('The numeric ID or machine name of an index to disable.'),
+ ),
+ 'aliases' => array('sapi-dis'),
+ );
+
$items['search-api-status'] = array(
'description' => 'Show the status of one or all search indexes.',
'examples' => array(
@@ -45,9 +71,10 @@ function search_api_drush_command() {
'drush sapi-i default_node_index' => dt('Index items for the index with the machine name !name.', array('!name' => 'default_node_index')),
'drush sapi-i 1 100' => dt("Index a maximum number of !limit items (index's cron batch size items per batch run) for the index with the ID !id.", array('!limit' => 100, '!id' => 1)),
'drush sapi-i 1 100 10' => dt("Index a maximum number of !limit items (!batch_size items per batch run) for the index with the ID !id.", array('!limit' => 100, '!batch_size' => 10, '!id' => 1)),
+ 'drush sapi-i 0 0 100' => dt("Index all items of all indexes with !batch_size items per batch run.", array('!batch_size' => 100)),
),
'arguments' => array(
- 'index_id' => dt('The numeric ID or machine name of an index.'),
+ 'index_id' => dt('The numeric ID or machine name of an index. Set to 0 to index all indexes. Defaults to 0 (index all).'),
'limit' => dt("The number of items to index (index's cron batch size items per run). Set to 0 to index all items. Defaults to 0 (index all)."),
'batch_size' => dt("The number of items to index per batch run. Set to 0 to index all items at once. Defaults to the index's cron batch size."),
),
@@ -73,8 +100,8 @@ function search_api_drush_command() {
'examples' => array(
'drush searchapi-clear' => dt('Clear all search indexes.'),
'drush sapi-c' => dt('Alias to clear all search indexes.'),
- 'drush sapi-r 1' => dt('Clear the search index with the ID !id.', array('!id' => 1)),
- 'drush sapi-r default_node_index' => dt('Clear the search index with the machine name !name.', array('!name' => 'default_node_index')),
+ 'drush sapi-c 1' => dt('Clear the search index with the ID !id.', array('!id' => 1)),
+ 'drush sapi-c default_node_index' => dt('Clear the search index with the machine name !name.', array('!name' => 'default_node_index')),
),
'arguments' => array(
'index_id' => dt('The numeric ID or machine name of an index.'),
@@ -82,6 +109,52 @@ function search_api_drush_command() {
'aliases' => array('sapi-c'),
);
+ $items['search-api-set-index-server'] = array(
+ 'description' => 'Set the search server used by a given index.',
+ 'examples' => array(
+ 'drush search-api-set-index-server default_node_index my_solr_server' => dt('Set the !index index to use the !server server.', array('!index' => 'default_node_index', '!server' => 'my_solr_server')),
+ 'drush sapi-sis default_node_index my_solr_server' => dt('Alias to set the !index index to use the !server server.', array('!index' => 'default_node_index', '!server' => 'my_solr_server')),
+ ),
+ 'arguments' => array(
+ 'index_id' => dt('The numeric ID or machine name of an index.'),
+ 'server_id' => dt('The numeric ID or machine name of a server to set on the index.'),
+ ),
+ 'aliases' => array('sapi-sis'),
+ );
+
+ $items['search-api-server-list'] = array(
+ 'description' => 'List all search servers.',
+ 'examples' => array(
+ 'drush search-api-server-list' => dt('List all search servers.'),
+ 'drush sapi-sl' => dt('Alias to list all search servers.'),
+ ),
+ 'aliases' => array('sapi-sl'),
+ );
+
+ $items['search-api-server-enable'] = array(
+ 'description' => 'Enable a search server.',
+ 'examples' => array(
+ 'drush search-api-server-e my_solr_server' => dt('Enable the !server search server.', array('!server' => 'my_solr_server')),
+ 'drush sapi-se my_solr_server' => dt('Alias to enable the !server search server.', array('!server' => 'my_solr_server')),
+ ),
+ 'arguments' => array(
+ 'server_id' => dt('The numeric ID or machine name of a search server to enable.'),
+ ),
+ 'aliases' => array('sapi-se'),
+ );
+
+ $items['search-api-server-disable'] = array(
+ 'description' => 'Disable a search server.',
+ 'examples' => array(
+ 'drush search-api-server-disable' => dt('Disable the !server search server.', array('!server' => 'my_solr_server')),
+ 'drush sapi-sd' => dt('Alias to disable the !server search server.', array('!server' => 'my_solr_server')),
+ ),
+ 'arguments' => array(
+ 'server_id' => dt('The numeric ID or machine name of a search server to disable.'),
+ ),
+ 'aliases' => array('sapi-sd'),
+ );
+
return $items;
}
@@ -111,15 +184,21 @@ function drush_search_api_list() {
foreach ($indexes as $index) {
$type = search_api_get_item_type_info($index->item_type);
$type = isset($type['name']) ? $type['name'] : $index->item_type;
- $server = $index->server();
- $server = $server ? $server->name : '(' . t('none') . ')';
+ try {
+ $server = $index->server();
+ $server = $server ? $server->name : '(' . dt('none') . ')';
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ $server = '(' . dt('unknown: !server', array('server' => $index->server)) . ')';
+ }
$row = array(
$index->id,
$index->name,
$index->machine_name,
$server,
$type,
- $index->enabled ? t('enabled') : t('disabled'),
+ $index->enabled ? dt('enabled') : dt('disabled'),
$index->options['cron_limit'],
);
$rows[] = $row;
@@ -127,6 +206,79 @@ function drush_search_api_list() {
drush_print_table($rows);
}
+/**
+ * Enable index(es).
+ *
+ * @param string|integer $index_id
+ * The index name or id which should be enabled.
+ */
+function drush_search_api_enable($index_id = NULL) {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+ $indexes = search_api_drush_get_index($index_id);
+ if (empty($indexes)) {
+ return;
+ }
+ foreach ($indexes as $index) {
+ $vars = array('!index' => $index->name);
+ if (!$index->enabled) {
+ drush_log(dt("Enabling index !index and queueing items for indexing.", $vars), 'notice');
+ $success = FALSE;
+ try {
+ if ($success = search_api_index_enable($index->id)) {
+ drush_log(dt("The index !index was successfully enabled.", $vars), 'ok');
+ }
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ }
+ if (!$success) {
+ drush_log(dt("Error enabling index !index.", $vars), 'error');
+ }
+ }
+ else {
+ drush_log(dt("The index !index is already enabled.", $vars), 'error');
+ }
+ }
+}
+
+/**
+ * Disable index(es).
+ *
+ * @param string|integer $index_id
+ * The index name or id which should be disabled.
+ */
+function drush_search_api_disable($index_id = NULL) {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+ $indexes = search_api_drush_get_index($index_id);
+ if (empty($indexes)) {
+ return;
+ }
+ foreach ($indexes as $index) {
+ $vars = array('!index' => $index->name);
+ if ($index->enabled) {
+ $success = FALSE;
+ try {
+ if ($success = search_api_index_disable($index->id)) {
+ drush_log(dt("The index !index was successfully disabled.", $vars), 'ok');
+ }
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ }
+ if (!$success) {
+ drush_log(dt("Error disabling index !index.", $vars), 'error');
+ }
+ }
+ else {
+ drush_log(dt("The index !index is already disabled.", $vars), 'error');
+ }
+ }
+}
+
/**
* Display index status.
*/
@@ -175,44 +327,77 @@ function drush_search_api_index($index_id = NULL, $limit = NULL, $batch_size = N
if (search_api_drush_static(__FUNCTION__)) {
return;
}
+ $index_id = !empty($index_id) ? $index_id : NULL;
$indexes = search_api_drush_get_index($index_id);
if (empty($indexes)) {
return;
}
+
+ $process_batch = FALSE;
foreach ($indexes as $index) {
- // Get the number of remaing items to index.
- $datasource = $index->datasource();
- $index_status = $datasource->getIndexStatus($index);
- $remaining = $index_status['total'] - $index_status['indexed'];
- if ($remaining <= 0) {
- drush_log(dt("The index !index is up to date.", array('!index' => $index->name)), 'ok');
- continue;
+ if (_drush_search_api_batch_indexing_create($index, $limit, $batch_size)) {
+ $process_batch = TRUE;
}
+ }
- // Get the number of items to index per batch run.
- if (!isset($batch_size)) {
- $batch_size = empty($index->options['cron_limit']) ? SEARCH_API_DEFAULT_CRON_LIMIT : $index->options['cron_limit'];
- }
- elseif ($batch_size <= 0) {
- $batch_size = $remaining;
- }
+ if ($process_batch) {
+ drush_backend_batch_process();
+ }
+}
- // Get the number items to index.
- if (!isset($limit) || !is_int($limit += 0) || $limit <= 0) {
- $limit = $remaining;
- }
+/**
+ * Creates and sets a batch for indexing items for a particular index.
+ *
+ * @param SearchApiIndex $index
+ * The index for which items should be indexed.
+ * @param int $limit
+ * (optional) The maximum number of items to index, or NULL to index all
+ * items.
+ * @param int $batch_size
+ * (optional) The number of items to index per batch, or NULL to index all
+ * items at once.
+ *
+ * @return bool
+ * TRUE if batch was created, FALSE otherwise.
+ */
+function _drush_search_api_batch_indexing_create(SearchApiIndex $index, $limit = NULL, $batch_size = NULL) {
+ // Get the number of remaining items to index.
+ try {
+ $datasource = $index->datasource();
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ return FALSE;
+ }
+ $index_status = $datasource->getIndexStatus($index);
+ $remaining = $index_status['total'] - $index_status['indexed'];
+ if ($remaining <= 0) {
+ drush_log(dt("The index !index is up to date.", array('!index' => $index->name)), 'ok');
+ return FALSE;
+ }
- drush_log(dt("Indexing a maximum number of !limit items (!batch_size items per batch run) for the index !index.", array('!index' => $index->name, '!limit' => $limit, '!batch_size' => $batch_size)), 'ok');
+ // Get the number of items to index per batch run.
+ if (!isset($batch_size)) {
+ $batch_size = empty($index->options['cron_limit']) ? SEARCH_API_DEFAULT_CRON_LIMIT : $index->options['cron_limit'];
+ }
+ elseif ($batch_size <= 0) {
+ $batch_size = $remaining;
+ }
- // Create the batch.
- if (!_search_api_batch_indexing_create($index, $batch_size, $limit, $remaining, TRUE)) {
- drush_log(dt("Couldn't create a batch, please check the batch size and limit parameters."), 'error');
- }
- else {
- // Launch the batch process.
- drush_backend_batch_process();
- }
+ // Get the total number of items to index.
+ if (!isset($limit) || !is_int($limit += 0) || $limit <= 0) {
+ $limit = $remaining;
}
+
+ drush_log(dt("Indexing a maximum number of !limit items (!batch_size items per batch run) for the index !index.", array('!index' => $index->name, '!limit' => $limit, '!batch_size' => $batch_size)), 'ok');
+
+ // Create the batch.
+ if (!_search_api_batch_indexing_create($index, $batch_size, $limit, $remaining, TRUE)) {
+ drush_log(dt("Couldn't create a batch, please check the batch size and limit parameters."), 'error');
+ return FALSE;
+ }
+
+ return TRUE;
}
/**
@@ -282,12 +467,54 @@ function drush_search_api_clear($index_id = NULL) {
}
/**
- * Helper function to return an index or all indexes as an array.
+ * Set the server for a given index.
+ */
+function drush_search_api_set_index_server($index_id = NULL, $server_id = NULL) {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+ // Make sure we have parameters to work with.
+ if (empty($index_id) || empty($server_id)) {
+ drush_log(dt('You must specify both an index and server.'), 'error');
+ return;
+ }
+ // Fetch current index and server data.
+ $indexes = search_api_drush_get_index($index_id);
+ $servers = search_api_drush_get_server($server_id);
+ if (empty($indexes) || empty($servers)) {
+ // If the specified index or server can't be found, just return. An
+ // appropriate error message should have been printed already.
+ return;
+ }
+ // Set the new server on the index.
+ $success = FALSE;
+ $index = reset($indexes);
+ $server = reset($servers);
+ try {
+ $success = $index->update(array('server' => $server->machine_name));
+ }
+ catch (SearchApiException $e) {
+ drush_log($e->getMessage(), 'error');
+ }
+ if ($success === FALSE) {
+ drush_log(dt('There was an error setting index !index to use server !server.', array('!index' => $index->name, '!server' => $server->name)), 'error');
+ }
+ elseif (!$success) {
+ drush_log(dt('Index !index was already using server !server.', array('!index' => $index->name, '!server' => $server->name)), 'ok');
+ }
+ else {
+ drush_log(dt('Index !index has been set to use server !server and items have been queued for indexing.', array('!index' => $index->name, '!server' => $server->name)), 'ok');
+ }
+}
+
+/**
+ * Returns an index or all indexes as an array.
*
- * @param $index_id
- * (optional) The provided index id.
+ * @param string|int|null $index_id
+ * (optional) The ID or machine name of the index to load. Defaults to
+ * loading all available indexes.
*
- * @return
+ * @return SearchApiIndex[]
* An array of indexes.
*/
function search_api_drush_get_index($index_id = NULL) {
@@ -302,7 +529,35 @@ function search_api_drush_get_index($index_id = NULL) {
}
/**
- * Static lookup to prevent Drush 4 from running twice.
+ * Returns a server or all servers as an array.
+ *
+ * @param string|int|null $server_id
+ * (optional) The ID or machine name of the server to load. Defaults to
+ * loading all available servers.
+ *
+ * @return SearchApiServer[]
+ * An array of servers.
+ */
+function search_api_drush_get_server($server_id = NULL) {
+ $ids = isset($server_id) ? array($server_id) : FALSE;
+ $servers = search_api_server_load_multiple($ids);
+ if (empty($servers)) {
+ drush_set_error(dt('Invalid server_id or no servers present.'));
+ drush_print();
+ drush_search_api_server_list();
+ }
+ return $servers;
+}
+
+/**
+ * Does a static lookup to prevent Drush 4 from running twice.
+ *
+ * @param string $function
+ * The Drush function being called.
+ *
+ * @return bool
+ * TRUE if the function was already called in this Drush execution, FALSE
+ * otherwise.
*
* @see http://drupal.org/node/704848
*/
@@ -312,4 +567,77 @@ function search_api_drush_static($function) {
return TRUE;
}
$index[$function] = TRUE;
+ return FALSE;
+}
+
+/**
+ * Lists all search servers.
+ */
+function drush_search_api_server_list() {
+ if (search_api_drush_static(__FUNCTION__)) {
+ return;
+ }
+ $servers = search_api_server_load_multiple(FALSE);
+ if (empty($servers)) {
+ drush_print(dt('There are no servers present.'));
+ return;
+ }
+ $rows[] = array(
+ dt('Machine name'),
+ dt('Name'),
+ dt('Status'),
+ );
+ foreach ($servers as $server) {
+ $row = array(
+ $server->machine_name,
+ $server->name,
+ $server->enabled ? dt('enabled') : dt('disabled'),
+ );
+ $rows[] = $row;
+ }
+ drush_print_table($rows);
+}
+
+/**
+ * Enables a search server.
+ *
+ * @param int|string $server_id
+ * The numeric ID or machine name of the server to enable.
+ */
+function drush_search_api_server_enable($server_id = NULL) {
+ if (!isset($server_id)) {
+ drush_print(dt('Please provide a valid server id.'));
+ return;
+ }
+ $server = search_api_server_load($server_id);
+ if (empty($server)) {
+ drush_print(dt('The server was not able to load.'));
+ return;
+ }
+ else {
+ $server->update(array('enabled' => 1));
+ drush_print(dt('The server was enabled successfully.'));
+ }
+}
+
+/**
+ * Disables a search server.
+ *
+ * @param int|string $server_id
+ * The numeric ID or machine name of the server to disable.
+ */
+function drush_search_api_server_disable($server_id = NULL) {
+ if (!isset($server_id)) {
+ drush_print(dt('Please provide a valid server id.'));
+ return;
+ }
+ $server = search_api_server_load($server_id);
+ if (empty($server)) {
+ drush_print(dt('The server was not able to load.'));
+ return;
+ }
+ else {
+ $server->update(array('enabled' => 0));
+ drush_print(dt('The server was disabled successfully.'));
+ }
}
diff --git a/search_api.info b/search_api.info
index c03de9d0..0e14ed88 100644
--- a/search_api.info
+++ b/search_api.info
@@ -1,5 +1,5 @@
name = Search API
-description = "Provides a generic API for modules offering search capabilites."
+description = "Provides a generic API for modules offering search capabilities."
dependencies[] = entity
core = 7.x
package = Search
@@ -11,19 +11,24 @@ files[] = includes/callback_add_hierarchy.inc
files[] = includes/callback_add_url.inc
files[] = includes/callback_add_viewed_entity.inc
files[] = includes/callback_bundle_filter.inc
+files[] = includes/callback_comment_access.inc
files[] = includes/callback_language_control.inc
files[] = includes/callback_node_access.inc
files[] = includes/callback_node_status.inc
files[] = includes/callback_role_filter.inc
+files[] = includes/callback_user_content.inc
+files[] = includes/callback_user_status.inc
files[] = includes/datasource.inc
files[] = includes/datasource_entity.inc
files[] = includes/datasource_external.inc
+files[] = includes/datasource_multiple.inc
files[] = includes/exception.inc
files[] = includes/index_entity.inc
files[] = includes/processor.inc
files[] = includes/processor_highlight.inc
files[] = includes/processor_html_filter.inc
files[] = includes/processor_ignore_case.inc
+files[] = includes/processor_stemmer.inc
files[] = includes/processor_stopwords.inc
files[] = includes/processor_tokenizer.inc
files[] = includes/processor_transliteration.inc
diff --git a/search_api.install b/search_api.install
index 09e8bb58..5dc26895 100644
--- a/search_api.install
+++ b/search_api.install
@@ -191,6 +191,76 @@ function search_api_schema() {
'primary key' => array('item_id', 'index_id'),
);
+ $schema['search_api_item_string_id'] = array(
+ 'description' => 'Stores the items which should be indexed for each index, and their status. Used only for items with string IDs.',
+ 'fields' => array(
+ 'item_id' => array(
+ 'description' => "The item's ID.",
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ ),
+ 'index_id' => array(
+ 'description' => 'The {search_api_index}.id this item belongs to.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'changed' => array(
+ 'description' => 'Either a flag or a timestamp to indicate if or when the item was changed since it was last indexed.',
+ 'type' => 'int',
+ 'size' => 'big',
+ 'not null' => TRUE,
+ 'default' => 1,
+ ),
+ ),
+ 'indexes' => array(
+ 'indexing' => array('index_id', 'changed'),
+ ),
+ 'primary key' => array('item_id', 'index_id'),
+ );
+
+ $schema['search_api_task'] = array(
+ 'description' => 'Stores pending tasks for servers.',
+ 'fields' => array(
+ 'id' => array(
+ 'description' => 'An integer identifying this task.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'server_id' => array(
+ 'description' => 'The {search_api_server}.machine_name for which this task should be executed.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ ),
+ 'type' => array(
+ 'description' => 'A keyword identifying the type of task that should be executed.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ ),
+ 'index_id' => array(
+ 'description' => 'The {search_api_index}.machine_name to which this task pertains, if applicable for this type.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => FALSE,
+ ),
+ 'data' => array(
+ 'description' => 'Some data needed for the task, might be optional depending on the type.',
+ 'type' => 'text',
+ 'size' => 'medium',
+ 'serialize' => TRUE,
+ 'not null' => FALSE,
+ ),
+ ),
+ 'indexes' => array(
+ 'server' => array('server_id'),
+ ),
+ 'primary key' => array('id'),
+ );
+
return $schema;
}
@@ -292,7 +362,7 @@ function search_api_install() {
),
);
search_api_index_insert($values);
- drupal_set_message('The Search API module was installed. A new default node index was created.');
+ drupal_set_message(t('The Search API module was installed. A new default node index was created.'));
}
/**
@@ -308,8 +378,13 @@ function search_api_enable() {
}
}
foreach ($types as $type => $indexes) {
- $controller = search_api_get_datasource_controller($type);
- $controller->startTracking($indexes);
+ try {
+ $controller = search_api_get_datasource_controller($type);
+ $controller->startTracking($indexes);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
}
}
@@ -322,8 +397,13 @@ function search_api_disable() {
$types[$index->item_type][] = $index;
}
foreach ($types as $type => $indexes) {
- $controller = search_api_get_datasource_controller($type);
- $controller->stopTracking($indexes);
+ try {
+ $controller = search_api_get_datasource_controller($type);
+ $controller->stopTracking($indexes);
+ }
+ catch (SearchApiException $e) {
+ // Modules defining entity or item types might have been disabled. Ignore.
+ }
}
}
@@ -331,7 +411,6 @@ function search_api_disable() {
* Implements hook_uninstall().
*/
function search_api_uninstall() {
- variable_del('search_api_tasks');
variable_del('search_api_index_worker_callback_runtime');
}
@@ -606,7 +685,7 @@ function search_api_update_7106() {
$callbacks['search_api_alter_add_aggregation'] = $callbacks['search_api_alter_add_fulltext'];
unset($callbacks['search_api_alter_add_fulltext']);
if (!empty($callbacks['search_api_alter_add_aggregation']['settings']['fields'])) {
- foreach ($callbacks['search_api_alter_add_aggregation']['settings']['fields'] as $field => &$info) {
+ foreach ($callbacks['search_api_alter_add_aggregation']['settings']['fields'] as &$info) {
if (!isset($info['type'])) {
$info['type'] = 'fulltext';
}
@@ -807,3 +886,189 @@ function search_api_update_7114() {
}
}
}
+
+/**
+ * Switch to indexing without the use of a cron queue.
+ */
+function search_api_update_7115() {
+ variable_del('search_api_batch_per_cron');
+ DrupalQueue::get('search_api_indexing_queue')->deleteQueue();
+ db_update('search_api_item')
+ ->fields(array(
+ 'changed' => 1,
+ ))
+ ->condition('changed', 0, '<')
+ ->execute();
+}
+
+/**
+ * Transfers the tasks for disabled servers to a separate database table.
+ */
+function search_api_update_7116() {
+ // Create table.
+ $table = array(
+ 'description' => 'Stores pending tasks for servers.',
+ 'fields' => array(
+ 'id' => array(
+ 'description' => 'An integer identifying this task.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'server_id' => array(
+ 'description' => 'The {search_api_server}.machine_name for which this task should be executed.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ ),
+ 'type' => array(
+ 'description' => 'A keyword identifying the type of task that should be executed.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ ),
+ 'index_id' => array(
+ 'description' => 'The {search_api_index}.machine_name to which this task pertains, if applicable for this type.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => FALSE,
+ ),
+ 'data' => array(
+ 'description' => 'Some data needed for the task, might be optional depending on the type.',
+ 'type' => 'text',
+ 'size' => 'medium',
+ 'serialize' => TRUE,
+ 'not null' => FALSE,
+ ),
+ ),
+ 'indexes' => array(
+ 'server' => array('server_id'),
+ ),
+ 'primary key' => array('id'),
+ );
+ db_create_table('search_api_task', $table);
+
+ // Collect old tasks.
+ $tasks = array();
+ foreach (variable_get('search_api_tasks', array()) as $server => $indexes) {
+ foreach ($indexes as $index => $old_tasks) {
+ if (in_array('clear all', $old_tasks)) {
+ $tasks[] = array(
+ 'server_id' => $server,
+ 'type' => 'deleteItems',
+ );
+ }
+ if (in_array('remove', $old_tasks)) {
+ $tasks[] = array(
+ 'server_id' => $server,
+ 'type' => 'removeIndex',
+ 'index_id' => $index,
+ );
+ }
+ }
+ }
+ variable_del('search_api_tasks');
+
+ $select = db_select('search_api_index', 'i')
+ ->fields('i', array('machine_name', 'server'));
+ $select->innerJoin('search_api_server', 's', 'i.server = s.machine_name AND s.enabled = 0');
+ $index_ids = array();
+ foreach ($select->execute() as $index) {
+ $index_ids[] = $index->machine_name;
+ $tasks[] = array(
+ 'server_id' => $index->server,
+ 'type' => 'removeIndex',
+ 'index_id' => $index->machine_name,
+ );
+ }
+ if ($index_ids) {
+ db_update('search_api_index')
+ ->fields(array(
+ 'enabled' => 0,
+ 'server' => NULL,
+ ))
+ ->condition('machine_name', $index_ids)
+ ->execute();
+ }
+
+ if ($tasks) {
+ $insert = db_insert('search_api_task')
+ ->fields(array('server_id', 'type', 'index_id', 'data'));
+ foreach ($tasks as $task) {
+ $task += array(
+ 'index_id' => NULL,
+ 'data' => NULL,
+ );
+ $insert->values($task);
+ }
+ $insert->execute();
+ }
+}
+
+/**
+ * Checks the database for illegal {search_api_index}.server values.
+ */
+function search_api_update_7117() {
+ $servers = db_select('search_api_server', 's')
+ ->fields('s', array('machine_name'))
+ ->condition('enabled', 1);
+ $indexes = db_select('search_api_index', 'i')
+ ->fields('i', array('id'))
+ ->condition('server', $servers, 'NOT IN')
+ ->execute()
+ ->fetchCol();
+ if ($indexes) {
+ db_delete('search_api_item')
+ ->condition('index_id', $indexes)
+ ->execute();
+ db_update('search_api_index')
+ ->fields(array(
+ 'server' => NULL,
+ 'enabled' => 0,
+ ))
+ ->condition('id', $indexes)
+ ->execute();
+ }
+}
+
+/**
+ * Adds the {search_api_item_string_id} table for items with string IDs.
+ */
+function search_api_update_7118() {
+ // Some users have reported that the table already existed for them, for
+ // whatever reason. Therefore, just bail if the table already exists, assuming
+ // it already looks as expected.
+ if (db_table_exists('search_api_item_string_id')) {
+ return;
+ }
+
+ $table = array(
+ 'description' => 'Stores the items which should be indexed for each index, and their status. Used only for items with string IDs.',
+ 'fields' => array(
+ 'item_id' => array(
+ 'description' => "The item's ID.",
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ ),
+ 'index_id' => array(
+ 'description' => 'The {search_api_index}.id this item belongs to.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'changed' => array(
+ 'description' => 'Either a flag or a timestamp to indicate if or when the item was changed since it was last indexed.',
+ 'type' => 'int',
+ 'size' => 'big',
+ 'not null' => TRUE,
+ 'default' => 1,
+ ),
+ ),
+ 'indexes' => array(
+ 'indexing' => array('index_id', 'changed'),
+ ),
+ 'primary key' => array('item_id', 'index_id'),
+ );
+ db_create_table('search_api_item_string_id', $table);
+}
diff --git a/search_api.module b/search_api.module
index 0c2e0249..4285193e 100644
--- a/search_api.module
+++ b/search_api.module
@@ -1,5 +1,10 @@
'View',
- 'weight' => -10,
'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
);
$items[$pre . '/server/%search_api_server/edit'] = array(
'title' => 'Edit',
@@ -65,6 +70,19 @@ function search_api_menu() {
'file' => 'search_api.admin.inc',
'weight' => -1,
'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+ );
+ $items[$pre . '/server/%search_api_server/disable'] = array(
+ 'title' => 'Disable',
+ 'description' => 'Disable index.',
+ 'page callback' => 'search_api_admin_server_view',
+ 'page arguments' => array(5, 6),
+ 'access callback' => 'search_api_access_disable_page',
+ 'access arguments' => array(5),
+ 'file' => 'search_api.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'weight' => 8,
);
$items[$pre . '/server/%search_api_server/delete'] = array(
'title' => 'Delete',
@@ -77,6 +95,8 @@ function search_api_menu() {
'access arguments' => array(5),
'file' => 'search_api.admin.inc',
'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'weight' => 10,
);
$items[$pre . '/index/%search_api_index'] = array(
'title' => 'View index',
@@ -93,27 +113,16 @@ function search_api_menu() {
'type' => MENU_DEFAULT_LOCAL_TASK,
'weight' => -10,
);
- $items[$pre . '/index/%search_api_index/status'] = array(
- 'title' => 'Status',
- 'description' => 'Display and work on index status.',
- 'page callback' => 'drupal_get_form',
- 'page arguments' => array('search_api_admin_index_status_form', 5),
- 'access arguments' => array('administer search_api'),
- 'file' => 'search_api.admin.inc',
- 'weight' => -8,
- 'type' => MENU_LOCAL_TASK,
- 'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
- );
$items[$pre . '/index/%search_api_index/edit'] = array(
- 'title' => 'Settings',
+ 'title' => 'Edit',
'description' => 'Edit index settings.',
'page callback' => 'drupal_get_form',
'page arguments' => array('search_api_admin_index_edit', 5),
'access arguments' => array('administer search_api'),
'file' => 'search_api.admin.inc',
- 'weight' => -6,
'type' => MENU_LOCAL_TASK,
'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+ 'weight' => -6,
);
$items[$pre . '/index/%search_api_index/fields'] = array(
'title' => 'Fields',
@@ -122,20 +131,32 @@ function search_api_menu() {
'page arguments' => array('search_api_admin_index_fields', 5),
'access arguments' => array('administer search_api'),
'file' => 'search_api.admin.inc',
- 'weight' => -4,
'type' => MENU_LOCAL_TASK,
'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+ 'weight' => -4,
);
$items[$pre . '/index/%search_api_index/workflow'] = array(
- 'title' => 'Workflow',
- 'description' => 'Edit index workflow.',
+ 'title' => 'Filters',
+ 'description' => 'Edit indexing workflow.',
'page callback' => 'drupal_get_form',
'page arguments' => array('search_api_admin_index_workflow', 5),
'access arguments' => array('administer search_api'),
'file' => 'search_api.admin.inc',
- 'weight' => -2,
'type' => MENU_LOCAL_TASK,
'context' => MENU_CONTEXT_INLINE | MENU_CONTEXT_PAGE,
+ 'weight' => -2,
+ );
+ $items[$pre . '/index/%search_api_index/disable'] = array(
+ 'title' => 'Disable',
+ 'description' => 'Disable index.',
+ 'page callback' => 'search_api_admin_index_view',
+ 'page arguments' => array(5, 6),
+ 'access callback' => 'search_api_access_disable_page',
+ 'access arguments' => array(5),
+ 'file' => 'search_api.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'weight' => 8,
);
$items[$pre . '/index/%search_api_index/delete'] = array(
'title' => 'Delete',
@@ -148,11 +169,37 @@ function search_api_menu() {
'access arguments' => array(5),
'file' => 'search_api.admin.inc',
'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'weight' => 10,
);
return $items;
}
+/**
+ * Implements hook_help().
+ */
+function search_api_help($path) {
+ switch ($path) {
+ case 'admin/help#search_api':
+ $classes = array();
+ foreach (search_api_get_service_info() as $id => $info) {
+ $id = drupal_clean_css_identifier($id);
+ $name = check_plain($info['name']);
+ $description = isset($info['description']) ? $info['description'] : '';
+ $classes[] = "
' . t('A search server and search index are used to execute searches. Several indexes can exist per server. You need at least one server and one index to create searches on your site.') . '
';
+ }
+}
+
/**
* Implements hook_hook_info().
*/
@@ -169,10 +216,13 @@ function search_api_hook_info() {
'search_api_data_type_info' => $hook_info,
'search_api_data_type_info_alter' => $hook_info,
'search_api_alter_callback_info' => $hook_info,
+ 'search_api_alter_callback_info_alter' => $hook_info,
'search_api_processor_info' => $hook_info,
+ 'search_api_processor_info_alter' => $hook_info,
'search_api_index_items_alter' => $hook_info,
'search_api_items_indexed' => $hook_info,
'search_api_query_alter' => $hook_info,
+ 'search_api_results_alter' => $hook_info,
'search_api_server_load' => $hook_info,
'search_api_server_insert' => $hook_info,
'search_api_server_update' => $hook_info,
@@ -193,6 +243,12 @@ function search_api_hook_info() {
* Implements hook_theme().
*/
function search_api_theme() {
+ $themes['search_api_dropbutton'] = array(
+ 'variables' => array(
+ 'links' => array(),
+ ),
+ 'file' => 'search_api.admin.inc',
+ );
$themes['search_api_server'] = array(
'variables' => array(
'id' => NULL,
@@ -200,10 +256,13 @@ function search_api_theme() {
'machine_name' => '',
'description' => NULL,
'enabled' => NULL,
+ 'class_id' => NULL,
'class_name' => NULL,
'class_description' => NULL,
+ 'indexes' => array(),
'options' => array(),
'status' => ENTITY_CUSTOM,
+ 'extra' => array(),
),
'file' => 'search_api.admin.inc',
);
@@ -214,11 +273,13 @@ function search_api_theme() {
'machine_name' => '',
'description' => NULL,
'item_type' => NULL,
+ 'datasource_config' => NULL,
'enabled' => NULL,
'server' => NULL,
'options' => array(),
'fields' => array(),
'indexed_items' => 0,
+ 'on_server' => NULL,
'total_items' => 0,
'status' => ENTITY_CUSTOM,
'read_only' => 0,
@@ -252,53 +313,78 @@ function search_api_permission() {
/**
* Implements hook_cron().
*
- * Will index $options['cron-limit'] items for each enabled index.
+ * This will first execute any pending server tasks. After that, items will
+ * be indexed on all enabled indexes with a non-zero cron limit. Indexing will
+ * run for the time set in the search_api_index_worker_callback_runtime variable
+ * (defaulting to 15 seconds), but will at least index one batch of items on
+ * each index.
+ *
+ * @see search_api_server_tasks_check()
*/
function search_api_cron() {
- $queue = DrupalQueue::get('search_api_indexing_queue');
- foreach (search_api_index_load_multiple(FALSE, array('enabled' => TRUE, 'read_only' => 0)) as $index) {
- $limit = isset($index->options['cron_limit'])
+ // Execute pending server tasks.
+ search_api_server_tasks_check();
+
+ // Load all enabled, not read-only indexes.
+ $conditions = array(
+ 'enabled' => TRUE,
+ 'read_only' => 0
+ );
+ $indexes = search_api_index_load_multiple(FALSE, $conditions);
+ if (!$indexes) {
+ return;
+ }
+ // Remember servers which threw an exception.
+ $ignored_servers = array();
+ // Continue indexing, one batch from each index, until the time is up, but at
+ // least index one batch per index.
+ $end = time() + variable_get('search_api_index_worker_callback_runtime', 15);
+ $first_pass = TRUE;
+ while (TRUE) {
+ if (!$indexes) {
+ break;
+ }
+ foreach ($indexes as $id => $index) {
+ if (!$first_pass && time() >= $end) {
+ break 2;
+ }
+ if (!empty($ignored_servers[$index->server])) {
+ continue;
+ }
+
+ $limit = isset($index->options['cron_limit'])
? $index->options['cron_limit']
: SEARCH_API_DEFAULT_CRON_LIMIT;
- if ($limit) {
- try {
- $task = array('index' => $index->machine_name);
- // Fetch items to index, do not fetch more than the configured amount
- // of batches to be created per cron run to avoid timeouts.
- $ids = search_api_get_items_to_index($index, $limit > 0 ? $limit * variable_get('search_api_batch_per_cron', 10) : -1);
- if (!$ids) {
- continue;
+ $num = 0;
+ if ($limit) {
+ try {
+ $num = search_api_index_items($index, $limit);
+ if ($num) {
+ $variables = array(
+ '@num' => $num,
+ '%name' => $index->name
+ );
+ watchdog('search_api', 'Indexed @num items for index %name.', $variables, WATCHDOG_INFO);
+ }
}
- $batches = $limit > 0 ? array_chunk($ids, $limit, TRUE) : array($ids);
- foreach ($batches as $batch) {
- $task['items'] = $batch;
- $queue->createItem($task);
+ catch (SearchApiException $e) {
+ // Exceptions will probably be caused by the server in most cases.
+ // Therefore, don't index for any index on this server.
+ $ignored_servers[$index->server] = TRUE;
+ $vars['%index'] = $index->name;
+ watchdog_exception('search_api', $e, '%type while trying to index items on %index: !message in %function (line %line of %file).', $vars);
}
- // Mark items as queued so they won't be inserted into the queue again
- // on the next cron run.
- search_api_track_item_queued($index, $ids);
}
- catch (SearchApiException $e) {
- watchdog_exception('search_api', $e);
+ if (!$num) {
+ // Couldn't index any items => stop indexing for this index in this
+ // cron run.
+ unset($indexes[$id]);
}
}
+ $first_pass = FALSE;
}
}
-/**
- * Implements hook_cron_queue_info().
- *
- * Defines a queue for saved searches that should be checked for new items.
- */
-function search_api_cron_queue_info() {
- return array(
- 'search_api_indexing_queue' => array(
- 'worker callback' => '_search_api_indexing_queue_process',
- 'time' => variable_get('search_api_index_worker_callback_runtime', 15),
- ),
- );
-}
-
/**
* Implements hook_entity_info().
*/
@@ -310,6 +396,7 @@ function search_api_entity_info() {
'entity class' => 'SearchApiServer',
'base table' => 'search_api_server',
'uri callback' => 'search_api_server_url',
+ 'access callback' => 'search_api_entity_access',
'module' => 'search_api',
'exportable' => TRUE,
'entity keys' => array(
@@ -325,6 +412,7 @@ function search_api_entity_info() {
'entity class' => 'SearchApiIndex',
'base table' => 'search_api_index',
'uri callback' => 'search_api_index_url',
+ 'access callback' => 'search_api_entity_access',
'module' => 'search_api',
'exportable' => TRUE,
'entity keys' => array(
@@ -383,6 +471,19 @@ function search_api_entity_property_info() {
'description' => t('A flag indicating whether the server is enabled.'),
'schema field' => 'enabled',
),
+ 'status' => array(
+ 'label' => t('Status'),
+ 'type' => 'integer',
+ 'description' => t('Search API server status property'),
+ 'schema field' => 'status',
+ 'options list' => 'search_api_status_options_list',
+ ),
+ 'module' => array(
+ 'label' => t('Module'),
+ 'type' => 'text',
+ 'description' => t('The name of the module from which this server originates.'),
+ 'schema field' => 'module',
+ ),
);
$info['search_api_index']['properties'] = array(
'id' => array(
@@ -444,6 +545,19 @@ function search_api_entity_property_info() {
'description' => t('A flag indicating whether the index is read-only.'),
'schema field' => 'read_only',
),
+ 'status' => array(
+ 'label' => t('Status'),
+ 'type' => 'integer',
+ 'description' => t('Search API index status property'),
+ 'schema field' => 'status',
+ 'options list' => 'search_api_status_options_list',
+ ),
+ 'module' => array(
+ 'label' => t('Module'),
+ 'type' => 'text',
+ 'description' => t('The name of the module from which this index originates.'),
+ 'schema field' => 'module',
+ ),
);
return $info;
@@ -481,50 +595,11 @@ function search_api_search_api_server_update(SearchApiServer $server) {
}
if (!empty($server->original) && $server->enabled != $server->original->enabled) {
if ($server->enabled) {
- // Were there any changes in the server's indexes while it was disabled?
- $tasks = variable_get('search_api_tasks', array());
- if (isset($tasks[$server->machine_name])) {
- foreach ($tasks[$server->machine_name] as $index_id => $index_tasks) {
- $index = search_api_index_load($index_id);
- foreach ($index_tasks as $task) {
- switch ($task) {
- case 'add':
- $server->addIndex($index);
- break;
- case 'clear':
- $server->deleteItems('all', $index);
- break;
- case 'clear all':
- // Would normally be used with a fake index ID of "", since it
- // doesn't matter.
- $server->deleteItems('all');
- break;
- case 'fields':
- if ($server->fieldsUpdated($index)) {
- _search_api_index_reindex($index);
- }
- break;
- case 'remove':
- $server->removeIndex($index ? $index : $index_id);
- break;
- default:
- if (substr($task, 0, 7) == 'delete-') {
- $id = substr($task, 7);
- $server->deleteItems(array($id), $index);
- }
- else {
- watchdog('search_api', t('Unknown task "@task" for server "@name".', array('@task' => $task, '@name' => $server->machine_name)), NULL, WATCHDOG_WARNING);
- }
- }
- }
- }
- unset($tasks[$server->machine_name]);
- variable_set('search_api_tasks', $tasks);
- }
+ search_api_server_tasks_check($server);
}
else {
- foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name, 'enabled' => 1)) as $index) {
- $index->update(array('enabled' => 0));
+ foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+ $index->update(array('enabled' => 0, 'server' => NULL));
}
}
}
@@ -548,9 +623,7 @@ function search_api_search_api_server_delete(SearchApiServer $server) {
$index->update(array('server' => NULL, 'enabled' => FALSE));
}
- $tasks = variable_get('search_api_tasks', array());
- unset($tasks[$server->machine_name]);
- variable_set('search_api_tasks', $tasks);
+ search_api_server_tasks_delete(NULL, $server);
}
/**
@@ -577,8 +650,9 @@ function search_api_search_api_index_insert(SearchApiIndex $index) {
* Implements hook_search_api_index_update().
*/
function search_api_search_api_index_update(SearchApiIndex $index) {
- // Call the datasource update function with the table this module provides.
+ // Call the datasource update function with the tables this module provides.
search_api_index_update_datasource($index, 'search_api_item');
+ search_api_index_update_datasource($index, 'search_api_item_string_id');
// If the server was changed, we have to call the appropriate service class
// hook methods.
@@ -588,30 +662,25 @@ function search_api_search_api_index_update(SearchApiIndex $index) {
$old_server = search_api_server_load($index->original->server);
// The server might have changed because the old one was deleted:
if ($old_server) {
- if ($old_server->enabled) {
- $old_server->removeIndex($index);
- }
- else {
- $tasks = variable_get('search_api_tasks', array());
- // When we add or remove an index, we can ignore all other tasks.
- $tasks[$old_server->machine_name][$index->machine_name] = array('remove');
- variable_set('search_api_tasks', $tasks);
- }
+ $old_server->removeIndex($index);
}
}
if ($index->server) {
- $new_server = $index->server(TRUE);
- // If the server is enabled, we call addIndex(); otherwise, we save the task.
- if ($new_server->enabled) {
+ try {
+ $new_server = $index->server(TRUE);
+ // If the server is enabled, we call addIndex(); otherwise, we save the task.
$new_server->addIndex($index);
}
- else {
- $tasks = variable_get('search_api_tasks', array());
- // When we add or remove an index, we can ignore all other tasks.
- $tasks[$new_server->machine_name][$index->machine_name] = array('add');
- variable_set('search_api_tasks', $tasks);
- unset($new_server);
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ // If the new server doesn't exist, we remove the index from all
+ // servers. Note that saving an entity in its own update hook is usually
+ // a recipe for disaster, but since we are only doing this if a server
+ // is set and remove the server here before saving, it should be safe
+ // enough.
+ $index->server = NULL;
+ $index->save();
}
}
@@ -620,28 +689,17 @@ function search_api_search_api_index_update(SearchApiIndex $index) {
}
// If the fields were changed, call the appropriate service class hook method
- // and re-index the content, if necessary. Also, clear the fields cache.
+ // and re-index the content, if necessary.
$old_fields = $index->original->options + array('fields' => array());
$old_fields = $old_fields['fields'];
$new_fields = $index->options + array('fields' => array());
$new_fields = $new_fields['fields'];
if ($old_fields != $new_fields) {
- cache_clear_all($index->getCacheId(), 'cache', TRUE);
- if ($index->server && $index->server()->fieldsUpdated($index)) {
- _search_api_index_reindex($index);
+ if ($index->server) {
+ $index->server()->fieldsUpdated($index);
}
}
- // If additional fields changed, clear the index's specific cache which
- // includes them.
- $old_additional = $index->original->options + array('additional fields' => array());
- $old_additional = $old_additional['additional fields'];
- $new_additional = $index->options + array('additional fields' => array());
- $new_additional = $new_additional['additional fields'];
- if ($old_additional != $new_additional) {
- cache_clear_all($index->getCacheId() . '-0-1', 'cache');
- }
-
// If the index's enabled or read-only status is being changed, queue or
// dequeue items for indexing.
if (!$index->read_only && $index->enabled != $index->original->enabled) {
@@ -660,15 +718,6 @@ function search_api_search_api_index_update(SearchApiIndex $index) {
$index->queueItems();
}
}
-
- // If the cron batch size changed, empty the cron queue for this index.
- $old_cron = $index->original->options + array('cron_limit' => NULL);
- $old_cron = $old_cron['cron_limit'];
- $new_cron = $index->options + array('cron_limit' => NULL);
- $new_cron = $new_cron['cron_limit'];
- if ($old_cron !== $new_cron) {
- _search_api_empty_cron_queue($index, TRUE);
- }
}
/**
@@ -692,7 +741,7 @@ function search_api_search_api_index_delete(SearchApiIndex $index) {
*
* Adds dependency information for exported servers.
*/
-function search_api_features_export_alter(&$export, $module_name) {
+function search_api_features_export_alter(&$export) {
if (isset($export['features']['search_api_server'])) {
// Get a list of the modules that provide storage engines.
$hook = 'search_api_service_info';
@@ -723,16 +772,82 @@ function search_api_features_export_alter(&$export, $module_name) {
}
}
+/**
+ * Implements hook_system_info_alter().
+ *
+ * Checks if the module provides any search item types or service classes. If it
+ * does, and there are search indexes using those item types, respectively
+ * servers using those service classes, the module is set to "required".
+ *
+ * Heavily borrowed from field_system_info_alter().
+ *
+ * @see hook_search_api_item_type_info()
+ */
+function search_api_system_info_alter(&$info, $file, $type) {
+ if ($type != 'module' || $file->name == 'search_api' || !module_exists($file->name)) {
+ return;
+ }
+ // Check for defined item types.
+ if (module_hook($file->name, 'search_api_item_type_info')) {
+ $types = array();
+ foreach (search_api_get_item_type_info() as $type => $type_info) {
+ if ($type_info['module'] == $file->name) {
+ $types[] = $type;
+ }
+ }
+ if ($types) {
+ $sql = 'SELECT machine_name, name FROM {search_api_index} WHERE item_type IN (:types)';
+ $indexes = db_query($sql, array(':types' => $types))->fetchAllKeyed();
+ if ($indexes) {
+ $info['required'] = TRUE;
+
+ $links = array();
+ foreach ($indexes as $id => $name) {
+ $url = url("admin/config/search/search_api/index/$id");
+ $links[] = '' . check_plain($name) . '';
+ }
+
+ $args = array('!indexes' => implode(', ', $links));
+ $info['explanation'] = format_plural(count($indexes), 'Item type in use by the following index: !indexes.', 'Item type(s) in use by the following indexes: !indexes.', $args);
+ }
+ }
+ }
+ // Check for defined service classes.
+ if (module_hook($file->name, 'search_api_service_info')) {
+ $classes = array();
+ foreach (search_api_get_service_info() as $class => $class_info) {
+ if ($class_info['module'] == $file->name) {
+ $classes[] = $class;
+ }
+ }
+ if ($classes) {
+ $sql = 'SELECT machine_name, name FROM {search_api_server} WHERE class IN (:classes)';
+ $servers = db_query($sql, array(':classes' => $classes))->fetchAllKeyed();
+ if ($servers) {
+ $info['required'] = TRUE;
+
+ $links = array();
+ foreach ($servers as $id => $name) {
+ $url = url("admin/config/search/search_api/server/$id");
+ $links[] = '' . check_plain($name) . '';
+ }
+
+ $args = array('!servers' => implode(', ', $links));
+ $explanation = format_plural(count($servers), 'Service class in use by the following server: !servers.', 'Service class(es) in use by the following servers: !servers.', $args);
+ $info['explanation'] = (!empty($info['explanation']) ? $info['explanation'] . ' ' : '') . $explanation;
+ }
+ }
+ }
+}
+
/**
* Implements hook_entity_insert().
*
- * Marks the new item as to-index for all indexes on entities of the specified
- * type.
+ * This is implemented on behalf of the SearchApiEntityDataSourceController
+ * datasource controller and calls search_api_track_item_insert() for the
+ * inserted items.
*
- * @param $entity
- * The new entity.
- * @param $type
- * The entity's type.
+ * @see search_api_search_api_item_type_info()
*/
function search_api_entity_insert($entity, $type) {
// When inserting a new search index, the new index was already inserted into
@@ -746,18 +861,21 @@ function search_api_entity_insert($entity, $type) {
list($id) = entity_extract_ids($type, $entity);
if (isset($id)) {
search_api_track_item_insert($type, array($id));
+ $combined_id = $type . '/' . $id;
+ search_api_track_item_insert('multiple', array($combined_id));
}
}
/**
* Implements hook_entity_update().
*
- * Marks the item as changed for all indexes on entities of the specified type.
+ * This is implemented on behalf of the SearchApiEntityDataSourceController
+ * datasource controller and calls search_api_track_item_change() for the
+ * updated items.
*
- * @param $entity
- * The updated entity.
- * @param $type
- * The entity's type.
+ * It also checks whether the entity's bundle changed and acts accordingly.
+ *
+ * @see search_api_search_api_item_type_info()
*/
function search_api_entity_update($entity, $type) {
// We only react on entity operations for types with property information, as
@@ -765,21 +883,31 @@ function search_api_entity_update($entity, $type) {
if (!entity_get_property_info($type)) {
return;
}
- list($id) = entity_extract_ids($type, $entity);
+ list($id, , $new_bundle) = entity_extract_ids($type, $entity);
+
+ // Check if the entity's bundle changed.
+ if (!empty($entity->original)) {
+ list(, , $old_bundle) = entity_extract_ids($type, $entity->original);
+ if ($new_bundle != $old_bundle) {
+ _search_api_entity_datasource_bundle_change($type, $id, $old_bundle, $new_bundle);
+ }
+ }
+
if (isset($id)) {
search_api_track_item_change($type, array($id));
+ $combined_id = $type . '/' . $id;
+ search_api_track_item_change('multiple', array($combined_id));
}
}
/**
* Implements hook_entity_delete().
*
- * Removes the item from the tracking table and deletes it from all indexes.
+ * This is implemented on behalf of the SearchApiEntityDataSourceController
+ * datasource controller and calls search_api_track_item_delete() for the
+ * deleted items.
*
- * @param $entity
- * The updated entity.
- * @param $type
- * The entity's type.
+ * @see search_api_search_api_item_type_info()
*/
function search_api_entity_delete($entity, $type) {
// We only react on entity operations for types with property information, as
@@ -790,6 +918,60 @@ function search_api_entity_delete($entity, $type) {
list($id) = entity_extract_ids($type, $entity);
if (isset($id)) {
search_api_track_item_delete($type, array($id));
+ $combined_id = $type . '/' . $id;
+ search_api_track_item_delete('multiple', array($combined_id));
+ }
+}
+
+/**
+ * Implements hook_node_access_records_alter().
+ *
+ * Marks the node as "changed" in indexes that use the "Node access" data
+ * alteration. Also marks the node's comments as changed in indexes that use the
+ * "Comment access" data alteration.
+ */
+function search_api_node_access_records_alter(&$grants, $node) {
+ foreach (search_api_index_load_multiple(FALSE) as $index) {
+ $item_ids = array();
+ if (!empty($index->options['data_alter_callbacks']['search_api_alter_node_access']['status'])) {
+ $item_id = $index->datasource()->getItemId($node);
+ if ($item_id !== NULL) {
+ $item_ids = array($item_id);
+ }
+ }
+ elseif (!empty($index->options['data_alter_callbacks']['search_api_alter_comment_access']['status'])) {
+ if (!isset($comments)) {
+ $comments = comment_load_multiple(FALSE, array('nid' => $node->nid));
+ }
+ foreach ($comments as $comment) {
+ $item_ids[] = $index->datasource()->getItemId($comment);
+ }
+ }
+
+ if ($item_ids) {
+ $indexes = array($index->machine_name => $index);
+ search_api_track_item_change_for_indexes($index->item_type, $item_ids, $indexes);
+ }
+ }
+}
+
+/**
+ * Implements hook_field_attach_rename_bundle().
+ *
+ * This is implemented on behalf of the SearchApiEntityDataSourceController
+ * datasource controller, to update any bundle settings that contain the changed
+ * bundle.
+ */
+function search_api_field_attach_rename_bundle($entity_type, $bundle_old, $bundle_new) {
+ foreach (search_api_index_load_multiple(FALSE, array('item_type' => $entity_type)) as $index) {
+ $bundles = &$index->options['datasource']['bundles'];
+ if (isset($bundles) && ($pos = array_search($bundle_old, $bundles)) !== FALSE) {
+ $bundles[$pos] = $bundle_new;
+ $index->save();
+ // Clear all caches that could contain the bundle information.
+ $index->resetCaches();
+ drupal_static_reset('search_api_get_datasource_controller');
+ }
}
}
@@ -799,7 +981,7 @@ function search_api_entity_delete($entity, $type) {
* Recalculates fields settings if the cardinality of the field has changed from
* or to 1.
*/
-function search_api_field_update_field($field, $prior_field, $has_data) {
+function search_api_field_update_field($field, $prior_field) {
$before = $prior_field['cardinality'];
$after = $field['cardinality'];
if ($before != $after && ($before == 1 || $after == 1)) {
@@ -827,35 +1009,62 @@ function search_api_flush_caches() {
function search_api_search_api_item_type_info() {
$types = array();
- foreach (entity_get_property_info() as $type => $property_info) {
- if ($info = entity_get_info($type)) {
- $types[$type] = array(
- 'name' => $info['label'],
- 'datasource controller' => 'SearchApiEntityDataSourceController',
- 'entity_type' => $type,
- );
- }
+ foreach (search_api_entity_type_options_list() as $type => $label) {
+ $types[$type] = array(
+ 'name' => $label,
+ 'datasource controller' => 'SearchApiEntityDataSourceController',
+ 'entity_type' => $type,
+ );
}
+ $types['multiple'] = array(
+ 'name' => t('Multiple types'),
+ 'datasource controller' => 'SearchApiCombinedEntityDataSourceController',
+ );
+
return $types;
}
+/**
+ * Implements hook_module_implements_alter().
+ *
+ * Ensures the item type and service class static caches are invalidated at the
+ * right time.
+ */
+function search_api_module_implements_alter(array &$implementations, $hook) {
+ switch ($hook) {
+ case 'modules_enabled':
+ $group = $implementations['search_api'];
+ unset($implementations['search_api']);
+ $implementations = array('search_api' => $group) + $implementations;
+ break;
+
+ case 'modules_disabled':
+ $group = $implementations['search_api'];
+ unset($implementations['search_api']);
+ $implementations['search_api'] = $group;
+ break;
+ }
+}
+
/**
* Implements hook_modules_enabled().
*/
-function search_api_modules_enabled(array $modules) {
- // New modules might offer additional entity types, invalidating the cached
- // item type information.
+function search_api_modules_enabled() {
+ // New modules might offer additional item types or service classes,
+ // invalidating the cached information.
drupal_static_reset('search_api_get_item_type_info');
+ drupal_static_reset('search_api_get_service_info');
}
/**
* Implements hook_modules_disabled().
*/
-function search_api_modules_disabled(array $modules) {
- // The disabled modules might have offered entity types, which are now
- // invalid. Therefore, clear the cached item type informaiton.
+function search_api_modules_disabled() {
+ // The disabled modules might have offered item types or service classes,
+ // invalidating the cached information.
drupal_static_reset('search_api_get_item_type_info');
+ drupal_static_reset('search_api_get_service_info');
}
/**
@@ -903,14 +1112,29 @@ function search_api_search_api_alter_callback_info() {
);
$callbacks['search_api_alter_node_access'] = array(
'name' => t('Node access'),
- 'description' => t('Add node access information to the index.'),
+ 'description' => t('Add node access information to the index. Caution: This only affects the indexed nodes themselves, not any node reference fields that are indexed with them, or displayed in search results.'),
'class' => 'SearchApiAlterNodeAccess',
);
+ $callbacks['search_api_alter_comment_access'] = array(
+ 'name' => t('Access check'),
+ 'description' => t('Add node access information to the index. Caution: This only affects the indexed nodes themselves, not any node reference fields that are indexed with them, or displayed in search results.'),
+ 'class' => 'SearchApiAlterCommentAccess',
+ );
$callbacks['search_api_alter_node_status'] = array(
'name' => t('Exclude unpublished nodes'),
- 'description' => t('Exclude unpublished nodes from the index.'),
+ 'description' => t('Exclude unpublished nodes from the index. Caution: This only affects the indexed nodes themselves. If an enabled node has references to disabled nodes, those will still be indexed (or displayed) normally.'),
'class' => 'SearchApiAlterNodeStatus',
);
+ $callbacks['search_api_alter_user_content'] = array(
+ 'name' => t('Add user content'),
+ 'description' => t('Allows indexing of nodes (and their fields) created by the indexed user. (Caution: This might lead to performance problems, or even errors during indexing, on larger sites.)'),
+ 'class' => 'SearchApiAlterAddUserContent',
+ );
+ $callbacks['search_api_alter_user_status'] = array(
+ 'name' => t('Exclude blocked users'),
+ 'description' => t('Exclude blocked users from the index. Caution: This only affects the indexed users themselves. If an active user account includes a reference to a disabled user, that reference will still be indexed (or displayed) normally.'),
+ 'class' => 'SearchApiAlterUserStatus',
+ );
return $callbacks;
}
@@ -955,11 +1179,17 @@ function search_api_search_api_processor_info() {
'class' => 'SearchApiStopWords',
'weight' => 30,
);
+ $processors['search_api_porter_stemmer'] = array(
+ 'name' => t('Stem words'),
+ 'description' => t('This processor reduces words to a stem (e.g., "talking" to "talk"). For best results, it should only be executed after tokenizing.'),
+ 'class' => 'SearchApiPorterStemmer',
+ 'weight' => 35,
+ );
$processors['search_api_highlighting'] = array(
'name' => t('Highlighting'),
'description' => t('Adds highlighting for search results.'),
'class' => 'SearchApiHighlight',
- 'weight' => 35,
+ 'weight' => 40,
);
return $processors;
@@ -968,9 +1198,9 @@ function search_api_search_api_processor_info() {
/**
* Inserts new unindexed items for all indexes on the specified type.
*
- * @param $type
+ * @param string $type
* The item type of the new items.
- * @param array $item_id
+ * @param array $item_ids
* The IDs of the new items.
*/
function search_api_track_item_insert($type, array $item_ids) {
@@ -984,7 +1214,17 @@ function search_api_track_item_insert($type, array $item_ids) {
return;
}
- search_api_get_datasource_controller($type)->trackItemInsert($item_ids, $indexes);
+ try {
+ $returned_indexes = search_api_get_datasource_controller($type)->trackItemInsert($item_ids, $indexes);
+ if (isset($returned_indexes)) {
+ $indexes = $returned_indexes;
+ }
+ }
+ catch (SearchApiException $e) {
+ $vars['%item_type'] = $type;
+ watchdog_exception('search_api', $e, '%type while inserting items of type %item_type: !message in %function (line %line of %file).', $vars);
+ return;
+ }
foreach ($indexes as $index) {
if (!empty($index->options['index_directly'])) {
@@ -1014,19 +1254,42 @@ function search_api_track_item_change($type, array $item_ids) {
if (!$indexes) {
return;
}
- search_api_get_datasource_controller($type)->trackItemChange($item_ids, $indexes);
- foreach ($indexes as $index) {
- if (!empty($index->options['index_directly'])) {
- // For indexes with the index_directly option set, queue the items to be
- // indexed at the end of the request.
- try {
- search_api_index_specific_items_delayed($index, $item_ids);
- }
- catch (SearchApiException $e) {
- watchdog_exception('search_api', $e);
+ search_api_track_item_change_for_indexes($type, $item_ids, $indexes);
+}
+
+/**
+ * Marks the items with the specified IDs as "dirty" for the given indexes.
+ *
+ * @param string $type
+ * The item type of the items.
+ * @param array $item_ids
+ * The item IDs.
+ * @param SearchApiIndex[] $indexes
+ * The indexes for which to mark the items as "dirty".
+ */
+function search_api_track_item_change_for_indexes($type, array $item_ids, $indexes) {
+ try {
+ $returned_indexes = search_api_get_datasource_controller($type)->trackItemChange($item_ids, $indexes);
+ if (isset($returned_indexes)) {
+ $indexes = $returned_indexes;
+ }
+ foreach ($indexes as $index) {
+ if (!empty($index->options['index_directly'])) {
+ // For indexes with the index_directly option set, queue the items to be
+ // indexed at the end of the request.
+ try {
+ search_api_index_specific_items_delayed($index, $item_ids);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
}
}
}
+ catch (SearchApiException $e) {
+ $vars['%item_type'] = $type;
+ watchdog_exception('search_api', $e, '%type while updating items of type %item_type: !message in %function (line %line of %file).', $vars);
+ }
}
/**
@@ -1036,9 +1299,20 @@ function search_api_track_item_change($type, array $item_ids) {
* The index on which items were queued.
* @param array $item_ids
* The ids of the queued items.
+ *
+ * @deprecated
+ * As of Search API 1.10, the cron queue is not used for indexing anymore,
+ * therefore this function has become useless. It will, along with
+ * SearchApiDataSourceControllerInterface::trackItemQueued(), be removed in
+ * the Drupal 8 version of this module.
*/
function search_api_track_item_queued(SearchApiIndex $index, array $item_ids) {
- $index->datasource()->trackItemQueued($item_ids, $index);
+ try {
+ $index->datasource()->trackItemQueued($item_ids, $index);
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ }
}
/**
@@ -1071,27 +1345,185 @@ function search_api_track_item_delete($type, array $item_ids) {
);
$indexes = search_api_index_load_multiple(FALSE, $conditions);
if ($indexes) {
- search_api_get_datasource_controller($type)->trackItemDelete($item_ids, $indexes);
+ try {
+ $changed_indexes = search_api_get_datasource_controller($type)->trackItemDelete($item_ids, $indexes);
+ if (isset($changed_indexes)) {
+ $indexes = $changed_indexes;
+ }
+ }
+ catch (SearchApiException $e) {
+ $vars['%item_type'] = $type;
+ watchdog_exception('search_api', $e, '%type while deleting items of type %item_type: !message in %function (line %line of %file).', $vars);
+ }
}
// Then, delete it from all servers. Servers of disabled indexes have to be
// considered, too!
- unset($conditions['enabled']);
- foreach (search_api_index_load_multiple(FALSE, $conditions) as $index) {
- if ($index->server) {
- $server = $index->server();
- if ($server->enabled) {
+ $conditions['enabled'] = 0;
+ $indexes = array_merge($indexes, search_api_index_load_multiple(FALSE, $conditions));
+ foreach ($indexes as $index) {
+ try {
+ if ($server = $index->server()) {
$server->deleteItems($item_ids, $index);
}
- else {
- $tasks = variable_get('search_api_tasks', array());
- foreach ($item_ids as $id) {
- $tasks[$server->machine_name][$index->machine_name][] = 'delete-' . $id;
- }
- variable_set('search_api_tasks', $tasks);
+ }
+ catch (Exception $e) {
+ $vars['%item_type'] = $type;
+ watchdog_exception('search_api', $e, '%type while deleting items of type %item_type: !message in %function (line %line of %file).', $vars);
+ }
+ }
+}
+
+/**
+ * Checks for pending tasks on one or all enabled search servers.
+ *
+ * @param SearchApiServer|null $server
+ * (optional) The server whose tasks should be checked. If not given, the
+ * tasks for all enabled servers are checked.
+ *
+ * @return bool
+ * TRUE if all tasks (for the specific server, if $server was given) were
+ * executed successfully, or if there were no tasks. FALSE if there are still
+ * pending tasks.
+ */
+function search_api_server_tasks_check(SearchApiServer $server = NULL) {
+ $select = db_select('search_api_task', 't')
+ ->fields('t')
+ // Only retrieve tasks we can handle.
+ ->condition('t.type', array('addIndex', 'fieldsUpdated', 'removeIndex', 'deleteItems'));
+ if ($server) {
+ $select->condition('t.server_id', $server->machine_name);
+ }
+ else {
+ $select->innerJoin('search_api_server', 's', 't.server_id = s.machine_name AND s.enabled = 1');
+ // By ordering by the server, we can later just load them when we reach them
+ // while looping through the tasks. It is very unlikely there will be tasks
+ // for more than one or two servers, so a *_load_multiple() probably
+ // wouldn't bring any significant advantages, but complicate the code.
+ $select->orderBy('t.server_id');
+ }
+ // Store a count query for later checking whether all tasks were processed
+ // successfully.
+ $count_query = $select->countQuery();
+
+ // Sometimes the order of tasks might be important, so make sure to order by
+ // the task ID (which should be in order of insertion).
+ $select->orderBy('t.id');
+ // Only retrieve and execute 100 tasks at once, to avoid running out of memory
+ // or time. We just can't do anything else until all tasks have been resolved,
+ // but at least we shouldn't crash sites, or keep piling up tasks, that way.
+ $select->range(0, 100);
+ $tasks = $select->execute();
+
+ $executed_tasks = array();
+ foreach ($tasks as $task) {
+ if (!$server || $server->machine_name != $task->server_id) {
+ $server = search_api_server_load($task->server_id);
+ if (!$server) {
+ continue;
}
}
+ switch ($task->type) {
+ case 'addIndex':
+ $index = search_api_index_load($task->index_id);
+ if ($index) {
+ $server->addIndex($index);
+ }
+ break;
+
+ case 'fieldsUpdated':
+ $index = search_api_index_load($task->index_id);
+ if ($index) {
+ if ($task->data) {
+ $index->original = unserialize($task->data);
+ }
+ $server->fieldsUpdated($index);
+ }
+ break;
+
+ case 'removeIndex':
+ $index = search_api_index_load($task->index_id);
+ if ($index) {
+ $server->removeIndex($index ? $index : $task->index_id);
+ }
+ break;
+
+ case 'deleteItems':
+ $ids = $task->data ? unserialize($task->data) : 'all';
+ $index = $task->index_id ? search_api_index_load($task->index_id) : NULL;
+ // Since a failed load returns (for stupid menu handler reasons) FALSE,
+ // not NULL, we have to make doubly sure here not to pass an invalid
+ // value (and cause a fatal error).
+ $index = $index ? $index : NULL;
+ $server->deleteItems($ids, $index);
+ break;
+
+ default:
+ // This should never happen.
+ continue;
+ }
+ $executed_tasks[] = $task->id;
}
+
+ // If there were no tasks (we recognized), return TRUE.
+ if (!$executed_tasks) {
+ return TRUE;
+ }
+ // Otherwise, delete the executed tasks and check if new tasks were created
+ // (or if we didn't even fetch all due to the 100 tasks limit).
+ search_api_server_tasks_delete($executed_tasks);
+ return $count_query->execute()->fetchField() === 0;
+}
+
+/**
+ * Adds an entry into a server's list of pending tasks.
+ *
+ * @param SearchApiServer $server
+ * The server for which a task should be remembered.
+ * @param $type
+ * The type of task to perform.
+ * @param SearchApiIndex|string|null $index
+ * (optional) If applicable, the index to which the task pertains (or its
+ * machine name).
+ * @param mixed $data
+ * (optional) If applicable, some further data necessary for the task.
+ */
+function search_api_server_tasks_add(SearchApiServer $server, $type, $index = NULL, $data = NULL) {
+ db_insert('search_api_task')
+ ->fields(array(
+ 'server_id' => $server->machine_name,
+ 'type' => $type,
+ 'index_id' => $index ? (is_object($index) ? $index->machine_name : $index) : NULL,
+ 'data' => isset($data) ? serialize($data) : NULL,
+ ))
+ ->execute();
+}
+
+/**
+ * Removes pending server tasks from the list.
+ *
+ * @param array|null $ids
+ * (optional) The IDs of the pending server tasks to delete. Set to NULL
+ * to not filter by IDs.
+ * @param SearchApiServer|null $server
+ * (optional) A server for which the tasks should be deleted. Set to NULL to
+ * delete tasks from all servers.
+ * @param SearchApiIndex|string|null $index
+ * (optional) An index (or its machine name) for which the tasks should be
+ * deleted. Set to NULL to delete tasks for all indexes.
+ */
+function search_api_server_tasks_delete(array $ids = NULL, SearchApiServer $server = NULL, $index = NULL) {
+ $delete = db_delete('search_api_task');
+ if ($ids) {
+ $delete->condition('id', $ids);
+ }
+ if ($server) {
+ $delete->condition('server_id', $server->machine_name);
+ }
+ if ($index) {
+ $delete->condition('index_id', $index->machine_name);
+ }
+ $delete->execute();
}
/**
@@ -1102,7 +1534,7 @@ function search_api_track_item_delete($type, array $item_ids) {
* index and, if a discrepancy is spotted, re-save that index with updated
* fields options (thus, of course, also triggering a re-indexing operation).
*
- * @param array|false $indexes
+ * @param SearchApiIndex[]|false $indexes
* An array of SearchApiIndex objects on which to perform the operation, or
* FALSE to perform it on all indexes.
*/
@@ -1131,7 +1563,7 @@ function search_api_index_recalculate_fields($indexes = FALSE) {
}
// Use a more accurate method of determining if the fields settings are
// equal to avoid needlessly re-indexing the whole index.
- if (!_search_api_settings_equals($fields, $index->options['fields'])) {
+ if ($fields != $index->options['fields']) {
$options = $index->options;
$options['fields'] = $fields;
$index->update(array('options' => $options));
@@ -1142,9 +1574,6 @@ function search_api_index_recalculate_fields($indexes = FALSE) {
/**
* Test two setting arrays (or individual settings) for equality.
*
- * While a simple == also works in some cases, this function takes into account
- * that the order of keys (usually) doesn't matter in settings arrays.
- *
* @param mixed $setting1
* The first setting (array).
* @param mixed $setting2
@@ -1152,6 +1581,8 @@ function search_api_index_recalculate_fields($indexes = FALSE) {
*
* @return bool
* TRUE if both settings are identical, FALSE otherwise.
+ *
+ * @deprecated The simple "==" operator will achieve the same.
*/
function _search_api_settings_equals($setting1, $setting2) {
if (!is_array($setting1) || !is_array($setting2)) {
@@ -1172,53 +1603,31 @@ function _search_api_settings_equals($setting1, $setting2) {
}
/**
- * Indexes items for the specified index. Only items marked as changed are
- * indexed, in their order of change (if known).
+ * Indexes items for the specified index.
+ *
+ * Only items marked as changed are indexed, in their order of change (if
+ * known).
*
* @param SearchApiIndex $index
* The index on which items should be indexed.
- * @param $limit
- * The number of items which should be indexed at most. -1 means no limit.
+ * @param int $limit
+ * (optional) The number of items which should be indexed at most. Defaults to
+ * -1, which means that all changed items should be indexed.
+ *
+ * @return int
+ * Number of successfully indexed items.
*
* @throws SearchApiException
* If any error occurs during indexing.
- *
- * @return
- * Number of successfully indexed items.
*/
function search_api_index_items(SearchApiIndex $index, $limit = -1) {
- // Don't try to index read-only indexes.
+ // Don't try to index on read-only indexes.
if ($index->read_only) {
return 0;
}
- $queue = DrupalQueue::get('search_api_indexing_queue');
- $queue->createQueue();
- $indexed = 0;
- $unlimited = $limit < 0;
- $release_items = array();
- while (($unlimited || $indexed < $limit) && ($item = $queue->claimItem(30))) {
- if ($item->data['index'] === $index->machine_name) {
- $indexed += _search_api_indexing_queue_process($item->data);
- $queue->deleteItem($item);
- }
- else {
- $release_items[] = $item;
- }
- }
-
- foreach ($release_items as $item) {
- $queue->releaseItem($item);
- }
-
- if ($unlimited || $indexed < $limit) {
- $ids = search_api_get_items_to_index($index, $unlimited ? -1 : $limit - $indexed);
- if ($ids) {
- $indexed += count(search_api_index_specific_items($index, $ids));
- }
- }
-
- return $indexed;
+ $ids = search_api_get_items_to_index($index, $limit);
+ return $ids ? count(search_api_index_specific_items($index, $ids)) : 0;
}
/**
@@ -1231,13 +1640,20 @@ function search_api_index_items(SearchApiIndex $index, $limit = -1) {
* @param array $ids
* The IDs of the items which should be indexed.
*
+ * @return array
+ * The IDs of all successfully indexed items.
+ *
* @throws SearchApiException
* If any error occurs during indexing.
- *
- * @return
- * The IDs of all successfully indexed items.
*/
function search_api_index_specific_items(SearchApiIndex $index, array $ids) {
+ // Before doing anything else, check whether there are pending tasks that need
+ // to be executed on the server. It might be important that they are executed
+ // before any indexing occurs.
+ if (!search_api_server_tasks_check($index->server())) {
+ throw new SearchApiException(t('Could not index items since important pending server tasks could not be performed.'));
+ }
+
$items = $index->loadItems($ids);
// Clone items because data alterations may alter them.
$cloned_items = array();
@@ -1252,10 +1668,7 @@ function search_api_index_specific_items(SearchApiIndex $index, array $ids) {
// some specific setups.
$type = search_api_get_item_type_info($index->item_type);
$type = $type ? $type['name'] : $index->item_type;
- watchdog('search_api',
- "Error during indexing: invalid item loaded for @type with ID @id.",
- array('@id' => $id, '@type' => $type),
- WATCHDOG_WARNING);
+ watchdog('search_api', "Error during indexing: invalid item loaded for @type with ID @id.", array('@id' => $id, '@type' => $type), WATCHDOG_WARNING);
}
}
$indexed = $items ? $index->index($cloned_items) : array();
@@ -1335,25 +1748,14 @@ function search_api_get_items_to_index(SearchApiIndex $index, $limit = -1) {
* @param $id
* The ID or machine name of the index to execute the search on.
* @param $options
- * An associative array of options. The following are recognized:
- * - filters: Either a SearchApiQueryFilterInterface object or an array of
- * filters used to filter the search.
- * - sort: An array of sort directives of the form $field => $order, where
- * $order is either 'ASC' or 'DESC'.
- * - offset: The position of the first returned search results relative to the
- * whole result in the index.
- * - limit: The maximum number of search results to return. -1 means no limit.
- * - 'query class': The query class to use. Must be a subtype of
- * SearchApiQueryInterface.
- * - conjunction: The type of conjunction to use for this query - either
- * 'AND' or 'OR'. 'AND' by default.
- * - 'parse mode': The mode with which to parse the $keys variable, if it
- * is set and not already an array. See SearchApiQuery::parseModes() for
- * parse modes recognized by the SearchApiQuery class.
- * Subclasses might define additional modes.
+ * An associative array of options to be passed to
+ * SearchApiQueryInterface::__construct().
*
* @return SearchApiQueryInterface
* An object for searching on the specified index.
+ *
+ * @throws SearchApiException
+ * If the index is unknown or disabled, or some other error was encountered.
*/
function search_api_query($id, array $options = array()) {
$index = search_api_index_load($id);
@@ -1364,9 +1766,10 @@ function search_api_query($id, array $options = array()) {
}
/**
- * Static store for the searches executed on the current page. Can either be
- * used to store an executed search, or to retrieve a previously stored
- * search.
+ * Stores or retrieves a search executed in this page request.
+ *
+ * Static storage for the searches executed during the current page request. Can
+ * used to store an executed search, or to retrieve a previously stored search.
*
* @param $search_id
* For pages displaying multiple searches, an optional ID identifying the
@@ -1480,22 +1883,48 @@ function search_api_get_data_type_info($type = NULL) {
*
* @see hook_search_api_service_info()
*
- * @param $id
+ * @param string|null $id
* The ID of the service info to retrieve.
*
* @return array
* If $id was not specified, an array of all available service classes.
* Otherwise, either the service info with the specified id (if it exists),
- * or NULL.
+ * or NULL. Service class information is formatted as specified by
+ * hook_search_api_service_info(), with the addition of a "module" key
+ * specifying the module that adds a certain class.
*/
function search_api_get_service_info($id = NULL) {
$services = &drupal_static(__FUNCTION__);
if (!isset($services)) {
- $services = module_invoke_all('search_api_service_info');
+ // Inlined version of module_invoke_all() to add "module" keys.
+ $services = array();
+ foreach (module_implements('search_api_service_info') as $module) {
+ $function = $module . '_search_api_service_info';
+ if (function_exists($function)) {
+ $new_services = $function();
+ if (isset($new_services) && is_array($new_services)) {
+ foreach ($new_services as $service => $info) {
+ $new_services[$service] += array('module' => $module);
+ }
+ }
+ $services += $new_services;
+ }
+ }
- // Allow other modules to alter definitions
- drupal_alter('search_api_service_info', $services);
+ // Same for drupal_alter().
+ foreach (module_implements('search_api_service_info_alter') as $module) {
+ $function = $module . '_search_api_service_info_alter';
+ if (function_exists($function)) {
+ $old = $services;
+ $function($services);
+ if ($new_services = array_diff_key($services, $old)) {
+ foreach ($new_services as $service => $info) {
+ $services[$service] += array('module' => $module);
+ }
+ }
+ }
+ }
}
if (isset($id)) {
@@ -1507,15 +1936,15 @@ function search_api_get_service_info($id = NULL) {
/**
* Returns information for either all item types, or a specific one.
*
- * @param $type
+ * @param string|null $type
* If set, the item type whose information should be returned.
*
- * @return
+ * @return array|null
* If $type is given, either an array containing the information of that item
* type, or NULL if it is unknown. Otherwise, an array keyed by type IDs
* containing the information for all item types. Item type information is
- * formatted as specified by hook_search_api_item_type_info(), and has all
- * optional fields filled with the defaults.
+ * formatted as specified by hook_search_api_item_type_info(), with the
+ * addition of a "module" key specifying the module that adds a certain type.
*
* @see hook_search_api_item_type_info()
*/
@@ -1523,8 +1952,34 @@ function search_api_get_item_type_info($type = NULL) {
$types = &drupal_static(__FUNCTION__);
if (!isset($types)) {
- $types = module_invoke_all('search_api_item_type_info');
- drupal_alter('search_api_item_type_info', $types);
+ // Inlined version of module_invoke_all() to add "module" keys.
+ $types = array();
+ foreach (module_implements('search_api_item_type_info') as $module) {
+ $function = $module . '_search_api_item_type_info';
+ if (function_exists($function)) {
+ $new_types = $function();
+ if (isset($new_types) && is_array($new_types)) {
+ foreach ($new_types as $id => $info) {
+ $new_types[$id] += array('module' => $module);
+ }
+ }
+ $types += $new_types;
+ }
+ }
+
+ // Same for drupal_alter().
+ foreach (module_implements('search_api_item_type_info_alter') as $module) {
+ $function = $module . '_search_api_item_type_info_alter';
+ if (function_exists($function)) {
+ $old = $types;
+ $function($types);
+ if ($new_types = array_diff_key($types, $old)) {
+ foreach ($new_types as $id => $info) {
+ $types[$id] += array('module' => $module);
+ }
+ }
+ }
+ }
}
if (isset($type)) {
@@ -1574,10 +2029,13 @@ function search_api_get_alter_callbacks() {
if (!isset($callbacks)) {
$callbacks = module_invoke_all('search_api_alter_callback_info');
- // Initialization of optional entries with default values
+ // Fill optional settings with default values.
foreach ($callbacks as $id => $callback) {
- $callbacks[$id] += array('enabled' => TRUE, 'weight' => 0);
+ $callbacks[$id] += array('weight' => 0);
}
+
+ // Invoke alter hook.
+ drupal_alter('search_api_alter_callback_info', $callbacks);
}
return $callbacks;
@@ -1597,10 +2055,13 @@ function search_api_get_processors() {
if (!isset($processors)) {
$processors = module_invoke_all('search_api_processor_info');
- // Initialization of optional entries with default values
+ // Fill optional settings with default values.
foreach ($processors as $id => $processor) {
- $processors[$id] += array('enabled pre' => TRUE, 'enabled post' => TRUE, 'weight' => 0);
+ $processors[$id] += array('weight' => 0);
}
+
+ // Invoke alter hook.
+ drupal_alter('search_api_processor_info', $processors);
}
return $processors;
@@ -1615,39 +2076,75 @@ function search_api_get_processors() {
* The SearchApiQueryInterface object representing the search query.
*/
function search_api_search_api_query_alter(SearchApiQueryInterface $query) {
+ global $user;
$index = $query->getIndex();
// Only add node access if the necessary fields are indexed in the index, and
// unless disabled explicitly by the query.
- $fields = $index->options['fields'];
- if (!empty($fields['search_api_access_node']) && !empty($fields['status']) && !empty($fields['author']) && !$query->getOption('search_api_bypass_access')) {
- $account = $query->getOption('search_api_access_account', $GLOBALS['user']);
+ $type = $index->getEntityType();
+ if (!empty($index->options['data_alter_callbacks']["search_api_alter_{$type}_access"]['status']) && !$query->getOption('search_api_bypass_access')) {
+ $account = $query->getOption('search_api_access_account', $user);
if (is_numeric($account)) {
$account = user_load($account);
}
if (is_object($account)) {
try {
- _search_api_query_add_node_access($account, $query);
+ _search_api_query_add_node_access($account, $query, $type);
}
catch (SearchApiException $e) {
watchdog_exception('search_api', $e);
}
}
else {
- watchdog('search_api', 'An illegal user UID was given for node access: @uid.', array('@uid' => $query->getOption('search_api_access_account', $GLOBALS['user'])), WATCHDOG_WARNING);
+ $account = $query->getOption('search_api_access_account', '(' . t('none') . ')');
+ if (is_object($account)) {
+ $account = $account->uid;
+ }
+ if (!is_scalar($account)) {
+ $account = var_export($account, TRUE);
+ }
+ watchdog('search_api', 'An illegal user UID was given for node access: @uid.', array('@uid' => $account), WATCHDOG_WARNING);
}
}
}
/**
- * Build a node access subquery.
- *
- * @param $account
- * The user object, who searches.
- *
- * @return SearchApiQueryFilter
- */
-function _search_api_query_add_node_access($account, SearchApiQueryInterface $query) {
- if (!user_access('access content', $account)) {
+ * Adds a node access filter to a search query, if applicable.
+ *
+ * @param object $account
+ * The user object, who searches.
+ * @param SearchApiQueryInterface $query
+ * The query to which a node access filter should be added, if applicable.
+ * @param string $type
+ * (optional) The type of search – either "node" or "comment". Defaults to
+ * "node".
+ *
+ * @throws SearchApiException
+ * If not all necessary fields are indexed on the index.
+ */
+function _search_api_query_add_node_access($account, SearchApiQueryInterface $query, $type = 'node') {
+ // Don't do anything if the user can access all content.
+ if (user_access('bypass node access', $account)) {
+ return;
+ }
+
+ $is_comment = ($type == 'comment');
+
+ // Check whether the necessary fields are indexed.
+ $fields = $query->getIndex()->options['fields'];
+ $required = array('search_api_access_node', 'status');
+ if (!$is_comment) {
+ $required[] = 'author';
+ }
+ foreach ($required as $field) {
+ if (empty($fields[$field])) {
+ $vars['@field'] = $field;
+ $vars['@index'] = $query->getIndex()->name;
+ throw new SearchApiException(t('Required field @field not indexed on index @index. Could not perform access checks.', $vars));
+ }
+ }
+
+ // If the user cannot access content/comments at all, return no results.
+ if (!user_access('access content', $account) || ($is_comment && !user_access('access comments', $account))) {
// Simple hack for returning no results.
$query->condition('status', 0);
$query->condition('status', 1);
@@ -1655,43 +2152,45 @@ function _search_api_query_add_node_access($account, SearchApiQueryInterface $qu
return;
}
- // Only filter for user which don't have full node access.
- if (!user_access('bypass node access', $account)) {
- // Filter by node "published" status.
- if (user_access('view own unpublished content')) {
- $filter = $query->createFilter('OR');
- $filter->condition('status', NODE_PUBLISHED);
- $filter->condition('author', $account->uid);
- $query->filter($filter);
- }
- else {
- $query->condition('status', NODE_PUBLISHED);
- }
- // Filter by node access grants.
+ // Filter by the "published" status.
+ $published = $is_comment ? COMMENT_PUBLISHED : NODE_PUBLISHED;
+ if (!$is_comment && user_access('view own unpublished content')) {
$filter = $query->createFilter('OR');
- $grants = node_access_grants('view', $account);
- foreach ($grants as $realm => $gids) {
- foreach ($gids as $gid) {
- $filter->condition('search_api_access_node', "node_access_$realm:$gid");
- }
- }
- $filter->condition('search_api_access_node', 'node_access__all');
+ $filter->condition('status', $published);
+ $filter->condition('author', $account->uid);
$query->filter($filter);
}
+ else {
+ $query->condition('status', $published);
+ }
+
+ // Filter by node access grants.
+ $filter = $query->createFilter('OR');
+ $grants = node_access_grants('view', $account);
+ foreach ($grants as $realm => $gids) {
+ foreach ($gids as $gid) {
+ $filter->condition('search_api_access_node', "node_access_$realm:$gid");
+ }
+ }
+ $filter->condition('search_api_access_node', 'node_access__all');
+ $query->filter($filter);
}
/**
- * Utility function for determining whether a field of the given type contains
- * text data.
+ * Determines whether a field of the given type contains text data.
*
- * @param $type
- * A string containing the type to check.
+ * Can also be used to find other types.
+ *
+ * @param string $type
+ * The type for which to check.
* @param array $allowed
* Optionally, an array of allowed types.
*
- * @return
- * TRUE if $type is either one of the specified types, or a list of such
+ * @return bool
+ * TRUE if $type is either one of the specified types or a list of such
* values. FALSE otherwise.
+ *
+ * @see search_api_extract_inner_type()
*/
function search_api_is_text_type($type, array $allowed = array('text')) {
return array_search(search_api_extract_inner_type($type), $allowed) !== FALSE;
@@ -1704,7 +2203,7 @@ function search_api_is_text_type($type, array $allowed = array('text')) {
* @param $type
* A string containing the type to check.
*
- * @return
+ * @return bool
* TRUE iff $type is a list type ("list<*>").
*/
function search_api_is_list_type($type) {
@@ -1717,7 +2216,7 @@ function search_api_is_list_type($type) {
* @param $type
* A string containing the type to check.
*
- * @return
+ * @return int
* The nesting level of the type. 0 for singular types, 1 for lists of
* singular types, etc.
*/
@@ -1744,7 +2243,7 @@ function search_api_list_nesting_level($type) {
* @param $nested_type
* Another type, determining the nesting level.
*
- * @return
+ * @return string
* A list version of $type, as specified above.
*/
function search_api_nest_type($type, $nested_type) {
@@ -1761,7 +2260,7 @@ function search_api_nest_type($type, $nested_type) {
* @param $type
* A string containing the list type to process.
*
- * @return
+ * @return string
* A string containing the primitive type contained within the list, e.g.
* "text" for "list" (or for "list>"). If $type is no list
* type, it is returned unchanged.
@@ -1783,7 +2282,7 @@ function search_api_extract_inner_type($type) {
*
* Modules implementing other datasource controllers, that use a table other
* than {search_api_item}, can use this function, too. It should be called
- * uncoditionally in a hook_search_api_index_update() implementation. If this
+ * unconditionally in a hook_search_api_index_update() implementation. If this
* function isn't used, similar code should be added there.
*
* However, note that this is only necessary (and this function should only be
@@ -1806,33 +2305,34 @@ function search_api_index_update_datasource(SearchApiIndex $index, $table, $colu
}
/**
- * Utility function for extracting specific fields from an EntityMetadataWrapper
- * object.
+ * Extracts specific field values from an EntityMetadataWrapper object.
*
* @param EntityMetadataWrapper $wrapper
* The wrapper from which to extract fields.
* @param array $fields
* The fields to extract, as stored in an index. I.e., the array keys are
- * field names, the values are arrays with the keys "name", "type", "boost"
- * and "indexed" (although only "type" is used by this function).
+ * field names, the values are arrays with at least a "type" key present.
* @param array $value_options
* An array of options that should be passed to the
* EntityMetadataWrapper::value() method (see there).
*
- * @return
+ * @return array
* The $fields array with additional "value" and "original_type" keys set.
*/
function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields, array $value_options = array()) {
+ $value_options += array(
+ 'identifier' => TRUE,
+ );
// If $wrapper is a list of entities, we have to aggregate their field values.
$wrapper_info = $wrapper->info();
if (search_api_is_list_type($wrapper_info['type'])) {
- foreach ($fields as $field => &$info) {
+ foreach ($fields as &$info) {
$info['value'] = array();
$info['original_type'] = $info['type'];
}
unset($info);
try {
- foreach ($wrapper as $i => $w) {
+ foreach ($wrapper as $w) {
$nested_fields = search_api_extract_fields($w, $fields, $value_options);
foreach ($nested_fields as $field => $info) {
if (isset($info['value'])) {
@@ -1860,19 +2360,31 @@ function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields
$info['original_type'] = $info['type'];
if (isset($wrapper->$field)) {
try {
- $info['value'] = $wrapper->$field->value($value_options);
- // For fulltext fields with options, also include the option labels.
- if (search_api_is_text_type($info['type']) && $wrapper->$field->optionsList('view')) {
- _search_api_add_option_values($info['value'], $wrapper->$field->optionsList('view'));
- }
+ // Set the original type according to the field wrapper's info.
$property_info = $wrapper->$field->info();
$info['original_type'] = $property_info['type'];
- // For entities, we extract the entity ID instead of the whole object.
- // @todo Use 'identifier' => TRUE instead of always loading the object.
- $t = search_api_extract_inner_type($property_info['type']);
- if (isset($entity_infos[$t])) {
- // If no object is set, set this field to NULL.
- $info['value'] = $info['value'] ? _search_api_extract_entity_value($wrapper->$field, search_api_is_text_type($info['type'])) : NULL;
+
+ // Extract the basic value from the field wrapper.
+ $info['value'] = $wrapper->$field->value($value_options);
+
+ // For entities, we need to take care to differentiate between
+ // entities with ID 0 and empty fields. In the latter case, the
+ // wrapper's value() method returns, when called with "identifier =
+ // TRUE", FALSE instead of the (more logical) NULL.
+ $is_entity = isset($entity_infos[search_api_extract_inner_type($property_info['type'])]);
+ if ($is_entity && $info['value'] === FALSE) {
+ $info['value'] = NULL;
+ }
+
+ // If we index the field as fulltext, we also include the entity label
+ // or option list label, if applicable.
+ if (search_api_is_text_type($info['type']) && isset($info['value'])) {
+ if ($wrapper->$field->optionsList('view')) {
+ _search_api_add_option_values($info['value'], $wrapper->$field->optionsList('view'));
+ }
+ elseif ($is_entity) {
+ $info['value'] = _search_api_extract_entity_value($wrapper->$field, TRUE);
+ }
}
}
catch (EntityMetadataWrapperException $e) {
@@ -1896,7 +2408,7 @@ function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields
}
}
else {
- foreach ($nested_fields as $field => &$info) {
+ foreach ($nested_fields as &$info) {
$info['value'] = NULL;
$info['original_type'] = $info['type'];
}
@@ -1975,7 +2487,7 @@ function search_api_server_load($id, $reset = FALSE) {
* @param bool $reset
* Whether to reset the internal entity_load cache.
*
- * @return array
+ * @return SearchApiServer[]
* An array of server objects keyed by machine name.
*/
function search_api_server_load_multiple($ids = array(), $conditions = array(), $reset = FALSE) {
@@ -1993,6 +2505,13 @@ function search_api_server_url(SearchApiServer $server) {
);
}
+/**
+ * Title callback for viewing or editing a server or index.
+ */
+function search_api_admin_item_title($object) {
+ return $object->name;
+}
+
/**
* Title callback for determining which title should be displayed for the
* "delete" local task.
@@ -2007,6 +2526,19 @@ function search_api_title_delete_page(Entity $entity) {
return $entity->hasStatus(ENTITY_OVERRIDDEN) ? t('Revert') : t('Delete');
}
+/**
+ * Determines whether the current user can disable a server or index.
+ *
+ * @param Entity $entity
+ * The server or index for which the access to the "disable" page is checked.
+ *
+ * @return bool
+ * TRUE if the "disable" page can be accessed by the user, FALSE otherwise.
+ */
+function search_api_access_disable_page(Entity $entity) {
+ return user_access('administer search_api') && !empty($entity->enabled);
+}
+
/**
* Access callback for determining if a server's or index' "delete" page should
* be accessible.
@@ -2014,13 +2546,22 @@ function search_api_title_delete_page(Entity $entity) {
* @param Entity $entity
* The server or index for which the access to the delete page is checked.
*
- * @return
+ * @return bool
* TRUE if the delete page can be accessed by the user, FALSE otherwise.
*/
function search_api_access_delete_page(Entity $entity) {
return user_access('administer search_api') && $entity->hasStatus(ENTITY_CUSTOM);
}
+/**
+ * Determines whether a user can access a certain search server or index.
+ *
+ * Used as an access callback in search_api_entity_info().
+ */
+function search_api_entity_access() {
+ return user_access('administer search_api');
+}
+
/**
* Inserts a new search server into the database.
*
@@ -2040,13 +2581,13 @@ function search_api_server_insert(array $values) {
/**
* Changes a server's settings.
*
- * @param $id
+ * @param string|int $id
* The ID or machine name of the server whose values should be changed.
* @param array $fields
* The new field values to set. The enabled field can't be set this way, use
* search_api_server_enable() and search_api_server_disable() instead.
*
- * @return
+ * @return int|false
* 1 if fields were changed, 0 if the fields already had the desired values.
* FALSE on failure.
*/
@@ -2057,13 +2598,14 @@ function search_api_server_edit($id, array $fields) {
}
/**
- * Enables a search server. Will also check for remembered tasks for this server
- * and execute them.
+ * Enables a search server.
*
- * @param $id
+ * Will also check for remembered tasks for this server and execute them.
+ *
+ * @param string|int $id
* The ID or machine name of the server to enable.
*
- * @return
+ * @return int|false
* 1 on success, 0 or FALSE on failure.
*/
function search_api_server_enable($id) {
@@ -2073,12 +2615,14 @@ function search_api_server_enable($id) {
}
/**
- * Disables a search server, along with all associated indexes.
+ * Disables a search server.
*
- * @param $id
+ * Will also disable all associated indexes and remove them from the server.
+ *
+ * @param string|int $id
* The ID or machine name of the server to disable.
*
- * @return
+ * @return int|false
* 1 on success, 0 or FALSE on failure.
*/
function search_api_server_disable($id) {
@@ -2087,13 +2631,37 @@ function search_api_server_disable($id) {
return $ret ? 1 : $ret;
}
+/**
+ * Clears a search server.
+ *
+ * Will delete all items stored on the server and mark all associated indexes
+ * for re-indexing.
+ *
+ * @param int|string $id
+ * The ID or machine name of the server to clear.
+ *
+ * @return bool
+ * TRUE on success, FALSE on failure.
+ */
+function search_api_server_clear($id) {
+ $server = search_api_server_load($id);
+ $success = TRUE;
+ foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+ $success &= $index->reindex();
+ }
+ if ($success) {
+ $server->deleteItems();
+ }
+ return $success;
+}
+
/**
* Deletes a search server and disables all associated indexes.
*
* @param $id
* The ID or machine name of the server to delete.
*
- * @return
+ * @return int|false
* 1 on success, 0 or FALSE on failure.
*/
function search_api_server_delete($id) {
@@ -2110,12 +2678,12 @@ function search_api_server_delete($id) {
* @param $reset
* Whether to reset the internal cache.
*
- * @return SearchApiIndex
- * A completely loaded index object, or NULL if no such index exists.
+ * @return SearchApiIndex|false
+ * A completely loaded index object, or FALSE if no such index exists.
*/
function search_api_index_load($id, $reset = FALSE) {
$ret = search_api_index_load_multiple(array($id), array(), $reset);
- return $ret ? reset($ret) : FALSE;
+ return reset($ret);
}
/**
@@ -2132,7 +2700,7 @@ function search_api_index_load($id, $reset = FALSE) {
* @param bool $reset
* Whether to reset the internal entity_load cache.
*
- * @return array
+ * @return SearchApiIndex[]
* An array of index objects keyed by machine name.
*/
function search_api_index_load_multiple($ids = array(), $conditions = array(), $reset = FALSE) {
@@ -2169,14 +2737,43 @@ function search_api_index_url(SearchApiIndex $index) {
}
/**
- * Property callback.
+ * Returns an index's server.
*
- * @return SearchApiServer
- * The server this index currently resides on, or NULL if the index
- * is currently unassigned.
+ * Used as a property getter callback for the index's "server_entity" prioperty
+ * in search_api_entity_property_info().
+ *
+ * @param SearchApiIndex $index
+ * The index whose server should be returned.
+ *
+ * @return SearchApiServer|null
+ * The server this index currently resides on, or NULL if the index is
+ * currently unassigned.
*/
function search_api_index_get_server(SearchApiIndex $index) {
- return $index->server();
+ try {
+ return $index->server();
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ return NULL;
+ }
+}
+
+/**
+ * Returns an options list for the "status" property.
+ *
+ * Used as an options list callback in search_api_entity_property_info().
+ *
+ * @return array
+ * An array of options, as defined by hook_options_list().
+ */
+function search_api_status_options_list() {
+ return array(
+ ENTITY_CUSTOM => t('Custom'),
+ ENTITY_IN_CODE => t('Default'),
+ ENTITY_OVERRIDDEN => t('Overridden'),
+ ENTITY_FIXED => t('Fixed'),
+ );
}
/**
@@ -2198,12 +2795,12 @@ function search_api_index_insert(array $values) {
/**
* Changes an index' settings.
*
- * @param $id
- * The edited index' id.
+ * @param int|string $id
+ * The edited index' ID or machine name.
* @param array $fields
* The new field values to set.
*
- * @return
+ * @return int|false
* 1 if fields were changed, 0 if the fields already had the desired values.
* FALSE on failure.
*/
@@ -2216,12 +2813,12 @@ function search_api_index_edit($id, array $fields) {
/**
* Changes an index' indexed field settings.
*
- * @param $id
+ * @param int|string $id
* The ID or machine name of the index whose fields should be changed.
* @param array $fields
* The new indexed field settings.
*
- * @return
+ * @return int|false
* 1 if the field settings were changed, 0 if they already had the desired
* values. FALSE on failure.
*/
@@ -2236,14 +2833,14 @@ function search_api_index_edit_fields($id, array $fields) {
/**
* Enables a search index.
*
- * @param $id
+ * @param string|int $id
* The ID or machine name of the index to enable.
*
- * @throws SearchApiException
- * If the index' server isn't enabled.
- *
- * @return
+ * @return int|false
* 1 on success, 0 or FALSE on failure.
+ *
+ * @throws SearchApiException
+ * If the index's server doesn't exist.
*/
function search_api_index_enable($id) {
$index = search_api_index_load($id, TRUE);
@@ -2254,11 +2851,14 @@ function search_api_index_enable($id) {
/**
* Disables a search index.
*
- * @param $id
+ * @param string|int $id
* The ID or machine name of the index to disable.
*
- * @return
+ * @return int|false
* 1 on success, 0 or FALSE on failure.
+ *
+ * @throws SearchApiException
+ * If the index's server doesn't exist.
*/
function search_api_index_disable($id) {
$index = search_api_index_load($id, TRUE);
@@ -2272,7 +2872,7 @@ function search_api_index_disable($id) {
* @param $id
* The ID or machine name of the index to re-index.
*
- * @return
+ * @return bool
* TRUE on success, FALSE on failure.
*/
function search_api_index_reindex($id) {
@@ -2288,42 +2888,6 @@ function search_api_index_reindex($id) {
*/
function _search_api_index_reindex(SearchApiIndex $index) {
$index->datasource()->trackItemChange(FALSE, array($index), TRUE);
- _search_api_empty_cron_queue($index);
-}
-
-/**
- * Helper method for removing all of an index's jobs from the cron queue.
- *
- * @param SearchApiIndex $index
- * The index whose jobs should be removed.
- * @param $mark_changed
- * If TRUE, mark all items in the queue as "changed" again. Defaults to FALSE.
- */
-function _search_api_empty_cron_queue(SearchApiIndex $index, $mark_changed = FALSE) {
- $index_id = $index->machine_name;
- $queue = DrupalQueue::get('search_api_indexing_queue');
- $queue->createQueue();
- $ids = array();
- $release_items = array();
- while ($item = $queue->claimItem()) {
- if ($item->data['index'] === $index_id) {
- $queue->deleteItem($item);
- if ($mark_changed) {
- $ids = array_merge($ids, $item->data['items']);
- }
- }
- else {
- $release_items[] = $item;
- }
- }
-
- foreach ($release_items as $item) {
- $queue->releaseItem($item);
- }
-
- if ($ids) {
- $index->datasource()->trackItemChange($ids, array($index), TRUE);
- }
}
/**
@@ -2332,7 +2896,7 @@ function _search_api_empty_cron_queue(SearchApiIndex $index, $mark_changed = FAL
* @param $id
* The ID or machine name of the index to clear.
*
- * @return
+ * @return bool
* TRUE on success, FALSE on failure.
*/
function search_api_index_clear($id) {
@@ -2346,7 +2910,7 @@ function search_api_index_clear($id) {
* @param $id
* The ID or machine name of the index to delete.
*
- * @return
+ * @return bool
* TRUE on success, FALSE on failure.
*/
function search_api_index_delete($id) {
@@ -2358,6 +2922,65 @@ function search_api_index_delete($id) {
return TRUE;
}
+/**
+ * Sanitizes field values returned from the server.
+ *
+ * @param array $values
+ * The field values, as returned from the server. See
+ * SearchApiQueryInterface::execute() for documentation on the structure.
+ *
+ * @return array
+ * An associative array of field IDs mapped to their sanitized values (scalar
+ * or array-valued).
+ */
+function search_api_get_sanitized_field_values(array $values) {
+ // Sanitize the field values returned from the server. Usually we use
+ // check_plain(), but this can be overridden by setting the field value to
+ // an array with "#value" and "#sanitize_callback" keys.
+ foreach ($values as $field_id => $field_value) {
+ if (is_array($field_value)
+ && isset($field_value['#sanitize_callback'])
+ && ($field_value['#sanitize_callback'] === FALSE || is_callable($field_value['#sanitize_callback']))
+ && array_key_exists('#value', $field_value)
+ ) {
+ $sanitize_callback = $field_value['#sanitize_callback'];
+ $field_value = $field_value['#value'];
+ }
+ else {
+ $sanitize_callback = 'check_plain';
+ }
+ if ($sanitize_callback !== FALSE) {
+ $field_value = search_api_sanitize_field_value($field_value, $sanitize_callback);
+ }
+ $values[$field_id] = $field_value;
+ }
+ return $values;
+}
+
+/**
+ * Sanitizes the given field value(s).
+ *
+ * @param mixed $field_value
+ * A scalar field value, or an array of field values.
+ * @param callable $sanitize_callback
+ * (optional) The callback to use for sanitizing a scalar value.
+ *
+ * @return mixed
+ * The sanitized field value(s).
+ */
+function search_api_sanitize_field_value($field_value, $sanitize_callback = 'check_plain') {
+ if ($field_value === NULL) {
+ return $field_value;
+ }
+ if (is_scalar($field_value)) {
+ return call_user_func($sanitize_callback, $field_value);
+ }
+ foreach ($field_value as &$nested_value) {
+ $nested_value = search_api_sanitize_field_value($nested_value, $sanitize_callback);
+ }
+ return $field_value;
+}
+
/**
* Options list callback for search indexes.
*
@@ -2376,39 +2999,63 @@ function search_api_index_options_list() {
}
/**
- * Cron queue worker callback for indexing some items.
+ * Options list callback for entity types.
*
- * @param array $task
- * An associative array containing:
- * - index: The ID of the index on which items should be indexed.
- * - items: The items that should be indexed.
+ * Will only include entity types which specify entity property information.
*
- * @return
- * The number of successfully indexed items.
+ * @return string[]
+ * An array of entity type machine names mapped to their human-readable
+ * names.
*/
-function _search_api_indexing_queue_process(array $task) {
- $index = search_api_index_load($task['index']);
- try {
- if ($index && $index->enabled && !$index->read_only && $task['items']) {
- $indexed = search_api_index_specific_items($index, $task['items']);
- $num = count($indexed);
- // If some items couldn't be indexed, mark them as dirty again.
- if ($num < count($task['items'])) {
- // Believe it or not but this is actually quite faster than the equivalent
- // $diff = array_diff($task['items'], $indexed);
- $diff = array_keys(array_diff_key(array_flip($task['items']), array_flip($indexed)));
- // Mark the items as dirty again.
- $index->datasource()->trackItemChange($diff, array($index), TRUE);
- }
- if ($num) {
- watchdog('search_api', t('Indexed @num items for index @name', array('@num' => $num, '@name' => $index->name)), NULL, WATCHDOG_INFO);
- }
- return $num;
+function search_api_entity_type_options_list() {
+ $types = array();
+ foreach (array_keys(entity_get_property_info()) as $type) {
+ $info = entity_get_info($type);
+ if ($info) {
+ $types[$type] = $info['label'];
}
}
- catch (SearchApiException $e) {
- watchdog_exception('search_api', $e);
+ return $types;
+}
+
+/**
+ * Options list callback for entity type bundles.
+ *
+ * Will include all bundles for all entity types which specify entity property
+ * information, in a format combining both entity type and bundle.
+ *
+ * @return string[]
+ * An array of bundle identifiers mapped to their human-readable names.
+ */
+function search_api_combined_bundle_options_list() {
+ $types = array();
+ foreach (array_keys(entity_get_property_info()) as $type) {
+ $info = entity_get_info($type);
+ if (!empty($info['bundles'])) {
+ foreach ($info['bundles'] as $bundle => $bundle_info) {
+ $types["$type:$bundle"] = $bundle_info['label'];
+ }
+ }
}
+ return $types;
+}
+
+/**
+ * Retrieves a human-readable label for a multi-type index item.
+ *
+ * Provided as a non-object alternative to
+ * SearchApiCombinedEntityDataSourceController::getItemLabel() so it can be used
+ * as a getter callback.
+ *
+ * @param object $item
+ * An item of the "multiple" item type.
+ *
+ * @return string|null
+ * Either a human-readable label for the item, or NULL if none is available.
+ */
+function search_api_get_multi_type_item_label($item) {
+ $label = entity_label($item->item_type, $item->{$item->item_type});
+ return $label ? $label : NULL;
}
/**
@@ -2468,18 +3115,117 @@ function _search_api_convert_custom_type($callback, $value, $original_type, $typ
}
/**
- * Create and set a batch for indexing items.
+ * Determines the number of items indexed on a server for a certain index.
+ *
+ * Used as a helper function in search_api_admin_index_view().
+ *
+ * @param SearchApiIndex $index
+ * The index
+ *
+ * @return int
+ * The number of items found on the server for this index, if the latter is
+ * enabled. 0 otherwise.
+ *
+ * @throws SearchApiException
+ * If an error prevented the search from completing.
+ */
+function _search_api_get_items_on_server(SearchApiIndex $index) {
+ if (!$index->enabled) {
+ return 0;
+ }
+ // We want the raw count, without facets or other filters. Therefore we don't
+ // use the query's execute() method but pass it straight to the server for
+ // evaluation. Since this circumvents the normal preprocessing, which sets the
+ // fields (on which some service classes might even rely when there are no
+ // keywords), we set them manually here.
+ $query = $index->query()
+ ->fields(array())
+ ->range(0, 0);
+ $response = $index->server()->search($query);
+ return $response['result count'];
+}
+
+/**
+ * Returns a deep copy of the input array.
+ *
+ * The behavior of PHP regarding arrays with references pointing to it is rather
+ * weird. Therefore, we use this helper function in theme_search_api_index() to
+ * create safe copies of such arrays.
+ *
+ * @param array $array
+ * The array to copy.
+ *
+ * @return array
+ * A deep copy of the array.
+ */
+function _search_api_deep_copy(array $array) {
+ $copy = array();
+ foreach ($array as $k => $v) {
+ if (is_array($v)) {
+ $copy[$k] = _search_api_deep_copy($v);
+ }
+ elseif (is_object($v)) {
+ $copy[$k] = clone $v;
+ }
+ elseif ($v) {
+ $copy[$k] = $v;
+ }
+ }
+ return $copy;
+}
+
+/**
+ * Reacts to a change in the bundle of an entity.
+ *
+ * Used as a helper function in search_api_entity_update().
+ *
+ * @param $type
+ * The entity's type.
+ * @param $id
+ * The entity's ID.
+ * @param $old_bundle
+ * The entity's previous bundle.
+ * @param $new_bundle
+ * The entity's new bundle.
+ */
+function _search_api_entity_datasource_bundle_change($type, $id, $old_bundle, $new_bundle) {
+ $controller = search_api_get_datasource_controller($type);
+ $conditions = array(
+ 'enabled' => 1,
+ 'item_type' => $type,
+ 'read_only' => 0,
+ );
+ foreach (search_api_index_load_multiple(FALSE, $conditions) as $index) {
+ if (!empty($index->options['datasource']['bundles'])) {
+ $bundles = drupal_map_assoc($index->options['datasource']['bundles']);
+ if (empty($bundles[$new_bundle]) != empty($bundles[$old_bundle])) {
+ if (empty($bundles[$new_bundle])) {
+ $controller->trackItemDelete(array($id), array($index));
+ }
+ else {
+ $controller->trackItemInsert(array($id), array($index));
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Creates and sets a batch for indexing items.
*
* @param SearchApiIndex $index
* The index for which items should be indexed.
- * @param $batch_size
+ * @param int $batch_size
* Number of items to index per batch.
- * @param $limit
- * Maximum number of items to index.
- * @param $remaining
+ * @param int $limit
+ * Maximum number of items to index. Negative values mean "no limit".
+ * @param int $remaining
* Remaining items to index.
- * @param $drush
+ * @param bool $drush
* Boolean specifying whether this was called from drush or not.
+ *
+ * @return bool
+ * Whether the batch was created and set successfully.
*/
function _search_api_batch_indexing_create(SearchApiIndex $index, $batch_size, $limit, $remaining, $drush = FALSE) {
if ($limit !== 0 && $batch_size !== 0) {
@@ -2517,10 +3263,10 @@ function _search_api_batch_indexing_create(SearchApiIndex $index, $batch_size, $
* Maximum number of items to index.
* @param boolean $drush
* Boolean specifying whether this was called from drush or not.
- * @param array $context
- * The batch context.
+ * @param $context
+ * An array (or object implementing ArrayAccess) containing the batch context.
*/
-function _search_api_batch_indexing_callback(SearchApiIndex $index, $batch_size, $limit, $drush = FALSE, array &$context) {
+function _search_api_batch_indexing_callback(SearchApiIndex $index, $batch_size, $limit, $drush = FALSE, &$context) {
// Persistent data among batch runs.
if (!isset($context['sandbox']['limit'])) {
$context['sandbox']['limit'] = $limit;
@@ -2539,8 +3285,18 @@ function _search_api_batch_indexing_callback(SearchApiIndex $index, $batch_size,
$to_index = min($context['sandbox']['limit'] - $context['sandbox']['progress'], $context['sandbox']['batch_size']);
// Index the items.
- $indexed = search_api_index_items($index, $to_index);
- $context['results']['indexed'] += $indexed;
+ try {
+ $indexed = search_api_index_items($index, $to_index);
+ $context['results']['indexed'] += $indexed;
+ }
+ catch (SearchApiException $e) {
+ watchdog_exception('search_api', $e);
+ $vars['@message'] = $e->getMessage();
+ $context['message'] = t('An error occurred during indexing: @message.', $vars);
+ $context['finished'] = 1;
+ $context['results']['not indexed'] += $context['sandbox']['limit'] - $context['sandbox']['progress'];
+ return;
+ }
// Display progress message.
if ($indexed > 0) {
@@ -2568,13 +3324,11 @@ function _search_api_batch_indexing_callback(SearchApiIndex $index, $batch_size,
* Batch API finishing callback for the indexing functionality.
*
* @param boolean $success
- * Result of the batch operation.
+ * Whether the batch finished successfully.
* @param array $results
- * Results.
- * @param array $operations
- * Remaining batch operation to process.
+ * Detailed information about the result.
*/
-function _search_api_batch_indexing_finished($success, $results, $operations) {
+function _search_api_batch_indexing_finished($success, $results) {
// Check if called from drush.
if (!empty($results['drush'])) {
$drupal_set_message = 'drush_log';
diff --git a/search_api.rules.inc b/search_api.rules.inc
index eef4cc08..0725f4ce 100644
--- a/search_api.rules.inc
+++ b/search_api.rules.inc
@@ -10,7 +10,7 @@
* Implements hook_rules_action_info().
*/
function search_api_rules_action_info() {
- $items['search_api_index'] = array (
+ $items['search_api_index'] = array(
'parameter' => array(
'entity' => array(
'type' => 'entity',
@@ -52,6 +52,9 @@ function _search_api_rules_access() {
* Rules action for indexing an item.
*/
function _search_api_rules_action_index(EntityDrupalWrapper $wrapper, SearchApiIndex $index = NULL, $index_immediately = TRUE) {
+ // If we do not have an index, we need to guess the item type to use.
+ // @todo Since this can only be used with entities anyways, we can just loop
+ // over the item type information and use all types with that entity type.
$type = $wrapper->type();
$item_ids = array($wrapper->getIdentifier());
@@ -61,6 +64,7 @@ function _search_api_rules_action_index(EntityDrupalWrapper $wrapper, SearchApiI
}
if ($index) {
+ $type = $index->item_type;
$indexes = array($index);
}
else {
diff --git a/search_api.test b/search_api.test
index 9157397a..76a89705 100644
--- a/search_api.test
+++ b/search_api.test
@@ -1,29 +1,66 @@
assertResponse(200, t('HTTP code 200 returned.'));
+ $this->assertResponse(200, 'HTTP code 200 returned.');
return $ret;
}
+ /**
+ * Overrides DrupalWebTestCase::drupalPost().
+ *
+ * Additionally asserts that the HTTP request returned a 200 status code.
+ */
protected function drupalPost($path, $edit, $submit, array $options = array(), array $headers = array(), $form_html_id = NULL, $extra_post = NULL) {
$ret = parent::drupalPost($path, $edit, $submit, $options, $headers, $form_html_id, $extra_post);
- $this->assertResponse(200, t('HTTP code 200 returned.'));
+ $this->assertResponse(200, 'HTTP code 200 returned.');
return $ret;
}
+ /**
+ * Returns information about this test case.
+ *
+ * @return array
+ * An array with information about this test case.
+ */
public static function getInfo() {
return array(
'name' => 'Test search API framework',
@@ -32,81 +69,100 @@ class SearchApiWebTest extends DrupalWebTestCase {
);
}
+ /**
+ * {@inheritdoc}
+ */
public function setUp() {
parent::setUp('entity', 'search_api', 'search_api_test');
}
+ /**
+ * Tests correct admin UI, indexing and search behavior.
+ *
+ * We only use a single test method to avoid wasting ressources on setting up
+ * the test environment multiple times. This will be the only method called
+ * by the Simpletest framework (since the method name starts with "test"). It
+ * in turn calls other methdos that set up the environment in a certain way
+ * and then run tests on it.
+ */
public function testFramework() {
+ module_enable(array('search_api_test_2'));
$this->drupalLogin($this->drupalCreateUser(array('administer search_api')));
- // @todo Why is there no default index?
- //$this->deleteDefaultIndex();
$this->insertItems();
- $this->checkOverview1();
$this->createIndex();
- $this->insertItems(5);
+ $this->insertItems();
$this->createServer();
- $this->checkOverview2();
+ $this->checkOverview();
$this->enableIndex();
$this->searchNoResults();
$this->indexItems();
$this->searchSuccess();
+ $this->checkIndexingOrder();
$this->editServer();
$this->clearIndex();
$this->searchNoResults();
$this->deleteServer();
+ $this->disableModules();
}
- protected function deleteDefaultIndex() {
- $this->drupalPost('admin/config/search/search_api/index/default_node_index/delete', array(), t('Confirm'));
+ /**
+ * Returns the test server in use by this test case.
+ *
+ * @return SearchApiServer
+ * The test server.
+ */
+ protected function server() {
+ return search_api_server_load($this->server_id, TRUE);
}
- protected function insertItems($offset = 0) {
+ /**
+ * Returns the test index in use by this test case.
+ *
+ * @return SearchApiIndex
+ * The test index.
+ */
+ protected function index() {
+ return search_api_index_load($this->index_id, TRUE);
+ }
+
+ /**
+ * Inserts some test items into the database, via the test module.
+ *
+ * @param int $number
+ * The number of items to insert.
+ *
+ * @see insertItem()
+ */
+ protected function insertItems($number = 5) {
$count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField();
- $this->insertItem(array(
- 'id' => $offset + 1,
- 'title' => 'Title 1',
- 'body' => 'Body text 1.',
- 'type' => 'Item',
- ));
- $this->insertItem(array(
- 'id' => $offset + 2,
- 'title' => 'Title 2',
- 'body' => 'Body text 2.',
- 'type' => 'Item',
- ));
- $this->insertItem(array(
- 'id' => $offset + 3,
- 'title' => 'Title 3',
- 'body' => 'Body text 3.',
- 'type' => 'Item',
- ));
- $this->insertItem(array(
- 'id' => $offset + 4,
- 'title' => 'Title 4',
- 'body' => 'Body text 4.',
- 'type' => 'Page',
- ));
- $this->insertItem(array(
- 'id' => $offset + 5,
- 'title' => 'Title 5',
- 'body' => 'Body text 5.',
- 'type' => 'Page',
- ));
+ for ($i = 1; $i <= $number; ++$i) {
+ $id = $count + $i;
+ $this->insertItem(array(
+ 'id' => $id,
+ 'title' => "Title $id",
+ 'body' => "Body text $id.",
+ 'type' => 'Item',
+ ));
+ }
$count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() - $count;
- $this->assertEqual($count, 5, t('@count items inserted.', array('@count' => $count)));
+ $this->assertEqual($count, $number, "$number items successfully inserted.");
}
- protected function insertItem($values) {
+ /**
+ * Helper function for inserting a single test item.
+ *
+ * @param array $values
+ * The property values of the test item.
+ *
+ * @see search_api_test_insert_item()
+ */
+ protected function insertItem(array $values) {
$this->drupalPost('search_api_test/insert', $values, t('Save'));
}
- protected function checkOverview1() {
- // This test fails for no apparent reason for drupal.org test bots.
- // Commenting them out for now.
- //$this->drupalGet('admin/config/search/search_api');
- //$this->assertText(t('There are no search servers or indexes defined yet.'), t('"No servers" message is displayed.'));
- }
-
+ /**
+ * Creates a test index via the UI and tests whether this works correctly.
+ */
protected function createIndex() {
$values = array(
'name' => '',
@@ -132,23 +188,23 @@ class SearchApiWebTest extends DrupalWebTestCase {
);
$this->drupalPost(NULL, $values, t('Create index'));
- $this->assertText(t('The index was successfully created. Please set up its indexed fields now.'), t('The index was successfully created.'));
+ $this->assertText(t('The index was successfully created. Please set up its indexed fields now.'), 'The index was successfully created.');
$found = strpos($this->getUrl(), 'admin/config/search/search_api/index/' . $id) !== FALSE;
- $this->assertTrue($found, t('Correct redirect.'));
- $index = search_api_index_load($id, TRUE);
- $this->assertEqual($index->name, $values['name'], t('Name correctly inserted.'));
- $this->assertEqual($index->item_type, $values['item_type'], t('Index item type correctly inserted.'));
- $this->assertFalse($index->enabled, t('Status correctly inserted.'));
- $this->assertEqual($index->description, $values['description'], t('Description correctly inserted.'));
- $this->assertNull($index->server, t('Index server correctly inserted.'));
- $this->assertEqual($index->options['cron_limit'], $values['options[cron_limit]'], t('Cron batch size correctly inserted.'));
+ $this->assertTrue($found, 'Correct redirect.');
+ $index = $this->index();
+ $this->assertEqual($index->name, $values['name'], 'Name correctly inserted.');
+ $this->assertEqual($index->item_type, $values['item_type'], 'Index item type correctly inserted.');
+ $this->assertFalse($index->enabled, 'Status correctly inserted.');
+ $this->assertEqual($index->description, $values['description'], 'Description correctly inserted.');
+ $this->assertNull($index->server, 'Index server correctly inserted.');
+ $this->assertEqual($index->options['cron_limit'], $values['options[cron_limit]'], 'Cron batch size correctly inserted.');
$values = array(
'additional[field]' => 'parent',
);
$this->drupalPost("admin/config/search/search_api/index/$id/fields", $values, t('Add fields'));
- $this->assertText(t('The available fields were successfully changed.'), t('Successfully added fields.'));
- $this->assertText('Parent » ID', t('!field displayed.', array('!field' => t('Added fields are'))));
+ $this->assertText(t('The available fields were successfully changed.'), 'Successfully added fields.');
+ $this->assertText('Parent » ID', 'Added fields are displayed.');
$values = array(
'fields[id][type]' => 'integer',
@@ -177,7 +233,7 @@ class SearchApiWebTest extends DrupalWebTestCase {
'fields[parent:type][indexed]' => 1,
);
$this->drupalPost(NULL, $values, t('Save changes'));
- $this->assertText(t('The indexed fields were successfully changed. The index was cleared and will have to be re-indexed with the new settings.'), t('Field settings saved.'));
+ $this->assertText(t('The indexed fields were successfully changed. The index was cleared and will have to be re-indexed with the new settings.'), 'Field settings saved.');
$values = array(
'callbacks[search_api_alter_add_url][status]' => 1,
@@ -209,18 +265,18 @@ class SearchApiWebTest extends DrupalWebTestCase {
'callbacks[search_api_alter_add_aggregation][settings][fields][search_api_aggregation_1][fields][parent:body]' => 1,
);
$this->drupalPost(NULL, $values, t('Save configuration'));
- $this->assertText(t("The search index' workflow was successfully edited. All content was scheduled for re-indexing so the new settings can take effect."), t('Workflow successfully edited.'));
+ $this->assertText(t("The indexing workflow was successfully edited. All content was scheduled for re-indexing so the new settings can take effect."), 'Workflow successfully edited.');
$this->drupalGet("admin/config/search/search_api/index/$id");
- $this->assertTitle('Search API test index | Drupal', t('Correct title when viewing index.'));
- $this->assertText('An index used for testing.', t('!field displayed.', array('!field' => t('Description'))));
- $this->assertText('Search API test entity', t('!field displayed.', array('!field' => t('Item type'))));
- $this->assertText(format_plural(1, '1 item per cron batch.', '@count items per cron batch.'), t('!field displayed.', array('!field' => t('Cron batch size'))));
-
- $this->drupalGet("admin/config/search/search_api/index/$id/status");
- $this->assertText(t('The index is currently disabled.'), t('"Disabled" status displayed.'));
+ $this->assertTitle('Search API test index | Drupal', 'Correct title when viewing index.');
+ $this->assertText('An index used for testing.', 'Description displayed.');
+ $this->assertText('Search API test entity', 'Item type displayed.');
+ $this->assertText(t('disabled'), '"Disabled" status displayed.');
}
+ /**
+ * Creates a test server via the UI and tests whether this works correctly.
+ */
protected function createServer() {
$values = array(
'name' => '',
@@ -249,100 +305,375 @@ class SearchApiWebTest extends DrupalWebTestCase {
$this->assertText(t('The server was successfully created.'));
$found = strpos($this->getUrl(), 'admin/config/search/search_api/server/' . $id) !== FALSE;
- $this->assertTrue($found, t('Correct redirect.'));
- $server = search_api_server_load($id, TRUE);
- $this->assertEqual($server->name, $values['name'], t('Name correctly inserted.'));
- $this->assertTrue($server->enabled, t('Status correctly inserted.'));
- $this->assertEqual($server->description, $values['description'], t('Description correctly inserted.'));
- $this->assertEqual($server->class, $values['class'], t('Service class correctly inserted.'));
- $this->assertEqual($server->options['test'], $values2['options[form][test]'], t('Service options correctly inserted.'));
- $this->assertTitle('Search API test server | Drupal', t('Correct title when viewing server.'));
- $this->assertText('A server used for testing.', t('!field displayed.', array('!field' => t('Description'))));
- $this->assertText('search_api_test_service', t('!field displayed.', array('!field' => t('Service name'))));
- $this->assertText('search_api_test_service description', t('!field displayed.', array('!field' => t('Service description'))));
- $this->assertText('search_api_test foo bar', t('!field displayed.', array('!field' => t('Service options'))));
- }
-
- protected function checkOverview2() {
+ $this->assertTrue($found, 'Correct redirect.');
+ $server = $this->server();
+ $this->assertEqual($server->name, $values['name'], 'Name correctly inserted.');
+ $this->assertTrue($server->enabled, 'Status correctly inserted.');
+ $this->assertEqual($server->description, $values['description'], 'Description correctly inserted.');
+ $this->assertEqual($server->class, $values['class'], 'Service class correctly inserted.');
+ $this->assertEqual($server->options['test'], $values2['options[form][test]'], 'Service options correctly inserted.');
+ $this->assertTitle('Search API test server | Drupal', 'Correct title when viewing server.');
+ $this->assertText('A server used for testing.', 'Description displayed.');
+ $this->assertText('search_api_test_service', 'Service name displayed.');
+ $this->assertText('search_api_test foo bar', 'Service options displayed.');
+ }
+
+ /**
+ * Checks whether the server and index are correctly listed in the overview.
+ */
+ protected function checkOverview() {
$this->drupalGet('admin/config/search/search_api');
- $this->assertText('Search API test server', t('!field displayed.', array('!field' => t('Server'))));
- $this->assertText('Search API test index', t('!field displayed.', array('!field' => t('Index'))));
- $this->assertNoText(t('There are no search servers or indexes defined yet.'), t('"No servers" message not displayed.'));
+ $this->assertText('Search API test server', 'Server displayed.');
+ $this->assertText('Search API test index', 'Index displayed.');
+ $this->assertNoText(t('There are no search servers or indexes defined yet.'), '"No servers" message not displayed.');
}
+ /**
+ * Moves the index onto the server and enables it.
+ */
protected function enableIndex() {
$values = array(
'server' => $this->server_id,
);
$this->drupalPost("admin/config/search/search_api/index/{$this->index_id}/edit", $values, t('Save settings'));
$this->assertText(t('The search index was successfully edited.'));
- $this->assertText('Search API test server', t('!field displayed.', array('!field' => t('Server'))));
+ $this->assertText('Search API test server', 'Server displayed.');
$this->clickLink(t('enable'));
$this->assertText(t('The index was successfully enabled.'));
}
+ /**
+ * Asserts that a search on the index works but yields no results.
+ *
+ * This is the case since no items should have been indexed yet.
+ */
protected function searchNoResults() {
- $this->drupalGet('search_api_test/query/' . $this->index_id);
- $this->assertText('result count = 0', t('No search results returned without indexing.'));
- $this->assertText('results = ()', t('No search results returned without indexing.'));
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 0, 'No search results returned without indexing.');
+ $this->assertEqual(array_keys($results['results']), array(), 'No search results returned without indexing.');
}
+ /**
+ * Executes a search on the test index.
+ *
+ * Helper method used for testing search results.
+ *
+ * @param int|null $offset
+ * (optional) The offset for the returned results.
+ * @param int|null $limit
+ * (optional) The limit for the returned results.
+ *
+ * @return array
+ * Search results as specified by SearchApiQueryInterface::execute().
+ */
+ protected function doSearch($offset = NULL, $limit = NULL) {
+ // Since we change server and index settings via the UI (and, therefore, in
+ // different page requests), the static cache in this page request
+ // (executing the tests) will get stale. Therefore, we clear it before
+ // executing the search.
+ $this->index();
+ $this->server();
+
+ $query = search_api_query($this->index_id);
+ if ($offset || $limit) {
+ $query->range($offset, $limit);
+ }
+ return $query->execute();
+ }
+
+ /**
+ * Tests indexing via the UI "Index now" functionality.
+ *
+ * Asserts that errors during indexing are handled properly and that the
+ * status readings work.
+ */
protected function indexItems() {
- $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/status");
- $this->assertText(t('The index is currently enabled.'), t('"Enabled" status displayed.'));
- $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 10)), t('!field displayed.', array('!field' => t('Correct index status'))));
- $this->assertText(t('Index now'), t('"Index now" button found.'));
- $this->assertText(t('Clear index'), t('"Clear index" button found.'));
- $this->assertNoText(t('Re-index content'), t('"Re-index" button not found.'));
+ $this->checkIndexStatus();
// Here we test the indexing + the warning message when some items
- // can not be indexed.
- // The server refuses (for test purpose) to index items with IDs that are
- // multiples of 8 unless the "search_api_test_index_all" variable is set.
+ // cannot be indexed.
+ // The server refuses (for test purpose) to index the item that has the same
+ // ID as the "search_api_test_indexing_break" variable (default: 8).
+ // Therefore, if we try to index 8 items, only the first seven will be
+ // successfully indexed and a warning should be displayed.
$values = array(
'limit' => 8,
);
$this->drupalPost(NULL, $values, t('Index now'));
$this->assertText(t('Successfully indexed @count items.', array('@count' => 7)));
- $this->assertText(t('1 item could not be indexed. Check the logs for details.'), t('Index errors warning is displayed.'));
- $this->assertNoText(t("Couldn't index items. Check the logs for details."), t("Index error isn't displayed."));
- $this->assertText(t('About @percentage% of all items have been indexed in their latest version (@indexed / @total).', array('@indexed' => 7, '@total' => 10, '@percentage' => 70)), t('!field displayed.', array('!field' => t('Correct index status'))));
- $this->assertText(t('Re-indexing'), t('"Re-index" button found.'));
+ $this->assertText(t('1 item could not be indexed. Check the logs for details.'), 'Index errors warning is displayed.');
+ $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed.");
+ $this->checkIndexStatus(7);
// Here we're testing the error message when no item could be indexed.
- // The item with ID 8 is still not indexed.
+ // The item with ID 8 is still not indexed, but it will be the first to be
+ // indexed now. Therefore, if we try to index a single items, only item 8
+ // will be passed to the server, which will reject it and no items will be
+ // indexed. Since normally this signifies a more serious error than when
+ // only some items couldn't be indexed, this is handled differently.
$values = array(
'limit' => 1,
);
$this->drupalPost(NULL, $values, t('Index now'));
- $this->assertNoPattern('/' . str_replace('144', '-?\d*', t('Successfully indexed @count items.', array('@count' => 144))) . '/', t('No items could be indexed.'));
- $this->assertNoText(t('1 item could not be indexed. Check the logs for details.'), t("Index errors warning isn't displayed."));
- $this->assertText(t("Couldn't index items. Check the logs for details."), t('Index error is displayed.'));
-
- // Here we test the indexing of all the remaining items.
- variable_set('search_api_test_index_all', TRUE);
+ $this->assertNoPattern('/' . str_replace('144', '-?\d*', t('Successfully indexed @count items.', array('@count' => 144))) . '/', 'No items could be indexed.');
+ $this->assertNoText(t('1 item could not be indexed. Check the logs for details.'), "Index errors warning isn't displayed.");
+ $this->assertText(t("Couldn't index items. Check the logs for details."), 'Index error is displayed.');
+
+ // No we set the "search_api_test_indexing_break" variable to 0, so all
+ // items will be indexed. The remaining items (8, 9, 10) should therefore
+ // be successfully indexed and no warning should show.
+ variable_set('search_api_test_indexing_break', 0);
$values = array(
'limit' => -1,
);
$this->drupalPost(NULL, $values, t('Index now'));
$this->assertText(t('Successfully indexed @count items.', array('@count' => 3)));
- $this->assertNoText(t("Some items couldn't be indexed. Check the logs for details."), t("Index errors warning isn't displayed."));
- $this->assertNoText(t("Couldn't index items. Check the logs for details."), t("Index error isn't displayed."));
- $this->assertText(t('All items have been indexed (@indexed / @total).', array('@indexed' => 10, '@total' => 10)), t('!field displayed.', array('!field' => t('Correct index status'))));
- $this->assertNoText(t('Index now'), t('"Index now" button no longer displayed.'));
+ $this->assertNoText(t("Some items couldn't be indexed. Check the logs for details."), "Index errors warning isn't displayed.");
+ $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed.");
+ $this->checkIndexStatus(10);
+
+ // Reset the static cache for the server.
+ $this->server();
+ }
+
+ /**
+ * Checks whether the index's "Status" tab shows the correct values.
+ *
+ * Helper method used by indexItems() and others.
+ *
+ * The internal browser will point to the index's "Status" tab after this
+ * method is called.
+ *
+ * @param int $indexed
+ * (optional) The number of items that should be indexed at the moment.
+ * Defaults to 0.
+ * @param int $total
+ * (optional) The (correct) total number of items. Defaults to 10.
+ * @param bool $check_buttons
+ * (optional) Whether to check for the correct presence/absence of buttons.
+ * Defaults to TRUE.
+ * @param int|null $on_server
+ * (optional) The number of items actually on the server. Defaults to
+ * $indexed.
+ */
+ protected function checkIndexStatus($indexed = 0, $total = 10, $check_buttons = TRUE, $on_server = NULL) {
+ $url = "admin/config/search/search_api/index/{$this->index_id}";
+ if (strpos($this->url, $url) === FALSE) {
+ $this->drupalGet($url);
+ }
+
+ $index_status = t('@indexed/@total indexed', array('@indexed' => $indexed, '@total' => $total));
+ $this->assertText($index_status, 'Correct index status displayed.');
+
+ if (!isset($on_server)) {
+ $on_server = $indexed;
+ }
+ $info = format_plural($on_server, 'There is 1 item indexed on the server for this index.', 'There are @count items indexed on the server for this index.');
+ $this->assertText(t('Server index status'), 'Server index status displayed.');
+ $this->assertText($info, 'Correct server index status displayed.');
+
+ if (!$check_buttons) {
+ return;
+ }
+
+ $this->assertText(t('enabled'), '"Enabled" status displayed.');
+ if ($indexed == $total) {
+ $this->assertRaw('disabled="disabled"', '"Index now" form disabled.');
+ }
+ else {
+ $this->assertNoRaw('disabled="disabled"', '"Index now" form enabled.');
+ }
}
+ /**
+ * Tests whether searches yield the right results after indexing.
+ *
+ * The test server only implements range functionality, no kind of fulltext
+ * search capabilities, so we can only test for that.
+ */
protected function searchSuccess() {
- $this->drupalGet('search_api_test/query/' . $this->index_id);
- $this->assertText('result count = 10', t('Correct search result count returned after indexing.'));
- $this->assertText('results = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)', t('Correct search results returned after indexing.'));
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 10, 'Correct search result count returned after indexing.');
+ $this->assertEqual(array_keys($results['results']), array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), 'Correct search results returned after indexing.');
+
+ $results = $this->doSearch(2, 4);
+ $this->assertEqual($results['result count'], 10, 'Correct search result count with ranged query.');
+ $this->assertEqual(array_keys($results['results']), array(3, 4, 5, 6), 'Correct search results with ranged query.');
+ }
+
+ /**
+ * Tests whether items are indexed in the right order.
+ *
+ * The indexing order should always be that new items are indexed before
+ * changed ones, and only then the changed items in the order of their change.
+ *
+ * This method also assures that this behavior is even observed when indexing
+ * temporarily fails.
+ *
+ * @see https://drupal.org/node/2115127
+ */
+ protected function checkIndexingOrder() {
+ // Set cron batch size to 1 so not all items will get indexed right away.
+ // This also ensures that later, when indexing of a single item will be
+ // rejected by using the "search_api_test_indexing_break" variable, this
+ // will have the effect of rejecting "all" items of a batch (since that
+ // batch only consists of a single item).
+ $values = array(
+ 'options[cron_limit]' => 1,
+ );
+ $this->drupalPost("admin/config/search/search_api/index/{$this->index_id}/edit", $values, t('Save settings'));
+ $this->assertText(t('The search index was successfully edited.'));
- $this->drupalGet('search_api_test/query/' . $this->index_id . '/foo/2/4');
- $this->assertText('result count = 10', t('Correct search result count with ranged query.'));
- $this->assertText('results = (3, 4, 5, 6)', t('Correct search results with ranged query.'));
+ // Manually clear the server's item storage – that way, the items will still
+ // count as indexed for the Search API, but won't be returned in searches.
+ // We do this so we have finer-grained control over the order in which items
+ // are indexed.
+ $this->server()->deleteItems();
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 0, 'Indexed items were successfully deleted from the server.');
+ $this->assertEqual(array_keys($results['results']), array(), 'Indexed items were successfully deleted from the server.');
+
+ // Now insert some new items, and mark others as changed. Make sure that
+ // each action has a unique timestamp, so the order will be correct.
+ $this->drupalGet('search_api_test/touch/8');
+ $this->insertItems(1);// item 11
+ sleep(1);
+ $this->drupalGet('search_api_test/touch/2');
+ $this->insertItems(1);// item 12
+ sleep(1);
+ $this->drupalGet('search_api_test/touch/5');
+ $this->insertItems(1);// item 13
+ sleep(1);
+ $this->drupalGet('search_api_test/touch/8');
+ $this->insertItems(1); // item 14
+
+ // Check whether the status display is right.
+ $this->checkIndexStatus(7, 14, FALSE, 0);
+
+ // Indexing order should now be: 11, 12, 13, 14, 8, 2, 4. Let's try it out!
+ // First manually index one item, and see if it's 11.
+ $values = array(
+ 'limit' => 1,
+ );
+ $this->drupalPost(NULL, $values, t('Index now'));
+ $this->assertText(t('Successfully indexed @count item.', array('@count' => 1)));
+ $this->assertNoText(t("Some items couldn't be indexed. Check the logs for details."), "Index errors warning isn't displayed.");
+ $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed.");
+ $this->checkIndexStatus(8, 14, FALSE, 1);
+
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 1, 'Indexing order test 1: correct result count.');
+ $this->assertEqual(array_keys($results['results']), array(11), 'Indexing order test 1: correct results.');
+
+ // Now index with a cron run, but stop at item 8.
+ variable_set('search_api_test_indexing_break', 8);
+ $this->cronRun();
+ // Now just the four new items should have been indexed.
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 4, 'Indexing order test 2: correct result count.');
+ $this->assertEqual(array_keys($results['results']), array(11, 12, 13, 14), 'Indexing order test 2: correct results.');
+
+ // This time stop at item 5 (should be the last one).
+ variable_set('search_api_test_indexing_break', 5);
+ $this->cronRun();
+ // Now all new and changed items should have been indexed, except item 5.
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 6, 'Indexing order test 3: correct result count.');
+ $this->assertEqual(array_keys($results['results']), array(2, 8, 11, 12, 13, 14), 'Indexing order test 3: correct results.');
+
+ // Index the remaining item.
+ variable_set('search_api_test_indexing_break', 0);
+ $this->cronRun();
+ // Now all new and changed items should have been indexed.
+ $results = $this->doSearch();
+ $this->assertEqual($results['result count'], 7, 'Indexing order test 4: correct result count.');
+ $this->assertEqual(array_keys($results['results']), array(2, 5, 8, 11, 12, 13, 14), 'Indexing order test 4: correct results.');
+ }
+
+ /**
+ * Tests whether the server tasks system works correctly.
+ *
+ * Uses the "search_api_test_error_state" variable to trigger exceptions in
+ * the test service class and asserts that the Search API reacts correctly and
+ * re-attempts the operation on the next cron run.
+ */
+ protected function checkServerTasks() {
+ // Make sure none of the previous operations added any tasks.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 0, 'No server tasks were previously saved.');
+
+ // Set error state for test service, so all operations will fail.
+ variable_set('search_api_test_error_state', TRUE);
+
+ // Delete some items.
+ $this->drupalGet('search_api_test/delete/8');
+ $this->drupalGet('search_api_test/delete/12');
+
+ // Assert that the indexed items haven't changed yet.
+ $results = $this->doSearch();
+ $this->assertEqual(array_keys($results['results']), array(2, 5, 8, 11, 12, 13, 14), 'During error state, no indexed items were deleted.');
+
+ // Check that tasks were correctly inserted.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 2, 'Server tasks for deleted items were saved.');
+
+ // Now reset the error state variable and run cron to delete the items.
+ variable_set('search_api_test_error_state', FALSE);
+ $this->cronRun();
+
+ // Assert that the indexed items were indeed deleted from the server.
+ $results = $this->doSearch();
+ $this->assertEqual(array_keys($results['results']), array(2, 5, 11, 13, 14), 'Pending "delete item" server tasks were correctly executed during the cron run.');
+
+ // Check that the tasks were correctly deleted.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 0, 'Server tasks were correctly deleted after being executed.');
+
+ // Now we first delete more items, then disable the server (thereby removing
+ // the index from it) – all while in error state.
+ variable_set('search_api_test_error_state', TRUE);
+ $this->drupalGet('search_api_test/delete/14');
+ $this->drupalGet('search_api_test/delete/2');
+ $settings['enabled'] = 0;
+ $this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/edit", $settings, t('Save settings'));
+
+ // Check whether the index was correctly removed from the server.
+ $this->assertEqual($this->index()->server(), NULL, 'The index was successfully set to have no server.');
+ $exception = FALSE;
+ try {
+ $this->doSearch();
+ }
+ catch (SearchApiException $e) {
+ $exception = TRUE;
+ }
+ $this->assertTrue($exception, 'Searching on the index failed with an exception.');
+
+ // Check that only one task – to remove the index from the server – is now
+ // present in the tasks table.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 1, 'Only the "remove index" task is present in the server tasks.');
+
+ // Reset the error state variable, re-enable the server.
+ variable_set('search_api_test_error_state', FALSE);
+ $settings['enabled'] = 1;
+ $this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/edit", $settings, t('Save settings'));
+
+ // Check whether the index was really removed from the server now.
+ $server = $this->server();
+ $this->assertTrue(empty($server->options['indexes'][$this->index_id]), 'The index was removed from the server after cron ran.');
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 0, 'Server tasks were correctly deleted after being executed.');
+
+ // Put the index back on the server and index some items for the next tests.
+ $settings = array('server' => $this->server_id);
+ $this->drupalPost("admin/config/search/search_api/index/{$this->index_id}/edit", $settings, t('Save settings'));
+ $this->cronRun();
}
+ /**
+ * Tests whether editing the server works correctly.
+ */
protected function editServer() {
$values = array(
'name' => 'test-name-foo',
@@ -351,22 +682,74 @@ class SearchApiWebTest extends DrupalWebTestCase {
);
$this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/edit", $values, t('Save settings'));
$this->assertText(t('The search server was successfully edited.'));
- $this->assertText('test-name-foo', t('!field changed.', array('!field' => t('Name'))));
- $this->assertText('test-description-bar', t('!field changed.', array('!field' => t('Description'))));
- $this->assertText('test-test-baz', t('!field changed.', array('!field' => t('Service options'))));
+ $this->assertText('test-name-foo', 'Name changed.');
+ $this->assertText('test-description-bar', 'Description changed.');
+ $this->assertText('test-test-baz', 'Service options changed.');
}
+ /**
+ * Tests whether clearing the index works correctly.
+ */
protected function clearIndex() {
- $this->drupalPost("admin/config/search/search_api/index/{$this->index_id}/status", array(), t('Clear index'));
+ $this->drupalPost("admin/config/search/search_api/index/{$this->index_id}", array(), t('Clear all indexed data'));
+ $this->drupalPost(NULL, array(), t('Confirm'));
$this->assertText(t('The index was successfully cleared.'));
- $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 10)), t('!field displayed.', array('!field' => t('Correct index status'))));
+ $this->assertText(t('@indexed/@total indexed', array('@indexed' => 0, '@total' => 14)), 'Correct index status displayed.');
}
+ /**
+ * Tests whether deleting the server works correctly.
+ *
+ * The index still lying on the server should be disabled and removed from it.
+ * Also, any tasks with that server's ID should be deleted.
+ */
protected function deleteServer() {
+ // Insert some dummy tasks to check for.
+ $server = $this->server();
+ search_api_server_tasks_add($server, 'foo');
+ search_api_server_tasks_add($server, 'bar', $this->index());
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 2, 'Dummy tasks were added.');
+
+ // Delete the server.
$this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/delete", array(), t('Confirm'));
- $this->assertNoText('test-name-foo', t('Server no longer listed.'));
- $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/status");
- $this->assertText(t('The index is currently disabled.'), t('The index was disabled and removed from the server.'));
+ $this->assertNoText('test-name-foo', 'Server no longer listed.');
+ $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}");
+ $this->assertNoText(t('Server'), 'The index was removed from the server.');
+ $this->assertText(t('disabled'), 'The index was disabled.');
+
+ // Check whether the tasks were correctly deleted.
+ $task_count = db_query('SELECT COUNT(id) FROM {search_api_task}')->fetchField();
+ $this->assertEqual($task_count, 0, 'Remaining server tasks were correctly deleted.');
+ }
+
+ /**
+ * Tests whether disabling and uninstalling the modules works correctly.
+ *
+ * This will disable and uninstall both the test module and the Search API. It
+ * asserts that this works correctly (since the server has been deleted in
+ * deleteServer()) and that all associated tables and variables are removed.
+ */
+ protected function disableModules() {
+ module_disable(array('search_api_test_2'), FALSE);
+ $this->assertFalse(module_exists('search_api_test_2'), 'Second test module was successfully disabled.');
+ module_disable(array('search_api_test'), FALSE);
+ $this->assertFalse(module_exists('search_api_test'), 'First test module was successfully disabled.');
+ module_disable(array('search_api'), FALSE);
+ $this->assertFalse(module_exists('search_api'), 'Search API module was successfully disabled.');
+
+ drupal_uninstall_modules(array('search_api_test_2'), FALSE);
+ $this->assertEqual(drupal_get_installed_schema_version('search_api_test_2', TRUE), SCHEMA_UNINSTALLED, 'Second test module was successfully uninstalled.');
+ drupal_uninstall_modules(array('search_api_test'), FALSE);
+ $this->assertEqual(drupal_get_installed_schema_version('search_api_test', TRUE), SCHEMA_UNINSTALLED, 'First test module was successfully uninstalled.');
+ $this->assertFalse(db_table_exists('search_api_test'), 'Test module table was successfully removed.');
+ drupal_uninstall_modules(array('search_api'), FALSE);
+ $this->assertEqual(drupal_get_installed_schema_version('search_api', TRUE), SCHEMA_UNINSTALLED, 'Search API module was successfully uninstalled.');
+ $this->assertFalse(db_table_exists('search_api_server'), 'Search server table was successfully removed.');
+ $this->assertFalse(db_table_exists('search_api_index'), 'Search index table was successfully removed.');
+ $this->assertFalse(db_table_exists('search_api_item'), 'Index items table was successfully removed.');
+ $this->assertFalse(db_table_exists('search_api_task'), 'Server tasks table was successfully removed.');
+ $this->assertNull(variable_get('search_api_index_worker_callback_runtime'), 'Worker runtime variable was correctly removed.');
}
}
@@ -379,8 +762,19 @@ class SearchApiWebTest extends DrupalWebTestCase {
*/
class SearchApiUnitTest extends DrupalWebTestCase {
+ /**
+ * The index used by these tests.
+ *
+ * @var SearchApIindex
+ */
protected $index;
+ /**
+ * Overrides DrupalTestCase::assertEqual().
+ *
+ * For arrays, checks whether all array keys are mapped the same in both
+ * arrays recursively, while ignoring their order.
+ */
protected function assertEqual($first, $second, $message = '', $group = 'Other') {
if (is_array($first) && is_array($second)) {
return $this->assertTrue($this->deepEquals($first, $second), $message, $group);
@@ -390,6 +784,20 @@ class SearchApiUnitTest extends DrupalWebTestCase {
}
}
+ /**
+ * Tests whether two values are equal.
+ *
+ * For arrays, this is done by comparing the key/value pairs recursively
+ * instead of checking for simple equality.
+ *
+ * @param mixed $first
+ * The first value.
+ * @param mixed $second
+ * The second value.
+ *
+ * @return bool
+ * TRUE if the two values are equal, FALSE otherwise.
+ */
protected function deepEquals($first, $second) {
if (!is_array($first) || !is_array($second)) {
return $first == $second;
@@ -405,6 +813,12 @@ class SearchApiUnitTest extends DrupalWebTestCase {
return empty($second);
}
+ /**
+ * Returns information about this test case.
+ *
+ * @return array
+ * An array with information about this test case.
+ */
public static function getInfo() {
return array(
'name' => 'Test search API components',
@@ -413,11 +827,15 @@ class SearchApiUnitTest extends DrupalWebTestCase {
);
}
+ /**
+ * {@inheritdoc}
+ */
public function setUp() {
parent::setUp('entity', 'search_api');
$this->index = entity_create('search_api_index', array(
'id' => 1,
'name' => 'test',
+ 'machine_name' => 'test',
'enabled' => 1,
'item_type' => 'user',
'options' => array(
@@ -436,55 +854,63 @@ class SearchApiUnitTest extends DrupalWebTestCase {
));
}
+ /**
+ * Tests the functionality of several components of the module.
+ *
+ * This is the single test method called by the Simpletest framework. It in
+ * turn calls other helper methods to test specific functionality.
+ */
public function testUnits() {
$this->checkQueryParseKeys();
$this->checkIgnoreCaseProcessor();
$this->checkTokenizer();
$this->checkHtmlFilter();
+ $this->checkEntityDatasource();
}
- public function checkQueryParseKeys() {
+ /**
+ * Checks whether the keys are parsed correctly by the query class.
+ */
+ protected function checkQueryParseKeys() {
$options['parse mode'] = 'direct';
$mode = &$options['parse mode'];
- $num = 1;
$query = new SearchApiQuery($this->index, $options);
- $modes = $query->parseModes();
$query->keys('foo');
- $this->assertEqual($query->getKeys(), 'foo', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), 'foo', '"Direct query" parse mode, test 1.');
$query->keys('foo bar');
- $this->assertEqual($query->getKeys(), 'foo bar', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), 'foo bar', '"Direct query" parse mode, test 2.');
$query->keys('(foo bar) OR "bar baz"');
- $this->assertEqual($query->getKeys(), '(foo bar) OR "bar baz"', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), '(foo bar) OR "bar baz"', '"Direct query" parse mode, test 3.');
$mode = 'single';
- $num = 1;
$query = new SearchApiQuery($this->index, $options);
$query->keys('foo');
- $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), '"Single term" parse mode, test 1.');
$query->keys('foo bar');
- $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo bar'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo bar'), '"Single term" parse mode, test 2.');
$query->keys('(foo bar) OR "bar baz"');
- $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', '(foo bar) OR "bar baz"'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', '(foo bar) OR "bar baz"'), '"Single term" parse mode, test 3.');
$mode = 'terms';
- $num = 1;
$query = new SearchApiQuery($this->index, $options);
$query->keys('foo');
- $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), '"Multiple terms" parse mode, test 1.');
$query->keys('foo bar');
- $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo', 'bar'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo', 'bar'), '"Multiple terms" parse mode, test 2.');
$query->keys('(foo bar) OR "bar baz"');
- $this->assertEqual($query->getKeys(), array('(foo', 'bar)', 'OR', 'bar baz', '#conjunction' => 'AND'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), array('(foo', 'bar)', 'OR', 'bar baz', '#conjunction' => 'AND'), '"Multiple terms" parse mode, test 3.');
// http://drupal.org/node/1468678
$query->keys('"Münster"');
- $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'Münster'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+ $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'Münster'), '"Multiple terms" parse mode, test 4.');
}
- public function checkIgnoreCaseProcessor() {
- $types = search_api_field_types();
+ /**
+ * Tests the functionality of the "Ignore case" processor.
+ */
+ protected function checkIgnoreCaseProcessor() {
$orig = 'Foo bar BaZ, ÄÖÜÀÁ<>»«.';
$processed = drupal_strtolower($orig);
$items = array(
@@ -524,33 +950,36 @@ class SearchApiUnitTest extends DrupalWebTestCase {
$processor = new SearchApiIgnoreCase($this->index, array('fields' => array('name' => 'name')));
$tmp = $items;
$processor->preprocessIndexItems($tmp);
- $this->assertEqual($tmp[1]['name']['value'], $processed, t('!type field was processed.', array('!type' => 'name')));
- $this->assertEqual($tmp[1]['mail']['value'], $orig, t("!type field wasn't processed.", array('!type' => 'mail')));
+ $this->assertEqual($tmp[1]['name']['value'], $processed, 'Name field was processed.');
+ $this->assertEqual($tmp[1]['mail']['value'], $orig, "Mail field wasn't processed.");
$query = new SearchApiQuery($this->index);
$query->keys('Foo "baR BaZ" fOObAr1');
$query->condition('name', 'FOO');
$query->condition('mail', 'BAR');
$processor->preprocessSearchQuery($query);
- $this->assertEqual($query->getKeys(), $keys1, t('Search keys were processed correctly.'));
- $this->assertEqual($query->getFilter()->getFilters(), $filters1, t('Filters were processed correctly.'));
+ $this->assertEqual($query->getKeys(), $keys1, 'Search keys were processed correctly.');
+ $this->assertEqual($query->getFilter()->getFilters(), $filters1, 'Filters were processed correctly.');
$processor = new SearchApiIgnoreCase($this->index, array('fields' => array('name' => 'name', 'mail' => 'mail')));
$tmp = $items;
$processor->preprocessIndexItems($tmp);
- $this->assertEqual($tmp[1]['name']['value'], $processed, t('!type field was processed.', array('!type' => 'name')));
- $this->assertEqual($tmp[1]['mail']['value'], $processed, t('!type field was processed.', array('!type' => 'mail')));
+ $this->assertEqual($tmp[1]['name']['value'], $processed, 'Name field was processed.');
+ $this->assertEqual($tmp[1]['mail']['value'], $processed, 'Mail field was processed.');
$query = new SearchApiQuery($this->index);
$query->keys('Foo "baR BaZ" fOObAr1');
$query->condition('name', 'FOO');
$query->condition('mail', 'BAR');
$processor->preprocessSearchQuery($query);
- $this->assertEqual($query->getKeys(), $keys2, t('Search keys were processed correctly.'));
- $this->assertEqual($query->getFilter()->getFilters(), $filters2, t('Filters were processed correctly.'));
+ $this->assertEqual($query->getKeys(), $keys2, 'Search keys were processed correctly.');
+ $this->assertEqual($query->getFilter()->getFilters(), $filters2, 'Filters were processed correctly.');
}
- public function checkTokenizer() {
+ /**
+ * Tests the functionality of the "Tokenizer" processor.
+ */
+ protected function checkTokenizer() {
$orig = 'Foo bar1 BaZ, La-la-la.';
$processed1 = array(
array(
@@ -614,28 +1043,31 @@ class SearchApiUnitTest extends DrupalWebTestCase {
$processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[^\p{L}\p{N}]', 'ignorable' => '[-]'));
$tmp = $items;
$processor->preprocessIndexItems($tmp);
- $this->assertEqual($tmp[1]['name']['value'], $processed1, t('Value was correctly tokenized with default settings.'));
+ $this->assertEqual($tmp[1]['name']['value'], $processed1, 'Value was correctly tokenized with default settings.');
$query = new SearchApiQuery($this->index, array('parse mode' => 'direct'));
$query->keys("foo \"bar-baz\" \n\t foobar1");
$processor->preprocessSearchQuery($query);
- $this->assertEqual($query->getKeys(), 'foo barbaz foobar1', t('Search keys were processed correctly.'));
+ $this->assertEqual($query->getKeys(), 'foo barbaz foobar1', 'Search keys were processed correctly.');
$processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[-a]', 'ignorable' => '\s'));
$tmp = $items;
$processor->preprocessIndexItems($tmp);
- $this->assertEqual($tmp[1]['name']['value'], $processed2, t('Value was correctly tokenized with custom settings.'));
+ $this->assertEqual($tmp[1]['name']['value'], $processed2, 'Value was correctly tokenized with custom settings.');
$query = new SearchApiQuery($this->index, array('parse mode' => 'direct'));
$query->keys("foo \"bar-baz\" \n\t foobar1");
$processor->preprocessSearchQuery($query);
- $this->assertEqual($query->getKeys(), 'foo"b r b z"foob r1', t('Search keys were processed correctly.'));
+ $this->assertEqual($query->getKeys(), 'foo"b r b z"foob r1', 'Search keys were processed correctly.');
}
- public function checkHtmlFilter() {
+ /**
+ * Tests the functionality of the "HTML filter" processor.
+ */
+ protected function checkHtmlFilter() {
$orig = <<a test.
+"something">a test.
Header
How to write links to other sites: <a href="URL" title="MOUSEOVER TEXT">TEXT</a>.
< signs can be escaped with "<".
@@ -644,6 +1076,7 @@ END;
$tags = << 'This', 'score' => 1),
@@ -651,6 +1084,7 @@ END;
array('value' => 'something', 'score' => 1.5),
array('value' => 'a', 'score' => 1.5),
array('value' => 'test', 'score' => 1.5),
+ array('value' => 'Header', 'score' => 3),
array('value' => 'How', 'score' => 1),
array('value' => 'to', 'score' => 1),
array('value' => 'write', 'score' => 1),
@@ -694,7 +1128,78 @@ END;
$processor->preprocessIndexItems($tmp);
$processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[\s.:]', 'ignorable' => ''));
$processor->preprocessIndexItems($tmp);
- $this->assertEqual($tmp[1]['name']['value'], $processed1, t('Text was correctly processed.'));
+ $this->assertEqual($tmp[1]['name']['value'], $processed1, 'Text was correctly processed.');
+ }
+
+ /**
+ * Tests the entity datasource controller and its bundle setting.
+ */
+ protected function checkEntityDatasource() {
+ // First, create the necessary content types.
+ $type = (object) array(
+ 'type' => 'article',
+ 'base' => 'article',
+ );
+ node_type_save($type);
+ $type->type = $type->base = 'page';
+ node_type_save($type);
+
+ // Now, create some nodes.
+ $node = (object) array(
+ 'title' => 'Foo',
+ 'type' => 'article',
+ );
+ node_save($node);
+ $nid1 = $node->nid;
+ $node = (object) array(
+ 'title' => 'Bar',
+ 'type' => 'article',
+ );
+ node_save($node);
+ $node = (object) array(
+ 'title' => 'Baz',
+ 'type' => 'page',
+ );
+ node_save($node);
+
+ // We can't use $this->index here, since users don't have bundles.
+ $index = entity_create('search_api_index', array(
+ 'id' => 2,
+ 'name' => 'test2',
+ 'machine_name' => 'test2',
+ 'enabled' => 1,
+ 'item_type' => 'node',
+ 'options' => array(
+ 'fields' => array(
+ 'nid' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ ));
+
+ // Now start tracking and check whether the index status is correct.
+ $datasource = search_api_get_datasource_controller('node');
+ $datasource->startTracking(array($index));
+ $status = $datasource->getIndexStatus($index);
+ $this->assertEqual($status['total'], 3, 'Correct number of items marked for indexing on not bundle-specific index.');
+ $datasource->stopTracking(array($index));
+
+ // Once again, but with only indexing articles.
+ $index->options['datasource']['bundles'] = array('article');
+ drupal_static_reset('search_api_get_datasource_controller');
+ $datasource = search_api_get_datasource_controller('node');
+ $datasource->startTracking(array($index));
+ $status = $datasource->getIndexStatus($index);
+ $this->assertEqual($status['total'], 2, 'Correct number of items marked for indexing on bundle-specific index.');
+ $datasource->stopTracking(array($index));
+
+ // Now test that bundle renaming works.
+ $index->save();
+ field_attach_rename_bundle('node', 'article', 'foo');
+ $index = search_api_index_load('test2', TRUE);
+ $this->assertEqual($index->options['datasource']['bundles'], array('foo'), 'Bundle was correctly renamed in index settings.');
+ $index->delete();
}
}
diff --git a/tests/search_api_test.install b/tests/search_api_test.install
index 8dea6ebc..2db73bb2 100644
--- a/tests/search_api_test.install
+++ b/tests/search_api_test.install
@@ -39,7 +39,13 @@ function search_api_test_schema() {
'description' => 'A comma separated list of keywords.',
'type' => 'varchar',
'length' => 200,
- 'not null' => FALSE,
+ 'not null' => FALSE,
+ ),
+ 'prices' => array(
+ 'description' => 'A comma separated list of prices.',
+ 'type' => 'varchar',
+ 'length' => 200,
+ 'not null' => FALSE,
),
),
'primary key' => array('id'),
diff --git a/tests/search_api_test.module b/tests/search_api_test.module
index 67e33920..1b227f3d 100644
--- a/tests/search_api_test.module
+++ b/tests/search_api_test.module
@@ -1,5 +1,10 @@
array('search_api_test_insert_item'),
'access callback' => TRUE,
),
- 'search_api_test/%search_api_test' => array(
+ 'search_api_test/view/%search_api_test' => array(
'title' => 'View item',
'page callback' => 'search_api_test_view',
- 'page arguments' => array(1),
+ 'page arguments' => array(2),
+ 'access callback' => TRUE,
+ ),
+ 'search_api_test/touch/%search_api_test' => array(
+ 'title' => 'Mark item as changed',
+ 'page callback' => 'search_api_test_touch',
+ 'page arguments' => array(2),
'access callback' => TRUE,
),
- 'search_api_test/query/%search_api_index' => array(
- 'title' => 'Search query',
- 'page callback' => 'search_api_test_query',
+ 'search_api_test/delete/%search_api_test' => array(
+ 'title' => 'Delete items',
+ 'page callback' => 'search_api_test_delete',
'page arguments' => array(2),
'access callback' => TRUE,
),
@@ -46,6 +57,9 @@ function search_api_test_insert_item(array $form, array &$form_state) {
'keywords' => array(
'#type' => 'textfield',
),
+ 'prices' => array(
+ '#type' => 'textfield',
+ ),
'submit' => array(
'#type' => 'submit',
'#value' => t('Save'),
@@ -74,42 +88,22 @@ function search_api_test_load($id) {
* Menu callback for displaying search_api_test entities.
*/
function search_api_test_view($entity) {
- return array('text' => nl2br(check_plain(print_r($entity, TRUE))));
+ return nl2br(check_plain(print_r($entity, TRUE)));
}
/**
- * Menu callback for executing a search.
+ * Menu callback for marking a "search_api_test" entity as changed.
*/
-function search_api_test_query(SearchApiIndex $index, $keys = 'foo bar', $offset = 0, $limit = 10, $fields = NULL, $sort = NULL, $filters = NULL) {
- $query = $index->query()
- ->keys($keys ? $keys : NULL)
- ->range($offset, $limit);
- if ($fields) {
- $query->fields(explode(',', $fields));
- }
- if ($sort) {
- $sort = explode(',', $sort);
- $query->sort($sort[0], $sort[1]);
- }
- else {
- $query->sort('search_api_id', 'ASC');
- }
- if ($filters) {
- $filters = explode(',', $filters);
- foreach ($filters as $filter) {
- $filter = explode('=', $filter);
- $query->condition($filter[0], $filter[1]);
- }
- }
- $result = $query->execute();
+function search_api_test_touch($entity) {
+ module_invoke_all('entity_update', $entity, 'search_api_test');
+}
- $ret = '';
- $ret .= 'result count = ' . (int) $result['result count'] . ' ';
- $ret .= 'results = (' . (empty($result['results']) ? '' : implode(', ', array_keys($result['results']))) . ') ';
- $ret .= 'warnings = (' . (empty($result['warnings']) ? '' : '"' . implode('", "', $result['warnings']) . '"') . ') ';
- $ret .= 'ignored = (' . (empty($result['ignored']) ? '' : implode(', ', $result['ignored'])) . ') ';
- $ret .= nl2br(check_plain(print_r($result['performance'], TRUE)));
- return $ret;
+/**
+ * Menu callback for marking a "search_api_test" entity as changed.
+ */
+function search_api_test_delete($entity) {
+ db_delete('search_api_test')->condition('id', $entity->id)->execute();
+ module_invoke_all('entity_delete', $entity, 'search_api_test');
}
/**
@@ -169,6 +163,12 @@ function search_api_test_entity_property_info() {
'description' => 'An optional collection of keywords describing the item.',
'getter callback' => 'search_api_test_list_callback',
),
+ 'prices' => array(
+ 'label' => 'Prices',
+ 'type' => 'list',
+ 'description' => 'An optional list of prices.',
+ 'getter callback' => 'search_api_test_list_callback',
+ ),
);
return $info;
@@ -193,13 +193,17 @@ function search_api_test_parent($entity) {
/**
* List callback.
*/
-function search_api_test_list_callback($data) {
- //return is_array($entity->keywords) ? $entity->keywords : explode(',', $entity->keywords);
+function search_api_test_list_callback($data, array $options, $name) {
if (is_array($data)) {
- $res = is_array($data['keywords']) ? $data['keywords'] : explode(',', $data['keywords']);
+ $res = is_array($data[$name]) ? $data[$name] : explode(',', $data[$name]);
}
else {
- $res = is_array($data->keywords) ? $data->keywords : explode(',', $data->keywords);
+ $res = is_array($data->$name) ? $data->$name : explode(',', $data->$name);
+ }
+ if ($name == 'prices') {
+ foreach ($res as &$x) {
+ $x = (float) $x;
+ }
}
return array_filter($res);
}
@@ -221,6 +225,11 @@ function search_api_test_search_api_service_info() {
*/
class SearchApiTestService extends SearchApiAbstractService {
+ /**
+ * Overrides SearchApiAbstractService::configurationForm().
+ *
+ * Returns a single text field for testing purposes.
+ */
public function configurationForm(array $form, array &$form_state) {
$form = array(
'test' => array(
@@ -236,38 +245,72 @@ class SearchApiTestService extends SearchApiAbstractService {
return $form;
}
+ /**
+ * {@inheritdoc}
+ */
+ public function addIndex(SearchApiIndex $index) {
+ $this->checkErrorState();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function fieldsUpdated(SearchApiIndex $index) {
+ $this->checkErrorState();
+ return db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() > 0;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function removeIndex($index) {
+ $this->checkErrorState();
+ parent::removeIndex($index);
+ }
+
+ /**
+ * Implements SearchApiServiceInterface::indexItems().
+ *
+ * Indexes items by storing their IDs in the server's options.
+ *
+ * If the "search_api_test_indexing_break" variable is set, the item with
+ * that ID will not be indexed.
+ */
public function indexItems(SearchApiIndex $index, array $items) {
- // Refuse to index items with IDs that are multiples of 8 unless the
- // "search_api_test_index_all" variable is set.
- if (variable_get('search_api_test_index_all', FALSE)) {
- return $this->index($index, array_keys($items));
- }
- $ret = array();
+ $this->checkErrorState();
+ // Refuse to index the item with the same ID as the
+ // "search_api_test_indexing_break" variable, if it is set.
+ $exclude = variable_get('search_api_test_indexing_break', 8);
foreach ($items as $id => $item) {
- if ($id % 8) {
- $ret[] = $id;
+ if ($id == $exclude) {
+ unset($items[$id]);
}
}
- return $this->index($index, $ret);
- }
+ $ids = array_keys($items);
- protected function index(SearchApiIndex $index, array $ids) {
$this->options += array('indexes' => array());
$this->options['indexes'] += array($index->machine_name => array());
$this->options['indexes'][$index->machine_name] += drupal_map_assoc($ids);
- sort($this->options['indexes'][$index->machine_name]);
+ asort($this->options['indexes'][$index->machine_name]);
$this->server->save();
+
return $ids;
}
/**
- * Override so deleteItems() isn't called which would otherwise lead to the
+ * Overrides SearchApiAbstractService::preDelete().
+ *
+ * Overridden so deleteItems() isn't called which would otherwise lead to the
* server being updated and, eventually, to a notice because there is no
* server to be updated anymore.
*/
public function preDelete() {}
+ /**
+ * {@inheritdoc}
+ */
public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
+ $this->checkErrorState();
if ($ids == 'all') {
if ($index) {
$this->options['indexes'][$index->machine_name] = array();
@@ -284,6 +327,12 @@ class SearchApiTestService extends SearchApiAbstractService {
$this->server->save();
}
+ /**
+ * Implements SearchApiServiceInterface::indexItems().
+ *
+ * Will ignore all query settings except the range, as only the item IDs are
+ * indexed.
+ */
public function search(SearchApiQueryInterface $query) {
$options = $query->getOptions();
$ret = array();
@@ -315,8 +364,16 @@ class SearchApiTestService extends SearchApiAbstractService {
return $ret;
}
- public function fieldsUpdated(SearchApiIndex $index) {
- return db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() > 0;
+ /**
+ * Throws an exception if the "search_api_test_error_state" variable is set.
+ *
+ * @throws SearchApiException
+ * If the "search_api_test_error_state" variable is set.
+ */
+ protected function checkErrorState() {
+ if (variable_get('search_api_test_error_state', FALSE)) {
+ throw new SearchApiException();
+ }
}
}
diff --git a/tests/search_api_test_2.info b/tests/search_api_test_2.info
new file mode 100644
index 00000000..a3beff1f
--- /dev/null
+++ b/tests/search_api_test_2.info
@@ -0,0 +1,10 @@
+name = Search API test service 2
+description = "A module providing a second test search service."
+core = 7.x
+package = Search
+
+dependencies[] = search_api
+
+files[] = search_api_test_service_2.module
+
+hidden = TRUE
diff --git a/tests/search_api_test_2.module b/tests/search_api_test_2.module
new file mode 100644
index 00000000..d78a80eb
--- /dev/null
+++ b/tests/search_api_test_2.module
@@ -0,0 +1,136 @@
+ $name,
+ 'description' => 'search_api_test_service_2 description',
+ 'class' => 'SearchApiDummyService',
+ );
+ return $services;
+}
+
+/**
+ * Implements hook_default_search_api_server().
+ */
+function search_api_test_2_default_search_api_server() {
+ $id = 'test_server_2';
+ $items[$id] = entity_create('search_api_server', array(
+ 'name' => 'Search API test server 2',
+ 'machine_name' => $id,
+ 'enabled' => 1,
+ 'description' => 'A server used for testing.',
+ 'class' => 'search_api_test_service_2',
+ ));
+ return $items;
+}
+
+/**
+ * Dummy service for testing.
+ */
+class SearchApiDummyService implements SearchApiServiceInterface {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(\SearchApiServer $server) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationForm(array $form, array &$form_state) {
+ return array();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormValidate(array $form, array &$values, array &$form_state) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configurationFormSubmit(array $form, array &$values, array &$form_state) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supportsFeature($feature) {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function viewSettings() {
+ return array();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postCreate() {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postUpdate() {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preDelete() {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addIndex(SearchApiIndex $index) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function fieldsUpdated(SearchApiIndex $index) {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function removeIndex($index) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function indexItems(SearchApiIndex $index, array $items) {
+ return array();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query(SearchApiIndex $index, $options = array()) {
+ throw new SearchApiException("The dummy service doesn't support queries");
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function search(SearchApiQueryInterface $query) {
+ return array();
+ }
+}