1
|
|
|
<?php |
|
|
|
|
2
|
|
|
if (!class_exists('SolrIndex')) { |
3
|
|
|
return; |
4
|
|
|
} |
5
|
|
|
|
6
|
|
|
/** |
7
|
|
|
* Search driver for the fulltext module with solr backend. |
8
|
|
|
* |
9
|
|
|
* @author Mark Guinn <[email protected]> |
10
|
|
|
* @date 08.29.2013 |
11
|
|
|
* @package shop_search |
12
|
|
|
*/ |
13
|
|
|
class ShopSearchSolr extends SolrIndex implements ShopSearchAdapter |
|
|
|
|
14
|
|
|
{ |
15
|
|
|
/** @var array - maps our names for fields to Solr's names (i.e. Title => SiteTree_Title) */ |
16
|
|
|
protected $fieldMap = array(); |
17
|
|
|
|
18
|
|
|
/** |
19
|
|
|
* Sets up the index |
20
|
|
|
*/ |
21
|
|
|
public function init() |
22
|
|
|
{ |
23
|
|
|
$searchables = ShopSearch::get_searchable_classes(); |
24
|
|
|
|
25
|
|
|
// Add each class to the index |
26
|
|
|
foreach ($searchables as $class) { |
27
|
|
|
$this->addClass($class); |
28
|
|
|
} |
29
|
|
|
|
30
|
|
|
// add the fields they've specifically asked for |
31
|
|
|
$fields = $this->getFulltextSpec(); |
32
|
|
|
foreach ($fields as $def) { |
33
|
|
|
$this->addFulltextField($def['field'], $def['type'], $def['params']); |
34
|
|
|
} |
35
|
|
|
|
36
|
|
|
// add the filters they've asked for |
37
|
|
|
$filters = $this->getFilterSpec(); |
38
|
|
|
foreach ($filters as $filterName => $def) { |
39
|
|
|
// NOTE: I'm pulling the guts out of this function so we can access Solr's full name |
40
|
|
|
// for the field (SiteTree_Title for Title) and build the fieldMap in one step instead |
41
|
|
|
// of two. |
42
|
|
|
//$this->addFilterField($def['field'], $def['type'], $def['params']); |
|
|
|
|
43
|
|
|
$singleFilter = $this->fieldData($def['field'], $def['type'], $def['params']); |
44
|
|
|
$this->filterFields = array_merge($this->filterFields, $singleFilter); |
45
|
|
|
foreach ($singleFilter as $solrName => $solrDef) { |
46
|
|
|
if ($def['field'] == $solrDef['field']) { |
47
|
|
|
$this->fieldMap[$filterName] = $solrName; |
48
|
|
|
} |
49
|
|
|
} |
50
|
|
|
} |
51
|
|
|
|
52
|
|
|
// Debug::dump($this->filterFields); |
|
|
|
|
53
|
|
|
|
54
|
|
|
// Add spellcheck fields |
|
|
|
|
55
|
|
|
// $spellFields = $cfg->get('ShopSearch', 'spellcheck_dictionary_source'); |
56
|
|
|
// if (empty($spellFields) || !is_array($spellFields)) { |
57
|
|
|
// $spellFields = array(); |
58
|
|
|
// $ftFields = $this->getFulltextFields(); |
59
|
|
|
// foreach ($ftFields as $name => $fieldDef) { |
60
|
|
|
// $spellFields[] = $name; |
61
|
|
|
// } |
62
|
|
|
// } |
63
|
|
|
// |
64
|
|
|
// foreach ($spellFields as $f) { |
65
|
|
|
// $this->addCopyField($f, '_spellcheckContent'); |
66
|
|
|
// } |
67
|
|
|
|
68
|
|
|
// Technically, filter and sort fields are the same in Solr/Lucene |
|
|
|
|
69
|
|
|
// $this->addSortField('ViewCount'); |
70
|
|
|
// $this->addSortField('LastEdited', 'SSDatetime'); |
71
|
|
|
|
72
|
|
|
// Aggregate fields for spelling checks |
|
|
|
|
73
|
|
|
// $this->addCopyField('Title', 'spellcheckData'); |
74
|
|
|
// $this->addCopyField('Content', 'spellcheckData'); |
75
|
|
|
|
76
|
|
|
// $this->addFullTextField('Category', 'Int', array( |
|
|
|
|
77
|
|
|
// 'multi_valued' => true, |
78
|
|
|
// 'stored' => true, |
79
|
|
|
// 'lookup_chain' => array( |
80
|
|
|
// 'call' => 'method', |
81
|
|
|
// 'method' => 'getAllProductCategoryIDs', |
82
|
|
|
// ) |
83
|
|
|
// )); |
84
|
|
|
|
85
|
|
|
// I can't get this to work. Need a way to create the Category field that get used |
86
|
|
|
// $this->addFilterField('Category', 'Int'); |
87
|
|
|
// $this->addFilterField('Parent.ID'); |
88
|
|
|
// $this->addFilterField('ProductCategories.ID'); |
89
|
|
|
// $this->addCopyField('SiteTree_Parent_ID', 'Category'); |
90
|
|
|
// $this->addCopyField('Product_ProductCategories_ID', 'Category'); |
91
|
|
|
|
92
|
|
|
// These will be added in a pull request to shop module. If they're not present they'll be ignored |
|
|
|
|
93
|
|
|
// $this->addFilterField('AllCategoryIDs', 'Int', array('multiValued' => 'true')); |
94
|
|
|
// $this->addFilterField('AllRecursiveCategoryIDs', 'Int', array('multiValued' => 'true')); |
95
|
|
|
|
96
|
|
|
// This will cause only live pages to be indexed. There are two ways to do |
97
|
|
|
// this. See fulltextsearch/docs/en/index.md for more information. |
98
|
|
|
// Not sure if this is really the way to go or not, but for now this is it. |
99
|
|
|
$this->excludeVariantState(array('SearchVariantVersioned' => 'Stage')); |
100
|
|
|
} |
101
|
|
|
|
102
|
|
|
|
103
|
|
|
/** |
104
|
|
|
* Transforms different formats of field list into something we can pass to solr |
105
|
|
|
* @param array $in |
106
|
|
|
* @return array |
107
|
|
|
*/ |
108
|
|
|
protected function scrubFieldList($in) |
109
|
|
|
{ |
110
|
|
|
$out = array(); |
111
|
|
|
if (empty($in) || !is_array($in)) { |
112
|
|
|
return $out; |
113
|
|
|
} |
114
|
|
|
|
115
|
|
|
foreach ($in as $name => $val) { |
116
|
|
|
// supports an indexed array format of simple field names |
117
|
|
|
if (is_numeric($name)) { |
118
|
|
|
$name = $val; |
119
|
|
|
$val = true; |
120
|
|
|
} |
121
|
|
|
|
122
|
|
|
// supports a boolean value meaning "use the default setup" |
123
|
|
|
$params = !is_array($val) ? array() : array_slice($val, 0); |
124
|
|
|
|
125
|
|
|
// build a normalized structur |
126
|
|
|
$def = array( |
127
|
|
|
'field' => isset($params['field']) ? $params['field'] : $name, |
128
|
|
|
'type' => isset($params['type']) ? $params['type'] : null, |
129
|
|
|
'params' => $params, |
130
|
|
|
); |
131
|
|
|
|
132
|
|
|
if (isset($def['params']['field'])) { |
133
|
|
|
unset($def['params']['field']); |
134
|
|
|
} |
135
|
|
|
if (isset($def['params']['type'])) { |
136
|
|
|
unset($def['params']['type']); |
137
|
|
|
} |
138
|
|
|
|
139
|
|
|
$out[$name] = $def; |
140
|
|
|
} |
141
|
|
|
|
142
|
|
|
return $out; |
143
|
|
|
} |
144
|
|
|
|
145
|
|
|
|
146
|
|
|
/** |
147
|
|
|
* @return array |
148
|
|
|
*/ |
149
|
|
|
protected function getFulltextSpec() |
150
|
|
|
{ |
151
|
|
|
$fields = Config::inst()->get('ShopSearch', 'solr_fulltext_fields'); |
152
|
|
|
if (empty($fields)) { |
153
|
|
|
$fields = array('Title', 'Content'); |
154
|
|
|
} |
155
|
|
|
return $this->scrubFieldList($fields); |
156
|
|
|
} |
157
|
|
|
|
158
|
|
|
|
159
|
|
|
/** |
160
|
|
|
* |
161
|
|
|
*/ |
162
|
|
|
protected function getFilterSpec() |
163
|
|
|
{ |
164
|
|
|
$fields = Config::inst()->get('ShopSearch', 'solr_filter_fields'); |
165
|
|
|
return $this->scrubFieldList($fields); |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
|
169
|
|
|
/** |
170
|
|
|
* @return string |
171
|
|
|
*/ |
172
|
|
|
public function getFieldDefinitions() |
173
|
|
|
{ |
174
|
|
|
$xml = parent::getFieldDefinitions(); |
175
|
|
|
// $xml .= "\n\t\t<field name='_spellcheckContent' type='htmltext' indexed='true' stored='false' multiValued='true' />"; |
|
|
|
|
176
|
|
|
|
177
|
|
|
// create a sorting column |
178
|
|
|
if (isset($this->fieldMap['Title']) || ShopSearch::config()->solr_title_sort_field) { |
179
|
|
|
$f = empty(ShopSearch::config()->title_sort_field) ? '_titleSort' : ShopSearch::config()->solr_title_sort_field; |
180
|
|
|
$xml .= "\n\t\t" . '<field name="' . $f . '" type="alphaOnlySort" indexed="true" stored="false" required="false" multiValued="false" />'; |
181
|
|
|
$xml .= "\n\t\t" . '<copyField source="SiteTree_Title" dest="' . $f . '"/>'; |
182
|
|
|
} |
183
|
|
|
|
184
|
|
|
// create an autocomplete column |
185
|
|
|
if (ShopSearch::config()->suggest_enabled) { |
186
|
|
|
$xml .= "\n\t\t<field name='_autocomplete' type='autosuggest_text' indexed='true' stored='false' multiValued='true'/>"; |
187
|
|
|
} |
188
|
|
|
|
189
|
|
|
return $xml; |
190
|
|
|
} |
191
|
|
|
|
192
|
|
|
|
193
|
|
|
/** |
194
|
|
|
* @return string |
195
|
|
|
*/ |
196
|
|
|
public function getCopyFieldDefinitions() |
197
|
|
|
{ |
198
|
|
|
$xml = parent::getCopyFieldDefinitions(); |
199
|
|
|
|
200
|
|
|
if (ShopSearch::config()->suggest_enabled) { |
201
|
|
|
foreach ($this->fulltextFields as $name => $field) { |
202
|
|
|
$xml .= "\n\t<copyField source='{$name}' dest='_autocomplete' />"; |
203
|
|
|
//$xml .= "\n\t<copyField source='{$name}' dest='_spellcheckContent' />"; |
|
|
|
|
204
|
|
|
} |
205
|
|
|
} |
206
|
|
|
|
207
|
|
|
return $xml; |
208
|
|
|
} |
209
|
|
|
|
210
|
|
|
|
211
|
|
|
/** |
212
|
|
|
* Overrides the parent to add a field for autocomplete |
213
|
|
|
* @return HTMLText |
214
|
|
|
*/ |
215
|
|
|
public function getTypes() |
216
|
|
|
{ |
217
|
|
|
$val = parent::getTypes(); |
218
|
|
|
if (!$val || !is_object($val)) { |
219
|
|
|
return $val; |
220
|
|
|
} |
221
|
|
|
$xml = $val->getValue(); |
222
|
|
|
$xml .= <<<XML |
223
|
|
|
|
224
|
|
|
<fieldType name="autosuggest_text" class="solr.TextField" |
225
|
|
|
positionIncrementGap="100"> |
226
|
|
|
<analyzer type="index"> |
227
|
|
|
<tokenizer class="solr.StandardTokenizerFactory"/> |
228
|
|
|
<filter class="solr.LowerCaseFilterFactory"/> |
229
|
|
|
<filter class="solr.ShingleFilterFactory" minShingleSize="2" maxShingleSize="4" outputUnigrams="true" outputUnigramsIfNoShingles="true" /> |
230
|
|
|
<filter class="solr.PatternReplaceFilterFactory" pattern="^([0-9. ])*$" replacement="" |
231
|
|
|
replace="all"/> |
232
|
|
|
<filter class="solr.RemoveDuplicatesTokenFilterFactory"/> |
233
|
|
|
</analyzer> |
234
|
|
|
<analyzer type="query"> |
235
|
|
|
<tokenizer class="solr.StandardTokenizerFactory"/> |
236
|
|
|
<filter class="solr.LowerCaseFilterFactory"/> |
237
|
|
|
</analyzer> |
238
|
|
|
</fieldType> |
239
|
|
|
|
240
|
|
|
XML; |
241
|
|
|
$val->setValue($xml); |
242
|
|
|
return $val; |
243
|
|
|
} |
244
|
|
|
|
245
|
|
|
/** |
246
|
|
|
* This is an intermediary to bridge the search form input |
247
|
|
|
* and the SearchQuery class. It allows us to have other |
248
|
|
|
* drivers that may not use the FullTextSearch module. |
249
|
|
|
* |
250
|
|
|
* @param string $keywords |
251
|
|
|
* @param array $filters [optional] |
252
|
|
|
* @param array $facetSpec [optional] |
253
|
|
|
* @param int $start [optional] |
254
|
|
|
* @param int $limit [optional] |
255
|
|
|
* @param string $sort [optional] |
256
|
|
|
* @return ArrayData |
257
|
|
|
*/ |
258
|
|
|
public function searchFromVars($keywords, array $filters=array(), array $facetSpec=array(), $start=-1, $limit=-1, $sort='score desc') |
259
|
|
|
{ |
260
|
|
|
$query = new SearchQuery(); |
261
|
|
|
$params = array( |
262
|
|
|
'sort' => $sort, |
263
|
|
|
); |
264
|
|
|
|
265
|
|
|
// swap out title search |
266
|
|
|
if ($params['sort'] == 'SiteTree_Title') { |
267
|
|
|
$params['sort'] = '_titleSort'; |
268
|
|
|
} |
269
|
|
|
|
270
|
|
|
// search by keywords |
271
|
|
|
$query->search(empty($keywords) ? '*:*' : $keywords); |
272
|
|
|
|
273
|
|
|
// search by filter |
274
|
|
|
foreach ($filters as $k => $v) { |
275
|
|
|
if (isset($this->fieldMap[$k])) { |
276
|
|
|
if (is_string($v) && preg_match('/^RANGE\~(.+)\~(.+)$/', $v, $m)) { |
277
|
|
|
// Is it a range value? |
278
|
|
|
$range = new SearchQuery_Range($m[1], $m[2]); |
279
|
|
|
$query->filter($this->fieldMap[$k], $range); |
280
|
|
|
} else { |
281
|
|
|
// Or a normal scalar value |
282
|
|
|
$query->filter($this->fieldMap[$k], $v); |
283
|
|
|
} |
284
|
|
|
} |
285
|
|
|
} |
286
|
|
|
|
287
|
|
|
// add facets |
288
|
|
|
$facetSpec = FacetHelper::inst()->expandFacetSpec($facetSpec); |
289
|
|
|
$params += $this->buildFacetParams($facetSpec); |
290
|
|
|
|
291
|
|
|
// TODO: add spellcheck |
292
|
|
|
|
293
|
|
|
return $this->search($query, $start, $limit, $params, $facetSpec); |
294
|
|
|
} |
295
|
|
|
|
296
|
|
|
|
297
|
|
|
/** |
298
|
|
|
* @param string $keywords |
299
|
|
|
* @param array $filters |
300
|
|
|
* @return array |
301
|
|
|
*/ |
302
|
|
|
public function suggestWithResults($keywords, array $filters = array()) |
303
|
|
|
{ |
304
|
|
|
$limit = (int)ShopSearch::config()->sayt_limit; |
305
|
|
|
|
306
|
|
|
// process the keywords a bit |
307
|
|
|
$terms = preg_split('/\s+/', trim(strtolower($keywords))); |
308
|
|
|
$lastTerm = count($terms) > 0 ? array_pop($terms) : ''; |
309
|
|
|
$prefix = count($terms) > 0 ? implode(' ', $terms) . ' ' : ''; |
310
|
|
|
//$terms[] = $lastTerm; |
|
|
|
|
311
|
|
|
$terms[] = $lastTerm . '*'; // this allows for partial words to still match |
312
|
|
|
|
313
|
|
|
// convert that to something solr adapater can handle |
314
|
|
|
$query = new SearchQuery(); |
315
|
|
|
$query->search('+' . implode(' +', $terms)); |
316
|
|
|
|
317
|
|
|
$params = array( |
318
|
|
|
'sort' => 'score desc', |
319
|
|
|
'facet' => 'true', |
320
|
|
|
'facet.field' => '_autocomplete', |
321
|
|
|
'facet.limit' => ShopSearch::config()->suggest_limit, |
322
|
|
|
'facet.prefix' => $lastTerm, |
323
|
|
|
); |
324
|
|
|
|
325
|
|
|
// $facetSpec = array( |
|
|
|
|
326
|
|
|
// '_autocomplete' => array( |
327
|
|
|
// 'Type' => ShopSearch::FACET_TYPE_LINK, |
328
|
|
|
// 'Label' => 'Suggestions', |
329
|
|
|
// 'Source' => '_autocomplete', |
330
|
|
|
// ), |
331
|
|
|
// ); |
332
|
|
|
// |
333
|
|
|
// Debug::dump($query); |
334
|
|
|
// |
335
|
|
|
// $search = $this->search($query, 0, $limit, $params, $facetSpec); |
336
|
|
|
// Debug::dump($search); |
337
|
|
|
// $prodList = $search->Matches; |
338
|
|
|
// |
339
|
|
|
// $suggestsion = array(); |
340
|
|
|
//// if ($) |
341
|
|
|
|
342
|
|
|
$service = $this->getService(); |
343
|
|
|
|
344
|
|
|
SearchVariant::with(count($query->classes) == 1 ? $query->classes[0]['class'] : null)->call('alterQuery', $query, $this); |
345
|
|
|
|
346
|
|
|
$q = $terms; |
347
|
|
|
$fq = array(); |
348
|
|
|
|
349
|
|
|
// Build the search itself |
|
|
|
|
350
|
|
|
// foreach ($query->search as $search) { |
351
|
|
|
// $text = $search['text']; |
352
|
|
|
// preg_match_all('/"[^"]*"|\S+/', $text, $parts); |
353
|
|
|
// |
354
|
|
|
// $fuzzy = $search['fuzzy'] ? '~' : ''; |
355
|
|
|
// |
356
|
|
|
// foreach ($parts[0] as $part) { |
357
|
|
|
// $fields = (isset($search['fields'])) ? $search['fields'] : array(); |
358
|
|
|
// if(isset($search['boost'])) $fields = array_merge($fields, array_keys($search['boost'])); |
359
|
|
|
// if ($fields) { |
360
|
|
|
// $searchq = array(); |
361
|
|
|
// foreach ($fields as $field) { |
362
|
|
|
// $boost = (isset($search['boost'][$field])) ? '^' . $search['boost'][$field] : ''; |
363
|
|
|
// $searchq[] = "{$field}:".$part.$fuzzy.$boost; |
364
|
|
|
// } |
365
|
|
|
// $q[] = '+('.implode(' OR ', $searchq).')'; |
366
|
|
|
// } |
367
|
|
|
// else { |
368
|
|
|
// $q[] = '+'.$part.$fuzzy; |
369
|
|
|
// } |
370
|
|
|
// } |
371
|
|
|
// } |
372
|
|
|
|
373
|
|
|
// Filter by class if requested |
374
|
|
|
$classq = array(); |
375
|
|
|
|
376
|
|
View Code Duplication |
foreach ($query->classes as $class) { |
|
|
|
|
377
|
|
|
if (!empty($class['includeSubclasses'])) { |
378
|
|
|
$classq[] = 'ClassHierarchy:'.$class['class']; |
379
|
|
|
} else { |
380
|
|
|
$classq[] = 'ClassName:'.$class['class']; |
381
|
|
|
} |
382
|
|
|
} |
383
|
|
|
|
384
|
|
|
if ($classq) { |
|
|
|
|
385
|
|
|
$fq[] = '+('.implode(' ', $classq).')'; |
386
|
|
|
} |
387
|
|
|
|
388
|
|
|
// Filter by filters |
389
|
|
View Code Duplication |
foreach ($query->require as $field => $values) { |
|
|
|
|
390
|
|
|
$requireq = array(); |
391
|
|
|
|
392
|
|
|
foreach ($values as $value) { |
393
|
|
|
if ($value === SearchQuery::$missing) { |
394
|
|
|
$requireq[] = "(*:* -{$field}:[* TO *])"; |
395
|
|
|
} elseif ($value === SearchQuery::$present) { |
396
|
|
|
$requireq[] = "{$field}:[* TO *]"; |
397
|
|
|
} elseif ($value instanceof SearchQuery_Range) { |
|
|
|
|
398
|
|
|
$start = $value->start; |
399
|
|
|
if ($start === null) { |
400
|
|
|
$start = '*'; |
401
|
|
|
} |
402
|
|
|
$end = $value->end; |
403
|
|
|
if ($end === null) { |
404
|
|
|
$end = '*'; |
405
|
|
|
} |
406
|
|
|
$requireq[] = "$field:[$start TO $end]"; |
407
|
|
|
} else { |
408
|
|
|
$requireq[] = $field.':"'.$value.'"'; |
409
|
|
|
} |
410
|
|
|
} |
411
|
|
|
|
412
|
|
|
$fq[] = '+('.implode(' ', $requireq).')'; |
413
|
|
|
} |
414
|
|
|
|
415
|
|
View Code Duplication |
foreach ($query->exclude as $field => $values) { |
|
|
|
|
416
|
|
|
$excludeq = array(); |
417
|
|
|
$missing = false; |
418
|
|
|
|
419
|
|
|
foreach ($values as $value) { |
420
|
|
|
if ($value === SearchQuery::$missing) { |
421
|
|
|
$missing = true; |
422
|
|
|
} elseif ($value === SearchQuery::$present) { |
423
|
|
|
$excludeq[] = "{$field}:[* TO *]"; |
424
|
|
|
} elseif ($value instanceof SearchQuery_Range) { |
|
|
|
|
425
|
|
|
$start = $value->start; |
426
|
|
|
if ($start === null) { |
427
|
|
|
$start = '*'; |
428
|
|
|
} |
429
|
|
|
$end = $value->end; |
430
|
|
|
if ($end === null) { |
431
|
|
|
$end = '*'; |
432
|
|
|
} |
433
|
|
|
$excludeq[] = "$field:[$start TO $end]"; |
434
|
|
|
} else { |
435
|
|
|
$excludeq[] = $field.':"'.$value.'"'; |
436
|
|
|
} |
437
|
|
|
} |
438
|
|
|
|
439
|
|
|
$fq[] = ($missing ? "+{$field}:[* TO *] " : '') . '-('.implode(' ', $excludeq).')'; |
440
|
|
|
} |
441
|
|
|
|
442
|
|
|
// if(!headers_sent()) { |
|
|
|
|
443
|
|
|
// if ($q) header('X-Query: '.implode(' ', $q)); |
444
|
|
|
// if ($fq) header('X-Filters: "'.implode('", "', $fq).'"'); |
445
|
|
|
// } |
446
|
|
|
|
447
|
|
|
$params = array_merge($params, array('fq' => implode(' ', $fq))); |
448
|
|
|
|
449
|
|
|
$res = $service->search( |
450
|
|
|
implode(' ', $q), |
451
|
|
|
0, |
452
|
|
|
$limit, |
453
|
|
|
$params, |
454
|
|
|
Apache_Solr_Service::METHOD_POST |
455
|
|
|
); |
456
|
|
|
|
457
|
|
|
$results = new ArrayList(); |
458
|
|
|
if ($res->getHttpStatus() >= 200 && $res->getHttpStatus() < 300) { |
459
|
|
|
foreach ($res->response->docs as $doc) { |
460
|
|
|
$result = DataObject::get_by_id($doc->ClassName, $doc->ID); |
461
|
|
|
if ($result) { |
462
|
|
|
$results->push($result); |
463
|
|
|
} |
464
|
|
|
} |
465
|
|
|
$numFound = $res->response->numFound; |
466
|
|
|
} else { |
467
|
|
|
$numFound = 0; |
468
|
|
|
} |
469
|
|
|
|
470
|
|
|
$ret = array(); |
471
|
|
|
$ret['products'] = new PaginatedList($results); |
472
|
|
|
$ret['products']->setLimitItems(false); |
473
|
|
|
$ret['products']->setTotalItems($numFound); |
474
|
|
|
$ret['products']->setPageStart(0); |
475
|
|
|
$ret['products']->setPageLength($limit); |
476
|
|
|
|
477
|
|
|
// Facets (this is how we're doing suggestions for now... |
478
|
|
|
$ret['suggestions'] = array(); |
479
|
|
|
if (isset($res->facet_counts->facet_fields->_autocomplete)) { |
480
|
|
|
foreach ($res->facet_counts->facet_fields->_autocomplete as $term => $count) { |
481
|
|
|
$ret['suggestions'][] = $prefix . $term; |
482
|
|
|
} |
483
|
|
|
} |
484
|
|
|
|
485
|
|
|
// Suggestions (requires custom setup, assumes spellcheck.collate=true) |
|
|
|
|
486
|
|
|
// if(isset($res->spellcheck->suggestions->collation)) { |
487
|
|
|
// $ret['Suggestion'] = $res->spellcheck->suggestions->collation; |
488
|
|
|
// } |
489
|
|
|
|
490
|
|
|
return $ret; |
491
|
|
|
} |
492
|
|
|
|
493
|
|
|
/** |
494
|
|
|
* @param $facets |
495
|
|
|
* @return array |
496
|
|
|
*/ |
497
|
|
|
protected function buildFacetParams(array $facets) |
498
|
|
|
{ |
499
|
|
|
$params = array(); |
500
|
|
|
|
501
|
|
|
if (!empty($facets)) { |
502
|
|
|
$params['facet'] = 'true'; |
503
|
|
|
|
504
|
|
|
foreach ($facets as $name => $spec) { |
505
|
|
|
// With our current implementation, "range" facets aren't true facets in solr terms. |
506
|
|
|
// They're just a type of filter which can be handled elsewhere. |
507
|
|
|
// For the other types we just ignore the rest of the spec and let Solr do its thing |
508
|
|
|
if ($spec['Type'] != ShopSearch::FACET_TYPE_RANGE && isset($this->fieldMap[$name])) { |
509
|
|
|
$params['facet.field'] = $this->fieldMap[$name]; |
510
|
|
|
} |
511
|
|
|
} |
512
|
|
|
} |
513
|
|
|
|
514
|
|
|
return $params; |
515
|
|
|
} |
516
|
|
|
|
517
|
|
|
|
518
|
|
|
/** |
519
|
|
|
* Fulltextsearch module doesn't yet support facets very well, so I've just copied this function here so |
520
|
|
|
* we have access to the results. I'd prefer to modify it minimally so we can eventually get rid of it |
521
|
|
|
* once they add faceting or hooks to get directly at the returned response. |
522
|
|
|
* |
523
|
|
|
* @param SearchQuery $query |
524
|
|
|
* @param integer $offset |
525
|
|
|
* @param integer $limit |
526
|
|
|
* @param Array $params Extra request parameters passed through to Solr |
527
|
|
|
* @param array $facetSpec - Added for ShopSearch so we can process the facets |
528
|
|
|
* @return ArrayData Map with the following keys: |
529
|
|
|
* - 'Matches': ArrayList of the matched object instances |
530
|
|
|
*/ |
531
|
|
|
public function search(SearchQuery $query, $offset = -1, $limit = -1, $params = array(), $facetSpec = array()) |
532
|
|
|
{ |
533
|
|
|
$service = $this->getService(); |
534
|
|
|
|
535
|
|
|
SearchVariant::with(count($query->classes) == 1 ? $query->classes[0]['class'] : null)->call('alterQuery', $query, $this); |
536
|
|
|
|
537
|
|
|
$q = array(); |
538
|
|
|
$fq = array(); |
539
|
|
|
|
540
|
|
|
// Build the search itself |
541
|
|
|
|
542
|
|
|
foreach ($query->search as $search) { |
543
|
|
|
$text = $search['text']; |
544
|
|
|
preg_match_all('/"[^"]*"|\S+/', $text, $parts); |
545
|
|
|
|
546
|
|
|
$fuzzy = $search['fuzzy'] ? '~' : ''; |
547
|
|
|
|
548
|
|
|
foreach ($parts[0] as $part) { |
549
|
|
|
$fields = (isset($search['fields'])) ? $search['fields'] : array(); |
550
|
|
|
if (isset($search['boost'])) { |
551
|
|
|
$fields = array_merge($fields, array_keys($search['boost'])); |
552
|
|
|
} |
553
|
|
|
if ($fields) { |
554
|
|
|
$searchq = array(); |
555
|
|
|
foreach ($fields as $field) { |
556
|
|
|
$boost = (isset($search['boost'][$field])) ? '^' . $search['boost'][$field] : ''; |
557
|
|
|
$searchq[] = "{$field}:".$part.$fuzzy.$boost; |
558
|
|
|
} |
559
|
|
|
$q[] = '+('.implode(' OR ', $searchq).')'; |
560
|
|
|
} else { |
561
|
|
|
$q[] = '+'.$part.$fuzzy; |
562
|
|
|
} |
563
|
|
|
} |
564
|
|
|
} |
565
|
|
|
|
566
|
|
|
// Filter by class if requested |
567
|
|
|
|
568
|
|
|
$classq = array(); |
569
|
|
|
|
570
|
|
View Code Duplication |
foreach ($query->classes as $class) { |
|
|
|
|
571
|
|
|
if (!empty($class['includeSubclasses'])) { |
572
|
|
|
$classq[] = 'ClassHierarchy:'.$class['class']; |
573
|
|
|
} else { |
574
|
|
|
$classq[] = 'ClassName:'.$class['class']; |
575
|
|
|
} |
576
|
|
|
} |
577
|
|
|
|
578
|
|
|
if ($classq) { |
|
|
|
|
579
|
|
|
$fq[] = '+('.implode(' ', $classq).')'; |
580
|
|
|
} |
581
|
|
|
|
582
|
|
|
// Filter by filters |
583
|
|
|
|
584
|
|
View Code Duplication |
foreach ($query->require as $field => $values) { |
|
|
|
|
585
|
|
|
$requireq = array(); |
586
|
|
|
|
587
|
|
|
foreach ($values as $value) { |
588
|
|
|
if ($value === SearchQuery::$missing) { |
589
|
|
|
$requireq[] = "(*:* -{$field}:[* TO *])"; |
590
|
|
|
} elseif ($value === SearchQuery::$present) { |
591
|
|
|
$requireq[] = "{$field}:[* TO *]"; |
592
|
|
|
} elseif ($value instanceof SearchQuery_Range) { |
|
|
|
|
593
|
|
|
$start = $value->start; |
594
|
|
|
if ($start === null) { |
595
|
|
|
$start = '*'; |
596
|
|
|
} |
597
|
|
|
$end = $value->end; |
598
|
|
|
if ($end === null) { |
599
|
|
|
$end = '*'; |
600
|
|
|
} |
601
|
|
|
$requireq[] = "$field:[$start TO $end]"; |
602
|
|
|
} else { |
603
|
|
|
$requireq[] = $field.':"'.$value.'"'; |
604
|
|
|
} |
605
|
|
|
} |
606
|
|
|
|
607
|
|
|
$fq[] = '+('.implode(' ', $requireq).')'; |
608
|
|
|
} |
609
|
|
|
|
610
|
|
View Code Duplication |
foreach ($query->exclude as $field => $values) { |
|
|
|
|
611
|
|
|
$excludeq = array(); |
612
|
|
|
$missing = false; |
613
|
|
|
|
614
|
|
|
foreach ($values as $value) { |
615
|
|
|
if ($value === SearchQuery::$missing) { |
616
|
|
|
$missing = true; |
617
|
|
|
} elseif ($value === SearchQuery::$present) { |
618
|
|
|
$excludeq[] = "{$field}:[* TO *]"; |
619
|
|
|
} elseif ($value instanceof SearchQuery_Range) { |
|
|
|
|
620
|
|
|
$start = $value->start; |
621
|
|
|
if ($start === null) { |
622
|
|
|
$start = '*'; |
623
|
|
|
} |
624
|
|
|
$end = $value->end; |
625
|
|
|
if ($end === null) { |
626
|
|
|
$end = '*'; |
627
|
|
|
} |
628
|
|
|
$excludeq[] = "$field:[$start TO $end]"; |
629
|
|
|
} else { |
630
|
|
|
$excludeq[] = $field.':"'.$value.'"'; |
631
|
|
|
} |
632
|
|
|
} |
633
|
|
|
|
634
|
|
|
$fq[] = ($missing ? "+{$field}:[* TO *] " : '') . '-('.implode(' ', $excludeq).')'; |
635
|
|
|
} |
636
|
|
|
|
637
|
|
|
// if(!headers_sent()) { |
|
|
|
|
638
|
|
|
// if ($q) header('X-Query: '.implode(' ', $q)); |
639
|
|
|
// if ($fq) header('X-Filters: "'.implode('", "', $fq).'"'); |
640
|
|
|
// } |
641
|
|
|
|
642
|
|
|
if ($offset == -1) { |
643
|
|
|
$offset = $query->start; |
644
|
|
|
} |
645
|
|
|
if ($limit == -1) { |
646
|
|
|
$limit = $query->limit; |
647
|
|
|
} |
648
|
|
|
if ($limit == -1) { |
649
|
|
|
$limit = SearchQuery::$default_page_size; |
650
|
|
|
} |
651
|
|
|
|
652
|
|
|
$params = array_merge($params, array('fq' => implode(' ', $fq))); |
653
|
|
|
|
654
|
|
|
$res = $service->search( |
655
|
|
|
$q ? implode(' ', $q) : '*:*', |
656
|
|
|
$offset, |
657
|
|
|
$limit, |
658
|
|
|
$params, |
659
|
|
|
Apache_Solr_Service::METHOD_POST |
660
|
|
|
); |
661
|
|
|
//Debug::dump($res); |
|
|
|
|
662
|
|
|
|
663
|
|
|
$results = new ArrayList(); |
664
|
|
|
if ($res->getHttpStatus() >= 200 && $res->getHttpStatus() < 300) { |
665
|
|
|
foreach ($res->response->docs as $doc) { |
666
|
|
|
$result = DataObject::get_by_id($doc->ClassName, $doc->ID); |
667
|
|
|
if ($result) { |
668
|
|
|
$results->push($result); |
669
|
|
|
|
670
|
|
|
// Add highlighting (optional) |
671
|
|
|
$docId = $doc->_documentid; |
672
|
|
|
if ($res->highlighting && $res->highlighting->$docId) { |
673
|
|
|
// TODO Create decorator class for search results rather than adding arbitrary object properties |
674
|
|
|
// TODO Allow specifying highlighted field, and lazy loading |
675
|
|
|
// in case the search API needs another query (similar to SphinxSearchable->buildExcerpt()). |
676
|
|
|
$combinedHighlights = array(); |
677
|
|
|
foreach ($res->highlighting->$docId as $field => $highlights) { |
678
|
|
|
$combinedHighlights = array_merge($combinedHighlights, $highlights); |
679
|
|
|
} |
680
|
|
|
|
681
|
|
|
// Remove entity-encoded U+FFFD replacement character. It signifies non-displayable characters, |
682
|
|
|
// and shows up as an encoding error in browsers. |
683
|
|
|
$result->Excerpt = DBField::create_field( |
684
|
|
|
'HTMLText', |
685
|
|
|
str_replace( |
686
|
|
|
'�', |
687
|
|
|
'', |
688
|
|
|
implode(' ... ', $combinedHighlights) |
689
|
|
|
) |
690
|
|
|
); |
691
|
|
|
} |
692
|
|
|
} |
693
|
|
|
} |
694
|
|
|
$numFound = $res->response->numFound; |
695
|
|
|
} else { |
696
|
|
|
$numFound = 0; |
697
|
|
|
} |
698
|
|
|
|
699
|
|
|
$ret = array(); |
700
|
|
|
$ret['Matches'] = new PaginatedList($results); |
701
|
|
|
$ret['Matches']->setLimitItems(false); |
702
|
|
|
// Tell PaginatedList how many results there are |
703
|
|
|
$ret['Matches']->setTotalItems($numFound); |
704
|
|
|
// Results for current page start at $offset |
705
|
|
|
$ret['Matches']->setPageStart($offset); |
706
|
|
|
// Results per page |
707
|
|
|
$ret['Matches']->setPageLength($limit); |
708
|
|
|
|
709
|
|
|
// Facets |
710
|
|
|
//Debug::dump($res); |
|
|
|
|
711
|
|
|
if (isset($res->facet_counts->facet_fields)) { |
712
|
|
|
$ret['Facets'] = $this->buildFacetResults($res->facet_counts->facet_fields, $facetSpec); |
713
|
|
|
} |
714
|
|
|
|
715
|
|
|
// Suggestions (requires custom setup, assumes spellcheck.collate=true) |
716
|
|
|
if (isset($res->spellcheck->suggestions->collation)) { |
717
|
|
|
$ret['Suggestion'] = $res->spellcheck->suggestions->collation; |
718
|
|
|
} |
719
|
|
|
|
720
|
|
|
return new ArrayData($ret); |
721
|
|
|
} |
722
|
|
|
|
723
|
|
|
|
724
|
|
|
/** |
725
|
|
|
* @param stdClass $facetFields |
726
|
|
|
* @param array $facetSpec |
727
|
|
|
* @return ArrayList |
728
|
|
|
*/ |
729
|
|
|
protected function buildFacetResults($facetFields, array $facetSpec) |
730
|
|
|
{ |
731
|
|
|
$out = new ArrayList; |
732
|
|
|
|
733
|
|
|
foreach ($facetSpec as $field => $facet) { |
734
|
|
|
if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { |
735
|
|
|
// If it's a range facet, set up the min/max |
736
|
|
|
// TODO: we could probably get the real min and max with solr's range faceting if we tried |
737
|
|
|
if (isset($facet['RangeMin'])) { |
738
|
|
|
$facet['MinValue'] = $facet['RangeMin']; |
739
|
|
|
} |
740
|
|
|
if (isset($facet['RangeMax'])) { |
741
|
|
|
$facet['MaxValue'] = $facet['RangeMax']; |
742
|
|
|
} |
743
|
|
|
$out->push(new ArrayData($facet)); |
744
|
|
|
} elseif (isset($this->fieldMap[$field])) { |
745
|
|
|
// Otherwise, look through Solr's results |
746
|
|
|
$mySolrName = $this->fieldMap[$field]; |
747
|
|
|
foreach ($facetFields as $solrName => $values) { |
|
|
|
|
748
|
|
|
if ($solrName == $mySolrName) { |
749
|
|
|
// we found a match, look through the values we were given |
750
|
|
|
foreach ($values as $val => $count) { |
751
|
|
|
if (!isset($facet['Values'][$val])) { |
752
|
|
|
// for link type facets we want to add anything |
753
|
|
|
// for checkboxes, if it's not in the provided list we leave it out |
754
|
|
|
if ($facet['Type'] != ShopSearch::FACET_TYPE_CHECKBOX && $count > 0) { |
755
|
|
|
$facet['Values'][$val] = new ArrayData(array( |
756
|
|
|
'Label' => $val, |
757
|
|
|
'Value' => $val, |
758
|
|
|
'Count' => $count, |
759
|
|
|
)); |
760
|
|
|
} |
761
|
|
|
} elseif ($facet['Values'][$val]) { |
762
|
|
|
$facet['Values'][$val]->Count = $count; |
763
|
|
|
} |
764
|
|
|
} |
765
|
|
|
} |
766
|
|
|
} |
767
|
|
|
|
768
|
|
|
// then add that to the stack |
769
|
|
|
$facet['Values'] = new ArrayList($facet['Values']); |
770
|
|
|
$out->push(new ArrayData($facet)); |
771
|
|
|
} |
772
|
|
|
} |
773
|
|
|
|
774
|
|
|
//Debug::dump($out); |
|
|
|
|
775
|
|
|
return $out; |
776
|
|
|
} |
777
|
|
|
} |
778
|
|
|
|
The PSR-1: Basic Coding Standard recommends that a file should either introduce new symbols, that is classes, functions, constants or similar, or have side effects. Side effects are anything that executes logic, like for example printing output, changing ini settings or writing to a file.
The idea behind this recommendation is that merely auto-loading a class should not change the state of an application. It also promotes a cleaner style of programming and makes your code less prone to errors, because the logic is not spread out all over the place.
To learn more about the PSR-1, please see the PHP-FIG site on the PSR-1.