|
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
|
5 |
|
public static function get_searchable_classes() |
|
89
|
|
|
{ |
|
90
|
|
|
// First get any explicitly declared searchable classes |
|
91
|
5 |
|
$searchable = Config::inst()->get('ShopSearch', 'searchable'); |
|
92
|
5 |
|
if (is_string($searchable) && strlen($searchable) > 0) { |
|
93
|
|
|
$searchable = array($searchable); |
|
94
|
5 |
|
} elseif (!is_array($searchable)) { |
|
95
|
|
|
$searchable = array(); |
|
96
|
|
|
} |
|
97
|
|
|
|
|
98
|
|
|
// Add in buyables automatically if asked |
|
99
|
5 |
|
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
|
5 |
|
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
|
2 |
|
public static function get_category_hierarchy($parentID = 0, $prefix = '', $maxDepth = 999) |
|
122
|
|
|
{ |
|
123
|
2 |
|
$out = array(); |
|
124
|
2 |
|
$cats = ProductCategory::get() |
|
125
|
2 |
|
->filter(array( |
|
126
|
2 |
|
'ParentID' => $parentID, |
|
127
|
2 |
|
'ShowInMenus' => 1, |
|
128
|
2 |
|
)) |
|
129
|
2 |
|
->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
|
2 |
|
if ($parentID == 0 && $cats->count() == 1) { |
|
134
|
|
|
return self::get_category_hierarchy($cats->first()->ID, $prefix, $maxDepth); |
|
135
|
|
|
} |
|
136
|
|
|
|
|
137
|
2 |
|
foreach ($cats as $cat) { |
|
138
|
2 |
|
$out[$cat->ID] = $prefix . $cat->Title; |
|
139
|
2 |
|
if ($maxDepth > 1) { |
|
140
|
2 |
|
$out += self::get_category_hierarchy($cat->ID, $prefix . $cat->Title . ' > ', $maxDepth - 1); |
|
141
|
2 |
|
} |
|
142
|
2 |
|
} |
|
143
|
|
|
|
|
144
|
2 |
|
return $out; |
|
145
|
|
|
} |
|
146
|
|
|
|
|
147
|
|
|
/** |
|
148
|
|
|
* @return ShopSearchAdapter |
|
149
|
|
|
*/ |
|
150
|
5 |
|
public static function adapter() |
|
151
|
|
|
{ |
|
152
|
5 |
|
$adapterClass = Config::inst()->get('ShopSearch', 'adapter_class'); |
|
153
|
5 |
|
return Injector::inst()->get($adapterClass); |
|
154
|
|
|
} |
|
155
|
|
|
|
|
156
|
|
|
/** |
|
157
|
|
|
* @return ShopSearch |
|
158
|
|
|
*/ |
|
159
|
5 |
|
public static function inst() |
|
160
|
|
|
{ |
|
161
|
5 |
|
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
|
5 |
|
public function search(array $vars, $logSearch=true, $useFacets=true, $start=-1, $limit=-1) |
|
179
|
|
|
{ |
|
180
|
5 |
|
$qs_q = $this->config()->get('qs_query'); |
|
181
|
5 |
|
$qs_f = $this->config()->get('qs_filters'); |
|
182
|
5 |
|
$qs_ps = $this->config()->get('qs_parent_search'); |
|
183
|
5 |
|
$qs_t = $this->config()->get('qs_title'); |
|
184
|
5 |
|
$qs_sort= $this->config()->get('qs_sort'); |
|
185
|
5 |
|
if ($limit < 0) { |
|
186
|
5 |
|
$limit = $this->config()->get('page_size'); |
|
187
|
5 |
|
} |
|
188
|
5 |
|
if ($start < 0) { |
|
189
|
5 |
|
$start = !empty($vars['start']) ? (int)$vars['start'] : 0; |
|
190
|
5 |
|
} // as far as i can see, fulltextsearch hard codes 'start' |
|
191
|
5 |
|
$facets = $useFacets ? $this->config()->get('facets') : array(); |
|
192
|
5 |
|
if (!is_array($facets)) { |
|
193
|
4 |
|
$facets = array(); |
|
194
|
4 |
|
} |
|
195
|
5 |
|
if (empty($limit)) { |
|
196
|
|
|
$limit = -1; |
|
197
|
|
|
} |
|
198
|
|
|
|
|
199
|
|
|
// figure out and scrub the sort |
|
200
|
5 |
|
$sortOptions = $this->config()->get('sort_options'); |
|
201
|
5 |
|
$sort = !empty($vars[$qs_sort]) ? $vars[$qs_sort] : ''; |
|
202
|
5 |
|
if (!isset($sortOptions[$sort])) { |
|
203
|
5 |
|
$sort = current(array_keys($sortOptions)); |
|
204
|
5 |
|
} |
|
205
|
|
|
|
|
206
|
|
|
// figure out and scrub the filters |
|
207
|
5 |
|
$filters = !empty($vars[$qs_f]) ? FacetHelper::inst()->scrubFilters($vars[$qs_f]) : array(); |
|
208
|
|
|
|
|
209
|
|
|
// do the search |
|
210
|
5 |
|
$keywords = !empty($vars[$qs_q]) ? $vars[$qs_q] : ''; |
|
211
|
5 |
|
if ($keywordRegex = $this->config()->get('keyword_filter_regex')) { |
|
212
|
5 |
|
$keywords = preg_replace($keywordRegex, '', $keywords); |
|
213
|
5 |
|
} |
|
214
|
5 |
|
$results = self::adapter()->searchFromVars($keywords, $filters, $facets, $start, $limit, $sort); |
|
215
|
|
|
|
|
216
|
|
|
// massage the results a bit |
|
217
|
5 |
|
if (!empty($keywords) && !$results->hasValue('Query')) { |
|
218
|
4 |
|
$results->Query = $keywords; |
|
219
|
4 |
|
} |
|
220
|
5 |
|
if (!empty($filters) && !$results->hasValue('Filters')) { |
|
221
|
2 |
|
$results->Filters = new ArrayData($filters); |
|
222
|
2 |
|
} |
|
223
|
5 |
|
if (!$results->hasValue('Sort')) { |
|
224
|
5 |
|
$results->Sort = $sort; |
|
225
|
5 |
|
} |
|
226
|
5 |
|
if (!$results->hasValue('TotalMatches')) { |
|
227
|
5 |
|
$results->TotalMatches = $results->Matches->hasMethod('getTotalItems') |
|
228
|
5 |
|
? $results->Matches->getTotalItems() |
|
229
|
5 |
|
: $results->Matches->count(); |
|
230
|
5 |
|
} |
|
231
|
|
|
|
|
232
|
|
|
// for some types of facets, update the state |
|
233
|
5 |
|
if ($results->hasValue('Facets')) { |
|
234
|
1 |
|
FacetHelper::inst()->transformHierarchies($results->Facets); |
|
235
|
1 |
|
FacetHelper::inst()->updateFacetState($results->Facets, $filters); |
|
236
|
1 |
|
} |
|
237
|
|
|
|
|
238
|
|
|
// make a hash of the search so we can know if we've already logged it this session |
|
239
|
5 |
|
$loggedFilters = !empty($filters) ? json_encode($filters) : null; |
|
240
|
5 |
|
$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
|
5 |
|
if ($start == 0 && $logSearch && (!empty($keywords) || !empty($filters))) { // && !in_array($searchHash, $sessSearches)) { |
|
|
|
|
|
|
248
|
5 |
|
$log = SearchLog::create(array( |
|
249
|
5 |
|
'Query' => $loggedQuery, |
|
250
|
5 |
|
'Title' => !empty($vars[$qs_t]) ? $vars[$qs_t] : '', |
|
251
|
5 |
|
'Link' => Controller::curr()->getRequest()->getURL(true), // I'm not 100% happy with this, but can't think of a better way |
|
252
|
5 |
|
'NumResults' => $results->TotalMatches, |
|
253
|
5 |
|
'MemberID' => Member::currentUserID(), |
|
254
|
5 |
|
'Filters' => $loggedFilters, |
|
255
|
5 |
|
'ParentSearchID'=> !empty($vars[$qs_ps]) ? $vars[$qs_ps] : 0, |
|
256
|
5 |
|
)); |
|
257
|
5 |
|
$log->write(); |
|
258
|
5 |
|
$results->SearchLogID = $log->ID; |
|
259
|
5 |
|
$results->SearchBreadcrumbs = $log->getBreadcrumbs(); |
|
260
|
|
|
|
|
261
|
|
|
// $sessSearches[] = $searchHash; |
|
|
|
|
|
|
262
|
|
|
// Session::set('loggedSearches', $sessSearches); |
|
263
|
5 |
|
} |
|
264
|
|
|
|
|
265
|
5 |
|
return $results; |
|
266
|
|
|
} |
|
267
|
|
|
|
|
268
|
|
|
/** |
|
269
|
|
|
* @param string $str |
|
270
|
|
|
* @return SS_Query |
|
271
|
|
|
*/ |
|
272
|
1 |
|
public function getSuggestQuery($str='') |
|
273
|
|
|
{ |
|
274
|
1 |
|
$hasResults = 'CASE WHEN max("SearchLog"."NumResults") > 0 THEN 1 ELSE 0 END'; |
|
275
|
1 |
|
$searchCount = 'count(distinct "SearchLog"."ID")'; |
|
276
|
1 |
|
$q = new SQLQuery(); |
|
|
|
|
|
|
277
|
1 |
|
$q = $q->setSelect('"SearchLog"."Query"') |
|
278
|
|
|
// TODO: what to do with filter? |
|
279
|
1 |
|
->selectField($searchCount, 'SearchCount') |
|
280
|
1 |
|
->selectField('max("SearchLog"."Created")', 'LastSearch') |
|
281
|
1 |
|
->selectField('max("SearchLog"."NumResults")', 'NumResults') |
|
282
|
1 |
|
->selectField($hasResults, 'HasResults') |
|
283
|
1 |
|
->setFrom('"SearchLog"') |
|
284
|
1 |
|
->setGroupBy('"SearchLog"."Query"') |
|
285
|
1 |
|
->setOrderBy(array( |
|
286
|
1 |
|
"$hasResults DESC", |
|
287
|
1 |
|
"$searchCount DESC" |
|
288
|
1 |
|
)) |
|
289
|
1 |
|
->setLimit(Config::inst()->get('ShopSearch', 'suggest_limit')) |
|
290
|
1 |
|
; |
|
291
|
|
|
|
|
292
|
1 |
|
if (strlen($str) > 0) { |
|
293
|
1 |
|
$q = $q->addWhere(sprintf('"SearchLog"."Query" LIKE \'%%%s%%\'', Convert::raw2sql($str))); |
|
294
|
1 |
|
} |
|
295
|
|
|
|
|
296
|
1 |
|
return $q; |
|
297
|
|
|
} |
|
298
|
|
|
|
|
299
|
|
|
|
|
300
|
|
|
/** |
|
301
|
|
|
* @param string $str |
|
302
|
|
|
* @return array |
|
303
|
|
|
*/ |
|
304
|
1 |
|
public function suggest($str='') |
|
305
|
|
|
{ |
|
306
|
1 |
|
$adapter = self::adapter(); |
|
307
|
1 |
|
if ($adapter->hasMethod('suggest')) { |
|
308
|
|
|
return $adapter->suggest($str); |
|
|
|
|
|
|
309
|
|
|
} else { |
|
310
|
1 |
|
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.