1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* Fulltext search index for shop buyables |
4
|
|
|
* |
5
|
|
|
* @author Mark Guinn <[email protected]> |
6
|
|
|
* @date 08.29.2013 |
7
|
|
|
* @package shop_search |
8
|
|
|
*/ |
9
|
|
|
class ShopSearch extends Object |
|
|
|
|
10
|
|
|
{ |
11
|
|
|
const FACET_TYPE_LINK = 'link'; |
12
|
|
|
const FACET_TYPE_CHECKBOX = 'checkbox'; |
13
|
|
|
const FACET_TYPE_RANGE = 'range'; |
14
|
|
|
|
15
|
|
|
/** @var string - class name of adapter class to use */ |
16
|
|
|
private static $adapter_class = 'ShopSearchSimple'; |
|
|
|
|
17
|
|
|
|
18
|
|
|
/** @var array - these classes will be added to the index - e.g. Category, Page, etc. */ |
19
|
|
|
private static $searchable = array(); |
|
|
|
|
20
|
|
|
|
21
|
|
|
/** @var bool - if true, all buyable models will be added to the index automatically */ |
22
|
|
|
private static $buyables_are_searchable = true; |
|
|
|
|
23
|
|
|
|
24
|
|
|
/** @var int - size of paging in the search */ |
25
|
|
|
private static $page_size = 10; |
|
|
|
|
26
|
|
|
|
27
|
|
|
/** @var bool */ |
28
|
|
|
private static $suggest_enabled = true; |
|
|
|
|
29
|
|
|
|
30
|
|
|
/** @var int - how many suggestions to provide */ |
31
|
|
|
private static $suggest_limit = 5; |
|
|
|
|
32
|
|
|
|
33
|
|
|
/** @var bool */ |
34
|
|
|
private static $search_as_you_type_enabled = true; |
|
|
|
|
35
|
|
|
|
36
|
|
|
/** @var int - how may sayt (search-as-you-type) entries to provide */ |
37
|
|
|
private static $sayt_limit = 5; |
|
|
|
|
38
|
|
|
|
39
|
|
|
/** @var bool - automatically create facets for static attributes */ |
40
|
|
|
private static $auto_facet_attributes = false; |
|
|
|
|
41
|
|
|
|
42
|
|
|
/** @var string - optionally, a different template to run ajax results through (sans-Page.ss) */ |
43
|
|
|
private static $ajax_results_template = ''; |
|
|
|
|
44
|
|
|
|
45
|
|
|
/** @var string - these allow you to use different querystring params in you need to */ |
46
|
|
|
private static $qs_query = 'q'; |
|
|
|
|
47
|
|
|
private static $qs_filters = 'f'; |
|
|
|
|
48
|
|
|
private static $qs_parent_search = '__ps'; |
|
|
|
|
49
|
|
|
private static $qs_title = '__t'; |
|
|
|
|
50
|
|
|
private static $qs_source = '__src'; // used to log searches from search-as-you-type |
|
|
|
|
51
|
|
|
private static $qs_sort = 'sort'; |
|
|
|
|
52
|
|
|
|
53
|
|
|
/** @var array - I'm leaving this particularly bare b/c with config merging it's a pain to remove items */ |
54
|
|
|
private static $sort_options = array( |
|
|
|
|
55
|
|
|
'score desc' => 'Relevance', |
56
|
|
|
// 'SiteTree_Title asc' => 'Alphabetical (A-Z)', |
|
|
|
|
57
|
|
|
// 'SiteTree_Title dsc' => 'Alphabetical (Z-A)', |
58
|
|
|
); |
59
|
|
|
|
60
|
|
|
/** |
61
|
|
|
* @var array - default search facets (price, category, etc) |
62
|
|
|
* Key field name - e.g. Price - can be a VirtualFieldIndex field |
63
|
|
|
* Value facet label - e.g. Search By Category - if the value is a relation or returns an array or |
64
|
|
|
* list all values will be faceted individually |
65
|
|
|
* NOTE: this can also be another array with keys: Label, Type, and Values (for checkbox only) |
66
|
|
|
*/ |
67
|
|
|
private static $facets = array(); |
|
|
|
|
68
|
|
|
|
69
|
|
|
/** @var array - field definition for Solr only */ |
70
|
|
|
private static $solr_fulltext_fields = array(); |
|
|
|
|
71
|
|
|
|
72
|
|
|
/** @var array - field definition for Solr only */ |
73
|
|
|
private static $solr_filter_fields = array(); |
|
|
|
|
74
|
|
|
|
75
|
|
|
/** @var string - if present, will create a copy of SiteTree_Title that's suited for alpha sorting */ |
76
|
|
|
private static $solr_title_sort_field = ''; |
|
|
|
|
77
|
|
|
|
78
|
|
|
/** |
79
|
|
|
* @var string - If present, everything matching the following regex will be removed from |
80
|
|
|
* keyword search queries before passing to the search adapter. |
81
|
|
|
*/ |
82
|
|
|
private static $keyword_filter_regex = '/[^a-zA-Z0-9\s\-]/'; |
|
|
|
|
83
|
|
|
|
84
|
|
|
|
85
|
|
|
/** |
86
|
|
|
* @return array |
87
|
|
|
*/ |
88
|
|
|
public static function get_searchable_classes() |
89
|
|
|
{ |
90
|
|
|
// First get any explicitly declared searchable classes |
91
|
|
|
$searchable = Config::inst()->get('ShopSearch', 'searchable'); |
92
|
|
|
if (is_string($searchable) && strlen($searchable) > 0) { |
93
|
|
|
$searchable = array($searchable); |
94
|
|
|
} elseif (!is_array($searchable)) { |
95
|
|
|
$searchable = array(); |
96
|
|
|
} |
97
|
|
|
|
98
|
|
|
// Add in buyables automatically if asked |
99
|
|
|
if (Config::inst()->get('ShopSearch', 'buyables_are_searchable')) { |
100
|
|
|
$buyables = SS_ClassLoader::instance()->getManifest()->getImplementorsOf('Buyable'); |
101
|
|
|
if (is_array($buyables) && count($buyables) > 0) { |
102
|
|
|
foreach ($buyables as $c) { |
103
|
|
|
$searchable[] = $c; |
104
|
|
|
} |
105
|
|
|
} |
106
|
|
|
} |
107
|
|
|
|
108
|
|
|
return array_unique($searchable); |
109
|
|
|
} |
110
|
|
|
|
111
|
|
|
/** |
112
|
|
|
* Returns an array of categories suitable for a dropdown menu |
113
|
|
|
* TODO: cache this |
114
|
|
|
* |
115
|
|
|
* @param int $parentID [optional] |
116
|
|
|
* @param string $prefix [optional] |
117
|
|
|
* @param int $maxDepth [optional] |
118
|
|
|
* @return array |
119
|
|
|
* @static |
120
|
|
|
*/ |
121
|
|
|
public static function get_category_hierarchy($parentID = 0, $prefix = '', $maxDepth = 999) |
122
|
|
|
{ |
123
|
|
|
$out = array(); |
124
|
|
|
$cats = ProductCategory::get() |
125
|
|
|
->filter(array( |
126
|
|
|
'ParentID' => $parentID, |
127
|
|
|
'ShowInMenus' => 1, |
128
|
|
|
)) |
129
|
|
|
->sort('Sort'); |
130
|
|
|
|
131
|
|
|
// If there is a single parent category (usually "Products" or something), we |
132
|
|
|
// probably don't want that in the hierarchy. |
133
|
|
|
if ($parentID == 0 && $cats->count() == 1) { |
134
|
|
|
return self::get_category_hierarchy($cats->first()->ID, $prefix, $maxDepth); |
135
|
|
|
} |
136
|
|
|
|
137
|
|
|
foreach ($cats as $cat) { |
138
|
|
|
$out[$cat->ID] = $prefix . $cat->Title; |
139
|
|
|
if ($maxDepth > 1) { |
140
|
|
|
$out += self::get_category_hierarchy($cat->ID, $prefix . $cat->Title . ' > ', $maxDepth - 1); |
141
|
|
|
} |
142
|
|
|
} |
143
|
|
|
|
144
|
|
|
return $out; |
145
|
|
|
} |
146
|
|
|
|
147
|
|
|
/** |
148
|
|
|
* @return ShopSearchAdapter |
149
|
|
|
*/ |
150
|
|
|
public static function adapter() |
151
|
|
|
{ |
152
|
|
|
$adapterClass = Config::inst()->get('ShopSearch', 'adapter_class'); |
153
|
|
|
return Injector::inst()->get($adapterClass); |
154
|
|
|
} |
155
|
|
|
|
156
|
|
|
/** |
157
|
|
|
* @return ShopSearch |
158
|
|
|
*/ |
159
|
|
|
public static function inst() |
160
|
|
|
{ |
161
|
|
|
return Injector::inst()->get('ShopSearch'); |
162
|
|
|
} |
163
|
|
|
|
164
|
|
|
/** |
165
|
|
|
* The result will contain at least the following: |
166
|
|
|
* Matches - SS_List of results |
167
|
|
|
* TotalMatches - total # of results, unlimited |
168
|
|
|
* Query - query string |
169
|
|
|
* Also saves a log record. |
170
|
|
|
* |
171
|
|
|
* @param array $vars |
172
|
|
|
* @param bool $logSearch [optional] |
173
|
|
|
* @param bool $useFacets [optional] |
174
|
|
|
* @param int $start [optional] |
175
|
|
|
* @param int $limit [optional] |
176
|
|
|
* @return ArrayData |
177
|
|
|
*/ |
178
|
|
|
public function search(array $vars, $logSearch=true, $useFacets=true, $start=-1, $limit=-1) |
179
|
|
|
{ |
180
|
|
|
$qs_q = $this->config()->get('qs_query'); |
181
|
|
|
$qs_f = $this->config()->get('qs_filters'); |
182
|
|
|
$qs_ps = $this->config()->get('qs_parent_search'); |
183
|
|
|
$qs_t = $this->config()->get('qs_title'); |
184
|
|
|
$qs_sort= $this->config()->get('qs_sort'); |
185
|
|
|
if ($limit < 0) { |
186
|
|
|
$limit = $this->config()->get('page_size'); |
187
|
|
|
} |
188
|
|
|
if ($start < 0) { |
189
|
|
|
$start = !empty($vars['start']) ? (int)$vars['start'] : 0; |
190
|
|
|
} // as far as i can see, fulltextsearch hard codes 'start' |
191
|
|
|
$facets = $useFacets ? $this->config()->get('facets') : array(); |
192
|
|
|
if (!is_array($facets)) { |
193
|
|
|
$facets = array(); |
194
|
|
|
} |
195
|
|
|
if (empty($limit)) { |
196
|
|
|
$limit = -1; |
197
|
|
|
} |
198
|
|
|
|
199
|
|
|
// figure out and scrub the sort |
200
|
|
|
$sortOptions = $this->config()->get('sort_options'); |
201
|
|
|
$sort = !empty($vars[$qs_sort]) ? $vars[$qs_sort] : ''; |
202
|
|
|
if (!isset($sortOptions[$sort])) { |
203
|
|
|
$sort = current(array_keys($sortOptions)); |
204
|
|
|
} |
205
|
|
|
|
206
|
|
|
// figure out and scrub the filters |
207
|
|
|
$filters = !empty($vars[$qs_f]) ? FacetHelper::inst()->scrubFilters($vars[$qs_f]) : array(); |
208
|
|
|
|
209
|
|
|
// do the search |
210
|
|
|
$keywords = !empty($vars[$qs_q]) ? $vars[$qs_q] : ''; |
211
|
|
|
if ($keywordRegex = $this->config()->get('keyword_filter_regex')) { |
212
|
|
|
$keywords = preg_replace($keywordRegex, '', $keywords); |
213
|
|
|
} |
214
|
|
|
$results = self::adapter()->searchFromVars($keywords, $filters, $facets, $start, $limit, $sort); |
215
|
|
|
|
216
|
|
|
// massage the results a bit |
217
|
|
|
if (!empty($keywords) && !$results->hasValue('Query')) { |
218
|
|
|
$results->Query = $keywords; |
219
|
|
|
} |
220
|
|
|
if (!empty($filters) && !$results->hasValue('Filters')) { |
221
|
|
|
$results->Filters = new ArrayData($filters); |
222
|
|
|
} |
223
|
|
|
if (!$results->hasValue('Sort')) { |
224
|
|
|
$results->Sort = $sort; |
225
|
|
|
} |
226
|
|
|
if (!$results->hasValue('TotalMatches')) { |
227
|
|
|
$results->TotalMatches = $results->Matches->hasMethod('getTotalItems') |
228
|
|
|
? $results->Matches->getTotalItems() |
229
|
|
|
: $results->Matches->count(); |
230
|
|
|
} |
231
|
|
|
|
232
|
|
|
// for some types of facets, update the state |
233
|
|
|
if ($results->hasValue('Facets')) { |
234
|
|
|
FacetHelper::inst()->transformHierarchies($results->Facets); |
235
|
|
|
FacetHelper::inst()->updateFacetState($results->Facets, $filters); |
236
|
|
|
} |
237
|
|
|
|
238
|
|
|
// make a hash of the search so we can know if we've already logged it this session |
239
|
|
|
$loggedFilters = !empty($filters) ? json_encode($filters) : null; |
240
|
|
|
$loggedQuery = strtolower($results->Query); |
241
|
|
|
// $searchHash = md5($loggedFilters . $loggedQuery); |
|
|
|
|
242
|
|
|
// $sessSearches = Session::get('loggedSearches'); |
243
|
|
|
// if (!is_array($sessSearches)) $sessSearches = array(); |
244
|
|
|
// Debug::dump($searchHash, $sessSearches); |
245
|
|
|
|
246
|
|
|
// save the log record |
247
|
|
|
if ($start == 0 && $logSearch && (!empty($keywords) || !empty($filters))) { // && !in_array($searchHash, $sessSearches)) { |
|
|
|
|
248
|
|
|
$log = SearchLog::create(array( |
249
|
|
|
'Query' => $loggedQuery, |
250
|
|
|
'Title' => !empty($vars[$qs_t]) ? $vars[$qs_t] : '', |
251
|
|
|
'Link' => Controller::curr()->getRequest()->getURL(true), // I'm not 100% happy with this, but can't think of a better way |
252
|
|
|
'NumResults' => $results->TotalMatches, |
253
|
|
|
'MemberID' => Member::currentUserID(), |
254
|
|
|
'Filters' => $loggedFilters, |
255
|
|
|
'ParentSearchID'=> !empty($vars[$qs_ps]) ? $vars[$qs_ps] : 0, |
256
|
|
|
)); |
257
|
|
|
$log->write(); |
258
|
|
|
$results->SearchLogID = $log->ID; |
259
|
|
|
$results->SearchBreadcrumbs = $log->getBreadcrumbs(); |
260
|
|
|
|
261
|
|
|
// $sessSearches[] = $searchHash; |
|
|
|
|
262
|
|
|
// Session::set('loggedSearches', $sessSearches); |
263
|
|
|
} |
264
|
|
|
|
265
|
|
|
return $results; |
266
|
|
|
} |
267
|
|
|
|
268
|
|
|
/** |
269
|
|
|
* @param string $str |
270
|
|
|
* @return SS_Query |
271
|
|
|
*/ |
272
|
|
|
public function getSuggestQuery($str='') |
273
|
|
|
{ |
274
|
|
|
$hasResults = 'CASE WHEN max("SearchLog"."NumResults") > 0 THEN 1 ELSE 0 END'; |
275
|
|
|
$searchCount = 'count(distinct "SearchLog"."ID")'; |
276
|
|
|
$q = new SQLQuery(); |
|
|
|
|
277
|
|
|
$q = $q->setSelect('"SearchLog"."Query"') |
278
|
|
|
// TODO: what to do with filter? |
279
|
|
|
->selectField($searchCount, 'SearchCount') |
280
|
|
|
->selectField('max("SearchLog"."Created")', 'LastSearch') |
281
|
|
|
->selectField('max("SearchLog"."NumResults")', 'NumResults') |
282
|
|
|
->selectField($hasResults, 'HasResults') |
283
|
|
|
->setFrom('"SearchLog"') |
284
|
|
|
->setGroupBy('"SearchLog"."Query"') |
285
|
|
|
->setOrderBy(array( |
286
|
|
|
"$hasResults DESC", |
287
|
|
|
"$searchCount DESC" |
288
|
|
|
)) |
289
|
|
|
->setLimit(Config::inst()->get('ShopSearch', 'suggest_limit')) |
290
|
|
|
; |
291
|
|
|
|
292
|
|
|
if (strlen($str) > 0) { |
293
|
|
|
$q = $q->addWhere(sprintf('"SearchLog"."Query" LIKE \'%%%s%%\'', Convert::raw2sql($str))); |
294
|
|
|
} |
295
|
|
|
|
296
|
|
|
return $q; |
297
|
|
|
} |
298
|
|
|
|
299
|
|
|
|
300
|
|
|
/** |
301
|
|
|
* @param string $str |
302
|
|
|
* @return array |
303
|
|
|
*/ |
304
|
|
|
public function suggest($str='') |
305
|
|
|
{ |
306
|
|
|
$adapter = self::adapter(); |
307
|
|
|
if ($adapter->hasMethod('suggest')) { |
308
|
|
|
return $adapter->suggest($str); |
|
|
|
|
309
|
|
|
} else { |
310
|
|
|
return $this->getSuggestQuery($str)->execute()->column('Query'); |
311
|
|
|
} |
312
|
|
|
} |
313
|
|
|
|
314
|
|
|
|
315
|
|
|
/** |
316
|
|
|
* Returns an array that can be made into json and passed to the controller |
317
|
|
|
* containing both term suggestions and a few product matches. |
318
|
|
|
* |
319
|
|
|
* @param array $searchVars |
320
|
|
|
* @return array |
321
|
|
|
*/ |
322
|
|
|
public function suggestWithResults(array $searchVars) |
323
|
|
|
{ |
324
|
|
|
$qs_q = $this->config()->get('qs_query'); |
325
|
|
|
$qs_f = $this->config()->get('qs_filters'); |
326
|
|
|
$keywords = !empty($searchVars[$qs_q]) ? $searchVars[$qs_q] : ''; |
327
|
|
|
$filters = !empty($searchVars[$qs_f]) ? $searchVars[$qs_f] : array(); |
328
|
|
|
|
329
|
|
|
$adapter = self::adapter(); |
330
|
|
|
|
331
|
|
|
// get suggestions and product list from the adapter |
332
|
|
|
if ($adapter->hasMethod('suggestWithResults')) { |
333
|
|
|
$results = $adapter->suggestWithResults($keywords, $filters); |
334
|
|
|
} else { |
335
|
|
|
$limit = (int)ShopSearch::config()->sayt_limit; |
336
|
|
|
$search = self::adapter()->searchFromVars($keywords, $filters, array(), 0, $limit, 'Popularity DESC'); |
337
|
|
|
//$search = ShopSearch::inst()->search($searchVars, false, false, 0, $limit); |
|
|
|
|
338
|
|
|
|
339
|
|
|
$results = array( |
340
|
|
|
'products' => $search->Matches, |
341
|
|
|
'suggestions' => $this->suggest($keywords), |
342
|
|
|
); |
343
|
|
|
} |
344
|
|
|
|
345
|
|
|
// the adapter just gave us a list of products, which we need to process a little further |
346
|
|
|
if (!empty($results['products'])) { |
347
|
|
|
// this gets encoded into the product links |
348
|
|
|
$searchVars['total'] = $results['products']->hasMethod('getTotalItems') |
349
|
|
|
? $results['products']->getTotalItems() |
350
|
|
|
: $results['products']->count(); |
351
|
|
|
|
352
|
|
|
$products = array(); |
353
|
|
|
foreach ($results['products'] as $prod) { |
354
|
|
|
if (!$prod || !$prod->exists()) { |
355
|
|
|
continue; |
356
|
|
|
} |
357
|
|
|
$img = $prod->hasMethod('ProductImage') ? $prod->ProductImage() : $prod->Image(); |
358
|
|
|
$thumb = ($img && $img->exists()) ? $img->getThumbnail() : null; |
359
|
|
|
|
360
|
|
|
$json = array( |
361
|
|
|
'link' => $prod->Link() . '?' . ShopSearch::config()->qs_source . '=' . urlencode(base64_encode(json_encode($searchVars))), |
362
|
|
|
'title' => $prod->Title, |
363
|
|
|
'desc' => $prod->obj('Content')->Summary(), |
364
|
|
|
'thumb' => $thumb ? $thumb->Link() : '', |
365
|
|
|
'price' => $prod->obj('Price')->Nice(), |
366
|
|
|
); |
367
|
|
|
|
368
|
|
|
if ($prod->hasExtension('HasPromotionalPricing') && $prod->hasValidPromotion()) { |
369
|
|
|
$json['original_price'] = $prod->getOriginalPrice()->Nice(); |
370
|
|
|
} |
371
|
|
|
|
372
|
|
|
$products[] = $json; |
373
|
|
|
} |
374
|
|
|
|
375
|
|
|
// replace the list of product objects with json |
376
|
|
|
$results['products'] = $products; |
377
|
|
|
} |
378
|
|
|
|
379
|
|
|
$this->extend('updateSuggestWithResults', $results, $keywords, $filters); |
380
|
|
|
|
381
|
|
|
return $results; |
382
|
|
|
} |
383
|
|
|
} |
384
|
|
|
|
You can fix this by adding a namespace to your class:
When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.