@@ -1,691 +1,691 @@ |
||
1 | 1 | <?php |
2 | 2 | /** |
3 | - * Adds methods for limited kinds of faceting using the silverstripe ORM. |
|
4 | - * This is used by the default ShopSearchSimple adapter but also can |
|
5 | - * be added to other contexts (such as ProductCategory). |
|
6 | - * |
|
7 | - * TODO: Facet class + subclasses |
|
8 | - * |
|
9 | - * @author Mark Guinn <[email protected]> |
|
10 | - * @date 10.21.2013 |
|
11 | - * @package shop_search |
|
12 | - * @subpackage helpers |
|
13 | - */ |
|
3 | + * Adds methods for limited kinds of faceting using the silverstripe ORM. |
|
4 | + * This is used by the default ShopSearchSimple adapter but also can |
|
5 | + * be added to other contexts (such as ProductCategory). |
|
6 | + * |
|
7 | + * TODO: Facet class + subclasses |
|
8 | + * |
|
9 | + * @author Mark Guinn <[email protected]> |
|
10 | + * @date 10.21.2013 |
|
11 | + * @package shop_search |
|
12 | + * @subpackage helpers |
|
13 | + */ |
|
14 | 14 | class FacetHelper extends Object |
15 | 15 | { |
16 | - /** @var bool - if this is turned on it will use an algorithm that doesn't require traversing the data set if possible */ |
|
17 | - private static $faster_faceting = false; |
|
18 | - |
|
19 | - /** @var bool - should the facets (link and checkbox only) be sorted - this can mess with things like category lists */ |
|
20 | - private static $sort_facet_values = true; |
|
21 | - |
|
22 | - /** @var string - I don't know why you'd want to override this, but you could if you wanted */ |
|
23 | - private static $attribute_facet_regex = '/^ATT(\d+)$/'; |
|
24 | - |
|
25 | - /** @var bool - For checkbox facets, is the initial state all checked or all unchecked? */ |
|
26 | - private static $default_checkbox_state = true; |
|
27 | - |
|
28 | - |
|
29 | - /** |
|
30 | - * @return FacetHelper |
|
31 | - */ |
|
32 | - public static function inst() |
|
33 | - { |
|
34 | - return Injector::inst()->get('FacetHelper'); |
|
35 | - } |
|
36 | - |
|
37 | - |
|
38 | - /** |
|
39 | - * Performs some quick pre-processing on filters from any source |
|
40 | - * |
|
41 | - * @param array $filters |
|
42 | - * @return array |
|
43 | - */ |
|
44 | - public function scrubFilters($filters) |
|
45 | - { |
|
46 | - if (!is_array($filters)) { |
|
47 | - $filters = array(); |
|
48 | - } |
|
49 | - |
|
50 | - foreach ($filters as $k => $v) { |
|
51 | - if (empty($v)) { |
|
52 | - unset($filters[$k]); |
|
53 | - } |
|
54 | - // this allows you to send an array as a comma-separated list, which is easier on the query string length |
|
55 | - if (is_string($v) && strpos($v, 'LIST~') === 0) { |
|
56 | - $filters[$k] = explode(',', substr($v, 5)); |
|
57 | - } |
|
58 | - } |
|
59 | - |
|
60 | - return $filters; |
|
61 | - } |
|
62 | - |
|
63 | - |
|
64 | - /** |
|
65 | - * @param DataList $list |
|
66 | - * @param array $filters |
|
67 | - * @param DataObject|string $sing - just a singleton object we can get information off of |
|
68 | - * @return DataList |
|
69 | - */ |
|
70 | - public function addFiltersToDataList($list, array $filters, $sing=null) |
|
71 | - { |
|
72 | - if (!$sing) { |
|
73 | - $sing = singleton($list->dataClass()); |
|
74 | - } |
|
75 | - if (is_string($sing)) { |
|
76 | - $sing = singleton($sing); |
|
77 | - } |
|
78 | - |
|
79 | - if (!empty($filters)) { |
|
80 | - foreach ($filters as $filterField => $filterVal) { |
|
81 | - if ($sing->hasExtension('HasStaticAttributes') && preg_match(self::config()->attribute_facet_regex, $filterField, $matches)) { |
|
82 | - // $sav = $sing->StaticAttributeValues(); |
|
16 | + /** @var bool - if this is turned on it will use an algorithm that doesn't require traversing the data set if possible */ |
|
17 | + private static $faster_faceting = false; |
|
18 | + |
|
19 | + /** @var bool - should the facets (link and checkbox only) be sorted - this can mess with things like category lists */ |
|
20 | + private static $sort_facet_values = true; |
|
21 | + |
|
22 | + /** @var string - I don't know why you'd want to override this, but you could if you wanted */ |
|
23 | + private static $attribute_facet_regex = '/^ATT(\d+)$/'; |
|
24 | + |
|
25 | + /** @var bool - For checkbox facets, is the initial state all checked or all unchecked? */ |
|
26 | + private static $default_checkbox_state = true; |
|
27 | + |
|
28 | + |
|
29 | + /** |
|
30 | + * @return FacetHelper |
|
31 | + */ |
|
32 | + public static function inst() |
|
33 | + { |
|
34 | + return Injector::inst()->get('FacetHelper'); |
|
35 | + } |
|
36 | + |
|
37 | + |
|
38 | + /** |
|
39 | + * Performs some quick pre-processing on filters from any source |
|
40 | + * |
|
41 | + * @param array $filters |
|
42 | + * @return array |
|
43 | + */ |
|
44 | + public function scrubFilters($filters) |
|
45 | + { |
|
46 | + if (!is_array($filters)) { |
|
47 | + $filters = array(); |
|
48 | + } |
|
49 | + |
|
50 | + foreach ($filters as $k => $v) { |
|
51 | + if (empty($v)) { |
|
52 | + unset($filters[$k]); |
|
53 | + } |
|
54 | + // this allows you to send an array as a comma-separated list, which is easier on the query string length |
|
55 | + if (is_string($v) && strpos($v, 'LIST~') === 0) { |
|
56 | + $filters[$k] = explode(',', substr($v, 5)); |
|
57 | + } |
|
58 | + } |
|
59 | + |
|
60 | + return $filters; |
|
61 | + } |
|
62 | + |
|
63 | + |
|
64 | + /** |
|
65 | + * @param DataList $list |
|
66 | + * @param array $filters |
|
67 | + * @param DataObject|string $sing - just a singleton object we can get information off of |
|
68 | + * @return DataList |
|
69 | + */ |
|
70 | + public function addFiltersToDataList($list, array $filters, $sing=null) |
|
71 | + { |
|
72 | + if (!$sing) { |
|
73 | + $sing = singleton($list->dataClass()); |
|
74 | + } |
|
75 | + if (is_string($sing)) { |
|
76 | + $sing = singleton($sing); |
|
77 | + } |
|
78 | + |
|
79 | + if (!empty($filters)) { |
|
80 | + foreach ($filters as $filterField => $filterVal) { |
|
81 | + if ($sing->hasExtension('HasStaticAttributes') && preg_match(self::config()->attribute_facet_regex, $filterField, $matches)) { |
|
82 | + // $sav = $sing->StaticAttributeValues(); |
|
83 | 83 | // Debug::log("sav = {$sav->getJoinTable()}, {$sav->getLocalKey()}, {$sav->getForeignKey()}"); |
84 | 84 | // $list = $list |
85 | 85 | // ->innerJoin($sav->getJoinTable(), "\"{$sing->baseTable()}\".\"ID\" = \"{$sav->getJoinTable()}\".\"{$sav->getLocalKey()}\"") |
86 | 86 | // ->filter("\"{$sav->getJoinTable()}\".\"{$sav->getForeignKey()}\"", $filterVal) |
87 | 87 | // ; |
88 | - // TODO: This logic should be something like the above, but I don't know |
|
89 | - // how to get the join table from a singleton (which returns an UnsavedRelationList |
|
90 | - // instead of a ManyManyList). I've got a deadline to meet, though, so this |
|
91 | - // will catch the majority of cases as long as the extension is applied to the |
|
92 | - // Product class instead of a subclass. |
|
93 | - $list = $list |
|
94 | - ->innerJoin('Product_StaticAttributeTypes', "\"SiteTree\".\"ID\" = \"Product_StaticAttributeTypes\".\"ProductID\"") |
|
95 | - ->innerJoin('ProductAttributeValue', "\"Product_StaticAttributeTypes\".\"ProductAttributeTypeID\" = \"ProductAttributeValue\".\"TypeID\"") |
|
96 | - ->innerJoin('Product_StaticAttributeValues', "\"SiteTree\".\"ID\" = \"Product_StaticAttributeValues\".\"ProductID\" AND \"ProductAttributeValue\".\"ID\" = \"Product_StaticAttributeValues\".\"ProductAttributeValueID\"") |
|
97 | - ->filter("Product_StaticAttributeValues.ProductAttributeValueID", $filterVal); |
|
98 | - } else { |
|
99 | - $list = $list->filter($this->processFilterField($sing, $filterField, $filterVal)); |
|
100 | - } |
|
101 | - } |
|
102 | - } |
|
103 | - |
|
104 | - return $list; |
|
105 | - } |
|
106 | - |
|
107 | - |
|
108 | - /** |
|
109 | - * @param DataObject $rec This would normally just be a singleton but we don't want to have to create it over and over |
|
110 | - * @param string $filterField |
|
111 | - * @param mixed $filterVal |
|
112 | - * @return array - returns the new filter added |
|
113 | - */ |
|
114 | - public function processFilterField($rec, $filterField, $filterVal) |
|
115 | - { |
|
116 | - // First check for VFI fields |
|
117 | - if ($rec->hasExtension('VirtualFieldIndex') && ($spec = $rec->getVFISpec($filterField))) { |
|
118 | - if ($spec['Type'] == VirtualFieldIndex::TYPE_LIST) { |
|
119 | - // Lists have to be handled a little differently |
|
120 | - $f = $rec->getVFIFieldName($filterField) . ':PartialMatch'; |
|
121 | - if (is_array($filterVal)) { |
|
122 | - foreach ($filterVal as &$val) { |
|
123 | - $val = '|' . $val . '|'; |
|
124 | - } |
|
125 | - return array($f => $filterVal); |
|
126 | - } else { |
|
127 | - return array($f => '|' . $filterVal . '|'); |
|
128 | - } |
|
129 | - } else { |
|
130 | - // Simples are simple |
|
131 | - $filterField = $rec->getVFIFieldName($filterField); |
|
132 | - } |
|
133 | - } |
|
134 | - |
|
135 | - // Next check for regular db fields |
|
136 | - if ($rec->dbObject($filterField)) { |
|
137 | - // Is it a range value? |
|
138 | - if (is_string($filterVal) && preg_match('/^RANGE\~(.+)\~(.+)$/', $filterVal, $m)) { |
|
139 | - $filterField .= ':Between'; |
|
140 | - $filterVal = array_slice($m, 1, 2); |
|
141 | - } |
|
142 | - |
|
143 | - return array($filterField => $filterVal); |
|
144 | - } |
|
145 | - |
|
146 | - return array(); |
|
147 | - } |
|
148 | - |
|
149 | - |
|
150 | - /** |
|
151 | - * Processes the facet spec and removes any shorthand (field => label). |
|
152 | - * @param array $facetSpec |
|
153 | - * @return array |
|
154 | - */ |
|
155 | - public function expandFacetSpec(array $facetSpec) |
|
156 | - { |
|
157 | - if (is_null($facetSpec)) { |
|
158 | - return array(); |
|
159 | - } |
|
160 | - $facets = array(); |
|
161 | - |
|
162 | - foreach ($facetSpec as $field => $label) { |
|
163 | - if (is_array($label)) { |
|
164 | - $facets[$field] = $label; |
|
165 | - } else { |
|
166 | - $facets[$field] = array('Label' => $label); |
|
167 | - } |
|
168 | - |
|
169 | - if (empty($facets[$field]['Source'])) { |
|
170 | - $facets[$field]['Source'] = $field; |
|
171 | - } |
|
172 | - if (empty($facets[$field]['Type'])) { |
|
173 | - $facets[$field]['Type'] = ShopSearch::FACET_TYPE_LINK; |
|
174 | - } |
|
175 | - |
|
176 | - if (empty($facets[$field]['Values'])) { |
|
177 | - $facets[$field]['Values'] = array(); |
|
178 | - } else { |
|
179 | - $vals = $facets[$field]['Values']; |
|
180 | - if (is_string($vals)) { |
|
181 | - $vals = eval('return ' . $vals . ';'); |
|
182 | - } |
|
183 | - $facets[$field]['Values'] = array(); |
|
184 | - foreach ($vals as $val => $lbl) { |
|
185 | - $facets[$field]['Values'][$val] = new ArrayData(array( |
|
186 | - 'Label' => $lbl, |
|
187 | - 'Value' => $val, |
|
188 | - 'Count' => 0, |
|
189 | - )); |
|
190 | - } |
|
191 | - } |
|
192 | - } |
|
193 | - |
|
194 | - return $facets; |
|
195 | - } |
|
196 | - |
|
197 | - |
|
198 | - /** |
|
199 | - * This is super-slow. I'm assuming if you're using facets you |
|
200 | - * probably also ought to be using Solr or something else. Or |
|
201 | - * maybe you have unlimited time and can refactor this feature |
|
202 | - * and submit a pull request... |
|
203 | - * |
|
204 | - * TODO: If this is going to be used for categories we're going |
|
205 | - * to have to really clean it up and speed it up. |
|
206 | - * Suggestion: |
|
207 | - * - option to turn off counts |
|
208 | - * - switch order of nested array so we don't go through results unless needed |
|
209 | - * - if not doing counts, min/max and link facets can be handled w/ queries |
|
210 | - * - separate that bit out into a new function |
|
211 | - * NOTE: This is partially done with the "faster_faceting" config |
|
212 | - * option but more could be done, particularly by covering link facets as well. |
|
213 | - * |
|
214 | - * Output - list of ArrayData in the format: |
|
215 | - * Label - name of the facet |
|
216 | - * Source - field name of the facet |
|
217 | - * Type - one of the ShopSearch::FACET_TYPE_XXXX constants |
|
218 | - * Values - SS_List of possible values for this facet |
|
219 | - * |
|
220 | - * @param SS_List $matches |
|
221 | - * @param array $facetSpec |
|
222 | - * @param bool $autoFacetAttributes [optional] |
|
223 | - * @return ArrayList |
|
224 | - */ |
|
225 | - public function buildFacets(SS_List $matches, array $facetSpec, $autoFacetAttributes=false) |
|
226 | - { |
|
227 | - $facets = $this->expandFacetSpec($facetSpec); |
|
228 | - if (!$autoFacetAttributes && (empty($facets) || !$matches || !$matches->count())) { |
|
229 | - return new ArrayList(); |
|
230 | - } |
|
231 | - $fasterMethod = (bool)$this->config()->faster_faceting; |
|
232 | - |
|
233 | - // fill them in |
|
234 | - foreach ($facets as $field => &$facet) { |
|
235 | - if (preg_match(self::config()->attribute_facet_regex, $field, $m)) { |
|
236 | - $this->buildAttributeFacet($matches, $facet, $m[1]); |
|
237 | - continue; |
|
238 | - } |
|
239 | - |
|
240 | - // NOTE: using this method range and checkbox facets don't get counts |
|
241 | - if ($fasterMethod && $facet['Type'] != ShopSearch::FACET_TYPE_LINK) { |
|
242 | - if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { |
|
243 | - if (isset($facet['RangeMin'])) { |
|
244 | - $facet['MinValue'] = $facet['RangeMin']; |
|
245 | - } |
|
246 | - if (isset($facet['RangeMax'])) { |
|
247 | - $facet['MaxValue'] = $facet['RangeMax']; |
|
248 | - } |
|
249 | - } |
|
250 | - |
|
251 | - continue; |
|
252 | - } |
|
253 | - |
|
254 | - foreach ($matches as $rec) { |
|
255 | - // If it's a range facet, set up the min/max |
|
256 | - if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { |
|
257 | - if (isset($facet['RangeMin'])) { |
|
258 | - $facet['MinValue'] = $facet['RangeMin']; |
|
259 | - } |
|
260 | - if (isset($facet['RangeMax'])) { |
|
261 | - $facet['MaxValue'] = $facet['RangeMax']; |
|
262 | - } |
|
263 | - } |
|
264 | - |
|
265 | - // If the field is accessible via normal methods, including |
|
266 | - // a user-defined getter, prefer that |
|
267 | - $fieldValue = $rec->relObject($field); |
|
268 | - if (is_null($fieldValue) && $rec->hasMethod($meth = "get{$field}")) { |
|
269 | - $fieldValue = $rec->$meth(); |
|
270 | - } |
|
271 | - |
|
272 | - // If not, look for a VFI field |
|
273 | - if (!$fieldValue && $rec->hasExtension('VirtualFieldIndex')) { |
|
274 | - $fieldValue = $rec->getVFI($field); |
|
275 | - } |
|
276 | - |
|
277 | - // If we found something, process it |
|
278 | - if (!empty($fieldValue)) { |
|
279 | - // normalize so that it's iterable |
|
280 | - if (!is_array($fieldValue) && !$fieldValue instanceof SS_List) { |
|
281 | - $fieldValue = array($fieldValue); |
|
282 | - } |
|
283 | - |
|
284 | - foreach ($fieldValue as $obj) { |
|
285 | - if (empty($obj)) { |
|
286 | - continue; |
|
287 | - } |
|
288 | - |
|
289 | - // figure out the right label |
|
290 | - if (is_object($obj) && $obj->hasMethod('Nice')) { |
|
291 | - $lbl = $obj->Nice(); |
|
292 | - } elseif (is_object($obj) && !empty($obj->Title)) { |
|
293 | - $lbl = $obj->Title; |
|
294 | - } elseif ( |
|
295 | - is_numeric($obj) && |
|
296 | - !empty($facet['LabelFormat']) && |
|
297 | - $facet['LabelFormat'] === 'Currency' && |
|
298 | - $facet['Type'] !== ShopSearch::FACET_TYPE_RANGE // this one handles it via javascript |
|
299 | - ) { |
|
300 | - $tmp = Currency::create($field); |
|
301 | - $tmp->setValue($obj); |
|
302 | - $lbl = $tmp->Nice(); |
|
303 | - } else { |
|
304 | - $lbl = (string)$obj; |
|
305 | - } |
|
306 | - |
|
307 | - // figure out the value for sorting |
|
308 | - if (is_object($obj) && $obj->hasMethod('getAmount')) { |
|
309 | - $val = $obj->getAmount(); |
|
310 | - } elseif (is_object($obj) && !empty($obj->ID)) { |
|
311 | - $val = $obj->ID; |
|
312 | - } else { |
|
313 | - $val = (string)$obj; |
|
314 | - } |
|
315 | - |
|
316 | - // if it's a range facet, calculate the min and max |
|
317 | - if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { |
|
318 | - if (!isset($facet['MinValue']) || $val < $facet['MinValue']) { |
|
319 | - $facet['MinValue'] = $val; |
|
320 | - $facet['MinLabel'] = $lbl; |
|
321 | - } |
|
322 | - if (!isset($facet['RangeMin']) || $val < $facet['RangeMin']) { |
|
323 | - $facet['RangeMin'] = $val; |
|
324 | - } |
|
325 | - if (!isset($facet['MaxValue']) || $val > $facet['MaxValue']) { |
|
326 | - $facet['MaxValue'] = $val; |
|
327 | - $facet['MaxLabel'] = $lbl; |
|
328 | - } |
|
329 | - if (!isset($facet['RangeMax']) || $val > $facet['RangeMax']) { |
|
330 | - $facet['RangeMax'] = $val; |
|
331 | - } |
|
332 | - } |
|
333 | - |
|
334 | - // Tally the value in the facets |
|
335 | - if (!isset($facet['Values'][$val])) { |
|
336 | - $facet['Values'][$val] = new ArrayData(array( |
|
337 | - 'Label' => $lbl, |
|
338 | - 'Value' => $val, |
|
339 | - 'Count' => 1, |
|
340 | - )); |
|
341 | - } elseif ($facet['Values'][$val]) { |
|
342 | - $facet['Values'][$val]->Count++; |
|
343 | - } |
|
344 | - } |
|
345 | - } |
|
346 | - } |
|
347 | - } |
|
348 | - |
|
349 | - // if we're auto-building the facets based on attributes, |
|
350 | - if ($autoFacetAttributes) { |
|
351 | - $facets = array_merge($this->buildAllAttributeFacets($matches), $facets); |
|
352 | - } |
|
353 | - |
|
354 | - // convert values to arraylist |
|
355 | - $out = new ArrayList(); |
|
356 | - $sortValues = self::config()->sort_facet_values; |
|
357 | - foreach ($facets as $f) { |
|
358 | - if ($sortValues) { |
|
359 | - ksort($f['Values']); |
|
360 | - } |
|
361 | - $f['Values'] = new ArrayList($f['Values']); |
|
362 | - $out->push(new ArrayData($f)); |
|
363 | - } |
|
364 | - |
|
365 | - return $out; |
|
366 | - } |
|
367 | - |
|
368 | - |
|
369 | - /** |
|
370 | - * NOTE: this will break if applied to something that's not a SiteTree subclass. |
|
371 | - * @param DataList|PaginatedList $matches |
|
372 | - * @param array $facet |
|
373 | - * @param int $typeID |
|
374 | - */ |
|
375 | - protected function buildAttributeFacet($matches, array &$facet, $typeID) |
|
376 | - { |
|
377 | - $q = $matches instanceof PaginatedList ? $matches->getList()->dataQuery()->query() : $matches->dataQuery()->query(); |
|
378 | - |
|
379 | - if (empty($facet['Label'])) { |
|
380 | - $type = ProductAttributeType::get()->byID($typeID); |
|
381 | - $facet['Label'] = $type->Label; |
|
382 | - } |
|
383 | - |
|
384 | - $baseTable = $q->getFrom(); |
|
385 | - if (is_array($baseTable)) { |
|
386 | - $baseTable = reset($baseTable); |
|
387 | - } |
|
388 | - |
|
389 | - $q = $q->setSelect(array()) |
|
390 | - ->selectField('"ProductAttributeValue"."ID"', 'Value') |
|
391 | - ->selectField('"ProductAttributeValue"."Value"', 'Label') |
|
392 | - ->selectField('count(distinct '.$baseTable.'."ID")', 'Count') |
|
393 | - ->selectField('"ProductAttributeValue"."Sort"') |
|
394 | - ->addInnerJoin('Product_StaticAttributeValues', $baseTable.'."ID" = "Product_StaticAttributeValues"."ProductID"') |
|
395 | - ->addInnerJoin('ProductAttributeValue', '"Product_StaticAttributeValues"."ProductAttributeValueID" = "ProductAttributeValue"."ID"') |
|
396 | - ->addWhere(sprintf("\"ProductAttributeValue\".\"TypeID\" = '%d'", $typeID)) |
|
397 | - ->setOrderBy('"ProductAttributeValue"."Sort"', 'ASC') |
|
398 | - ->setGroupBy('"ProductAttributeValue"."ID"') |
|
399 | - ->execute() |
|
400 | - ; |
|
401 | - |
|
402 | - $facet['Values'] = array(); |
|
403 | - foreach ($q as $row) { |
|
404 | - $facet['Values'][ $row['Value'] ] = new ArrayData($row); |
|
405 | - } |
|
406 | - } |
|
407 | - |
|
408 | - |
|
409 | - /** |
|
410 | - * Builds facets from all attributes present in the data set. |
|
411 | - * @param DataList|PaginatedList $matches |
|
412 | - * @return array |
|
413 | - */ |
|
414 | - protected function buildAllAttributeFacets($matches) |
|
415 | - { |
|
416 | - $q = $matches instanceof PaginatedList ? $matches->getList()->dataQuery()->query() : $matches->dataQuery()->query(); |
|
417 | - |
|
418 | - // this is the easiest way to get SiteTree vs SiteTree_Live |
|
419 | - $baseTable = $q->getFrom(); |
|
420 | - if (is_array($baseTable)) { |
|
421 | - $baseTable = reset($baseTable); |
|
422 | - } |
|
423 | - |
|
424 | - $q = $q->setSelect(array()) |
|
425 | - ->selectField('"ProductAttributeType"."ID"', 'TypeID') |
|
426 | - ->selectField('"ProductAttributeType"."Label"', 'TypeLabel') |
|
427 | - ->selectField('"ProductAttributeValue"."ID"', 'Value') |
|
428 | - ->selectField('"ProductAttributeValue"."Value"', 'Label') |
|
429 | - ->selectField('count(distinct '.$baseTable.'."ID")', 'Count') |
|
430 | - ->selectField('"ProductAttributeValue"."Sort"') |
|
431 | - ->addInnerJoin('Product_StaticAttributeTypes', $baseTable.'."ID" = "Product_StaticAttributeTypes"."ProductID"') |
|
432 | - ->addInnerJoin('ProductAttributeType', '"Product_StaticAttributeTypes"."ProductAttributeTypeID" = "ProductAttributeType"."ID"') |
|
433 | - ->addInnerJoin('Product_StaticAttributeValues', $baseTable.'."ID" = "Product_StaticAttributeValues"."ProductID"') |
|
434 | - ->addInnerJoin('ProductAttributeValue', '"Product_StaticAttributeValues"."ProductAttributeValueID" = "ProductAttributeValue"."ID"' |
|
435 | - . ' AND "ProductAttributeValue"."TypeID" = "ProductAttributeType"."ID"') |
|
436 | - ->setOrderBy(array( |
|
437 | - '"ProductAttributeType"."Label"' => 'ASC', |
|
438 | - '"ProductAttributeValue"."Sort"' => 'ASC', |
|
439 | - )) |
|
440 | - ->setGroupBy(array('"ProductAttributeValue"."ID"', '"ProductAttributeType"."ID"')) |
|
441 | - ->execute() |
|
442 | - ; |
|
443 | - |
|
444 | - |
|
445 | - $curType = 0; |
|
446 | - $facets = array(); |
|
447 | - $curFacet = null; |
|
448 | - foreach ($q as $row) { |
|
449 | - if ($curType != $row['TypeID']) { |
|
450 | - if ($curType > 0) { |
|
451 | - $facets['ATT'.$curType] = $curFacet; |
|
452 | - } |
|
453 | - $curType = $row['TypeID']; |
|
454 | - $curFacet = array( |
|
455 | - 'Label' => $row['TypeLabel'], |
|
456 | - 'Source' => 'ATT'.$curType, |
|
457 | - 'Type' => ShopSearch::FACET_TYPE_LINK, |
|
458 | - 'Values' => array(), |
|
459 | - ); |
|
460 | - } |
|
461 | - |
|
462 | - unset($row['TypeID']); |
|
463 | - unset($row['TypeLabel']); |
|
464 | - $curFacet['Values'][ $row['Value'] ] = new ArrayData($row); |
|
465 | - } |
|
466 | - |
|
467 | - if ($curType > 0) { |
|
468 | - $facets['ATT'.$curType] = $curFacet; |
|
469 | - } |
|
470 | - return $facets; |
|
471 | - } |
|
472 | - |
|
473 | - |
|
474 | - /** |
|
475 | - * Inserts a "Link" field into the values for each facet which can be |
|
476 | - * used to get a filtered search based on that facets |
|
477 | - * |
|
478 | - * @param ArrayList $facets |
|
479 | - * @param array $baseParams |
|
480 | - * @param string $baseLink |
|
481 | - * @return ArrayList |
|
482 | - */ |
|
483 | - public function insertFacetLinks(ArrayList $facets, array $baseParams, $baseLink) |
|
484 | - { |
|
485 | - $qs_f = Config::inst()->get('ShopSearch', 'qs_filters'); |
|
486 | - $qs_t = Config::inst()->get('ShopSearch', 'qs_title'); |
|
487 | - |
|
488 | - foreach ($facets as $facet) { |
|
489 | - switch ($facet->Type) { |
|
490 | - case ShopSearch::FACET_TYPE_RANGE: |
|
491 | - $params = array_merge($baseParams, array()); |
|
492 | - if (!isset($params[$qs_f])) { |
|
493 | - $params[$qs_f] = array(); |
|
494 | - } |
|
495 | - $params[$qs_f][$facet->Source] = 'RANGEFACETVALUE'; |
|
496 | - $params[$qs_t] = $facet->Label . ': RANGEFACETLABEL'; |
|
497 | - $facet->Link = $baseLink . '?' . http_build_query($params); |
|
498 | - break; |
|
499 | - |
|
500 | - case ShopSearch::FACET_TYPE_CHECKBOX; |
|
501 | - $facet->LinkDetails = json_encode(array( |
|
502 | - 'filter' => $qs_f, |
|
503 | - 'source' => $facet->Source, |
|
504 | - 'leaves' => $facet->FilterOnlyLeaves, |
|
505 | - )); |
|
506 | - |
|
507 | - // fall through on purpose |
|
508 | - |
|
509 | - default: |
|
510 | - foreach ($facet->Values as $value) { |
|
511 | - // make a copy of the existing params |
|
512 | - $params = array_merge($baseParams, array()); |
|
513 | - |
|
514 | - // add the filter for this value |
|
515 | - if (!isset($params[$qs_f])) { |
|
516 | - $params[$qs_f] = array(); |
|
517 | - } |
|
518 | - if ($facet->Type == ShopSearch::FACET_TYPE_CHECKBOX) { |
|
519 | - unset($params[$qs_f][$facet->Source]); // this will be figured out via javascript |
|
520 | - $params[$qs_t] = ($value->Active ? 'Remove ' : '') . $facet->Label . ': ' . $value->Label; |
|
521 | - } else { |
|
522 | - $params[$qs_f][$facet->Source] = $value->Value; |
|
523 | - $params[$qs_t] = $facet->Label . ': ' . $value->Label; |
|
524 | - } |
|
525 | - |
|
526 | - // build a new link |
|
527 | - $value->Link = $baseLink . '?' . http_build_query($params); |
|
528 | - } |
|
529 | - } |
|
530 | - } |
|
531 | - |
|
532 | - return $facets; |
|
533 | - } |
|
534 | - |
|
535 | - |
|
536 | - /** |
|
537 | - * @param ArrayList $children |
|
538 | - * @return array |
|
539 | - */ |
|
540 | - protected function getRecursiveChildValues(ArrayList $children) |
|
541 | - { |
|
542 | - $out = array(); |
|
543 | - |
|
544 | - foreach ($children as $child) { |
|
545 | - $out[$child->Value] = $child->Value; |
|
546 | - if (!empty($child->Children)) { |
|
547 | - $out += $this->getRecursiveChildValues($child->Children); |
|
548 | - } |
|
549 | - } |
|
550 | - |
|
551 | - return $out; |
|
552 | - } |
|
553 | - |
|
554 | - |
|
555 | - /** |
|
556 | - * For checkbox and range facets, this updates the state (checked and min/max) |
|
557 | - * based on current filter values. |
|
558 | - * |
|
559 | - * @param ArrayList $facets |
|
560 | - * @param array $filters |
|
561 | - * @return ArrayList |
|
562 | - */ |
|
563 | - public function updateFacetState(ArrayList $facets, array $filters) |
|
564 | - { |
|
565 | - foreach ($facets as $facet) { |
|
566 | - if ($facet->Type == ShopSearch::FACET_TYPE_CHECKBOX) { |
|
567 | - if (empty($filters[$facet->Source])) { |
|
568 | - // If the filter is not being used at all, we count |
|
569 | - // all values as active. |
|
570 | - foreach ($facet->Values as $value) { |
|
571 | - $value->Active = (bool)FacetHelper::config()->default_checkbox_state; |
|
572 | - } |
|
573 | - } else { |
|
574 | - $filterVals = $filters[$facet->Source]; |
|
575 | - if (!is_array($filterVals)) { |
|
576 | - $filterVals = array($filterVals); |
|
577 | - } |
|
578 | - $this->updateCheckboxFacetState( |
|
579 | - !empty($facet->NestedValues) ? $facet->NestedValues : $facet->Values, |
|
580 | - $filterVals, |
|
581 | - !empty($facet->FilterOnlyLeaves)); |
|
582 | - } |
|
583 | - } elseif ($facet->Type == ShopSearch::FACET_TYPE_RANGE) { |
|
584 | - if (!empty($filters[$facet->Source]) && preg_match('/^RANGE\~(.+)\~(.+)$/', $filters[$facet->Source], $m)) { |
|
585 | - $facet->MinValue = $m[1]; |
|
586 | - $facet->MaxValue = $m[2]; |
|
587 | - } |
|
588 | - } |
|
589 | - } |
|
590 | - |
|
591 | - return $facets; |
|
592 | - } |
|
593 | - |
|
594 | - |
|
595 | - /** |
|
596 | - * For checkboxes, updates the state based on filters. Handles hierarchies and FilterOnlyLeaves |
|
597 | - * @param ArrayList $values |
|
598 | - * @param array $filterVals |
|
599 | - * @param bool $filterOnlyLeaves [optional] |
|
600 | - * @return bool - true if any of the children are true, false if all children are false |
|
601 | - */ |
|
602 | - protected function updateCheckboxFacetState(ArrayList $values, array $filterVals, $filterOnlyLeaves=false) |
|
603 | - { |
|
604 | - $out = false; |
|
605 | - |
|
606 | - foreach ($values as $value) { |
|
607 | - if ($filterOnlyLeaves && !empty($value->Children)) { |
|
608 | - if (in_array($value->Value, $filterVals)) { |
|
609 | - // This wouldn't be normal, but even if it's not a leaf, we want to handle |
|
610 | - // the case where a filter might be set for this node. It should still show up correctly. |
|
611 | - $value->Active = true; |
|
612 | - foreach ($value->Children as $c) { |
|
613 | - $c->Active = true; |
|
614 | - } |
|
615 | - // TODO: handle more than one level of recursion here |
|
616 | - } else { |
|
617 | - $value->Active = $this->updateCheckboxFacetState($value->Children, $filterVals, $filterOnlyLeaves); |
|
618 | - } |
|
619 | - } else { |
|
620 | - $value->Active = in_array($value->Value, $filterVals); |
|
621 | - } |
|
622 | - |
|
623 | - if ($value->Active) { |
|
624 | - $out = true; |
|
625 | - } |
|
626 | - } |
|
627 | - |
|
628 | - return $out; |
|
629 | - } |
|
630 | - |
|
631 | - |
|
632 | - /** |
|
633 | - * If there are any facets (link or checkbox) that have a HierarchyDivider field |
|
634 | - * in the spec, transform them into a hierarchy so they can be displayed as such. |
|
635 | - * |
|
636 | - * @param ArrayList $facets |
|
637 | - * @return ArrayList |
|
638 | - */ |
|
639 | - public function transformHierarchies(ArrayList $facets) |
|
640 | - { |
|
641 | - foreach ($facets as $facet) { |
|
642 | - if (!empty($facet->HierarchyDivider)) { |
|
643 | - $out = new ArrayList(); |
|
644 | - $parentStack = array(); |
|
645 | - |
|
646 | - foreach ($facet->Values as $value) { |
|
647 | - if (empty($value->Label)) { |
|
648 | - continue; |
|
649 | - } |
|
650 | - $value->FullLabel = $value->Label; |
|
651 | - |
|
652 | - // Look for the most recent parent that matches the beginning of this one |
|
653 | - while (count($parentStack) > 0) { |
|
654 | - $curParent = $parentStack[ count($parentStack)-1 ]; |
|
655 | - if (strpos($value->Label, $curParent->FullLabel) === 0) { |
|
656 | - if (!isset($curParent->Children)) { |
|
657 | - $curParent->Children = new ArrayList(); |
|
658 | - } |
|
659 | - |
|
660 | - // Modify the name so we only show the last component |
|
661 | - $value->FullLabel = $value->Label; |
|
662 | - $p = strrpos($value->Label, $facet->HierarchyDivider); |
|
663 | - if ($p > -1) { |
|
664 | - $value->Label = trim(substr($value->Label, $p + 1)); |
|
665 | - } |
|
666 | - |
|
667 | - $curParent->Children->push($value); |
|
668 | - break; |
|
669 | - } else { |
|
670 | - array_pop($parentStack); |
|
671 | - } |
|
672 | - } |
|
673 | - |
|
674 | - // If we went all the way back to the root without a match, this is |
|
675 | - // a new parent item |
|
676 | - if (count($parentStack) == 0) { |
|
677 | - $out->push($value); |
|
678 | - } |
|
679 | - |
|
680 | - // Each item could be a potential parent. If it's not it will get popped |
|
681 | - // immediately on the next iteration |
|
682 | - $parentStack[] = $value; |
|
683 | - } |
|
684 | - |
|
685 | - $facet->NestedValues = $out; |
|
686 | - } |
|
687 | - } |
|
688 | - |
|
689 | - return $facets; |
|
690 | - } |
|
88 | + // TODO: This logic should be something like the above, but I don't know |
|
89 | + // how to get the join table from a singleton (which returns an UnsavedRelationList |
|
90 | + // instead of a ManyManyList). I've got a deadline to meet, though, so this |
|
91 | + // will catch the majority of cases as long as the extension is applied to the |
|
92 | + // Product class instead of a subclass. |
|
93 | + $list = $list |
|
94 | + ->innerJoin('Product_StaticAttributeTypes', "\"SiteTree\".\"ID\" = \"Product_StaticAttributeTypes\".\"ProductID\"") |
|
95 | + ->innerJoin('ProductAttributeValue', "\"Product_StaticAttributeTypes\".\"ProductAttributeTypeID\" = \"ProductAttributeValue\".\"TypeID\"") |
|
96 | + ->innerJoin('Product_StaticAttributeValues', "\"SiteTree\".\"ID\" = \"Product_StaticAttributeValues\".\"ProductID\" AND \"ProductAttributeValue\".\"ID\" = \"Product_StaticAttributeValues\".\"ProductAttributeValueID\"") |
|
97 | + ->filter("Product_StaticAttributeValues.ProductAttributeValueID", $filterVal); |
|
98 | + } else { |
|
99 | + $list = $list->filter($this->processFilterField($sing, $filterField, $filterVal)); |
|
100 | + } |
|
101 | + } |
|
102 | + } |
|
103 | + |
|
104 | + return $list; |
|
105 | + } |
|
106 | + |
|
107 | + |
|
108 | + /** |
|
109 | + * @param DataObject $rec This would normally just be a singleton but we don't want to have to create it over and over |
|
110 | + * @param string $filterField |
|
111 | + * @param mixed $filterVal |
|
112 | + * @return array - returns the new filter added |
|
113 | + */ |
|
114 | + public function processFilterField($rec, $filterField, $filterVal) |
|
115 | + { |
|
116 | + // First check for VFI fields |
|
117 | + if ($rec->hasExtension('VirtualFieldIndex') && ($spec = $rec->getVFISpec($filterField))) { |
|
118 | + if ($spec['Type'] == VirtualFieldIndex::TYPE_LIST) { |
|
119 | + // Lists have to be handled a little differently |
|
120 | + $f = $rec->getVFIFieldName($filterField) . ':PartialMatch'; |
|
121 | + if (is_array($filterVal)) { |
|
122 | + foreach ($filterVal as &$val) { |
|
123 | + $val = '|' . $val . '|'; |
|
124 | + } |
|
125 | + return array($f => $filterVal); |
|
126 | + } else { |
|
127 | + return array($f => '|' . $filterVal . '|'); |
|
128 | + } |
|
129 | + } else { |
|
130 | + // Simples are simple |
|
131 | + $filterField = $rec->getVFIFieldName($filterField); |
|
132 | + } |
|
133 | + } |
|
134 | + |
|
135 | + // Next check for regular db fields |
|
136 | + if ($rec->dbObject($filterField)) { |
|
137 | + // Is it a range value? |
|
138 | + if (is_string($filterVal) && preg_match('/^RANGE\~(.+)\~(.+)$/', $filterVal, $m)) { |
|
139 | + $filterField .= ':Between'; |
|
140 | + $filterVal = array_slice($m, 1, 2); |
|
141 | + } |
|
142 | + |
|
143 | + return array($filterField => $filterVal); |
|
144 | + } |
|
145 | + |
|
146 | + return array(); |
|
147 | + } |
|
148 | + |
|
149 | + |
|
150 | + /** |
|
151 | + * Processes the facet spec and removes any shorthand (field => label). |
|
152 | + * @param array $facetSpec |
|
153 | + * @return array |
|
154 | + */ |
|
155 | + public function expandFacetSpec(array $facetSpec) |
|
156 | + { |
|
157 | + if (is_null($facetSpec)) { |
|
158 | + return array(); |
|
159 | + } |
|
160 | + $facets = array(); |
|
161 | + |
|
162 | + foreach ($facetSpec as $field => $label) { |
|
163 | + if (is_array($label)) { |
|
164 | + $facets[$field] = $label; |
|
165 | + } else { |
|
166 | + $facets[$field] = array('Label' => $label); |
|
167 | + } |
|
168 | + |
|
169 | + if (empty($facets[$field]['Source'])) { |
|
170 | + $facets[$field]['Source'] = $field; |
|
171 | + } |
|
172 | + if (empty($facets[$field]['Type'])) { |
|
173 | + $facets[$field]['Type'] = ShopSearch::FACET_TYPE_LINK; |
|
174 | + } |
|
175 | + |
|
176 | + if (empty($facets[$field]['Values'])) { |
|
177 | + $facets[$field]['Values'] = array(); |
|
178 | + } else { |
|
179 | + $vals = $facets[$field]['Values']; |
|
180 | + if (is_string($vals)) { |
|
181 | + $vals = eval('return ' . $vals . ';'); |
|
182 | + } |
|
183 | + $facets[$field]['Values'] = array(); |
|
184 | + foreach ($vals as $val => $lbl) { |
|
185 | + $facets[$field]['Values'][$val] = new ArrayData(array( |
|
186 | + 'Label' => $lbl, |
|
187 | + 'Value' => $val, |
|
188 | + 'Count' => 0, |
|
189 | + )); |
|
190 | + } |
|
191 | + } |
|
192 | + } |
|
193 | + |
|
194 | + return $facets; |
|
195 | + } |
|
196 | + |
|
197 | + |
|
198 | + /** |
|
199 | + * This is super-slow. I'm assuming if you're using facets you |
|
200 | + * probably also ought to be using Solr or something else. Or |
|
201 | + * maybe you have unlimited time and can refactor this feature |
|
202 | + * and submit a pull request... |
|
203 | + * |
|
204 | + * TODO: If this is going to be used for categories we're going |
|
205 | + * to have to really clean it up and speed it up. |
|
206 | + * Suggestion: |
|
207 | + * - option to turn off counts |
|
208 | + * - switch order of nested array so we don't go through results unless needed |
|
209 | + * - if not doing counts, min/max and link facets can be handled w/ queries |
|
210 | + * - separate that bit out into a new function |
|
211 | + * NOTE: This is partially done with the "faster_faceting" config |
|
212 | + * option but more could be done, particularly by covering link facets as well. |
|
213 | + * |
|
214 | + * Output - list of ArrayData in the format: |
|
215 | + * Label - name of the facet |
|
216 | + * Source - field name of the facet |
|
217 | + * Type - one of the ShopSearch::FACET_TYPE_XXXX constants |
|
218 | + * Values - SS_List of possible values for this facet |
|
219 | + * |
|
220 | + * @param SS_List $matches |
|
221 | + * @param array $facetSpec |
|
222 | + * @param bool $autoFacetAttributes [optional] |
|
223 | + * @return ArrayList |
|
224 | + */ |
|
225 | + public function buildFacets(SS_List $matches, array $facetSpec, $autoFacetAttributes=false) |
|
226 | + { |
|
227 | + $facets = $this->expandFacetSpec($facetSpec); |
|
228 | + if (!$autoFacetAttributes && (empty($facets) || !$matches || !$matches->count())) { |
|
229 | + return new ArrayList(); |
|
230 | + } |
|
231 | + $fasterMethod = (bool)$this->config()->faster_faceting; |
|
232 | + |
|
233 | + // fill them in |
|
234 | + foreach ($facets as $field => &$facet) { |
|
235 | + if (preg_match(self::config()->attribute_facet_regex, $field, $m)) { |
|
236 | + $this->buildAttributeFacet($matches, $facet, $m[1]); |
|
237 | + continue; |
|
238 | + } |
|
239 | + |
|
240 | + // NOTE: using this method range and checkbox facets don't get counts |
|
241 | + if ($fasterMethod && $facet['Type'] != ShopSearch::FACET_TYPE_LINK) { |
|
242 | + if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { |
|
243 | + if (isset($facet['RangeMin'])) { |
|
244 | + $facet['MinValue'] = $facet['RangeMin']; |
|
245 | + } |
|
246 | + if (isset($facet['RangeMax'])) { |
|
247 | + $facet['MaxValue'] = $facet['RangeMax']; |
|
248 | + } |
|
249 | + } |
|
250 | + |
|
251 | + continue; |
|
252 | + } |
|
253 | + |
|
254 | + foreach ($matches as $rec) { |
|
255 | + // If it's a range facet, set up the min/max |
|
256 | + if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { |
|
257 | + if (isset($facet['RangeMin'])) { |
|
258 | + $facet['MinValue'] = $facet['RangeMin']; |
|
259 | + } |
|
260 | + if (isset($facet['RangeMax'])) { |
|
261 | + $facet['MaxValue'] = $facet['RangeMax']; |
|
262 | + } |
|
263 | + } |
|
264 | + |
|
265 | + // If the field is accessible via normal methods, including |
|
266 | + // a user-defined getter, prefer that |
|
267 | + $fieldValue = $rec->relObject($field); |
|
268 | + if (is_null($fieldValue) && $rec->hasMethod($meth = "get{$field}")) { |
|
269 | + $fieldValue = $rec->$meth(); |
|
270 | + } |
|
271 | + |
|
272 | + // If not, look for a VFI field |
|
273 | + if (!$fieldValue && $rec->hasExtension('VirtualFieldIndex')) { |
|
274 | + $fieldValue = $rec->getVFI($field); |
|
275 | + } |
|
276 | + |
|
277 | + // If we found something, process it |
|
278 | + if (!empty($fieldValue)) { |
|
279 | + // normalize so that it's iterable |
|
280 | + if (!is_array($fieldValue) && !$fieldValue instanceof SS_List) { |
|
281 | + $fieldValue = array($fieldValue); |
|
282 | + } |
|
283 | + |
|
284 | + foreach ($fieldValue as $obj) { |
|
285 | + if (empty($obj)) { |
|
286 | + continue; |
|
287 | + } |
|
288 | + |
|
289 | + // figure out the right label |
|
290 | + if (is_object($obj) && $obj->hasMethod('Nice')) { |
|
291 | + $lbl = $obj->Nice(); |
|
292 | + } elseif (is_object($obj) && !empty($obj->Title)) { |
|
293 | + $lbl = $obj->Title; |
|
294 | + } elseif ( |
|
295 | + is_numeric($obj) && |
|
296 | + !empty($facet['LabelFormat']) && |
|
297 | + $facet['LabelFormat'] === 'Currency' && |
|
298 | + $facet['Type'] !== ShopSearch::FACET_TYPE_RANGE // this one handles it via javascript |
|
299 | + ) { |
|
300 | + $tmp = Currency::create($field); |
|
301 | + $tmp->setValue($obj); |
|
302 | + $lbl = $tmp->Nice(); |
|
303 | + } else { |
|
304 | + $lbl = (string)$obj; |
|
305 | + } |
|
306 | + |
|
307 | + // figure out the value for sorting |
|
308 | + if (is_object($obj) && $obj->hasMethod('getAmount')) { |
|
309 | + $val = $obj->getAmount(); |
|
310 | + } elseif (is_object($obj) && !empty($obj->ID)) { |
|
311 | + $val = $obj->ID; |
|
312 | + } else { |
|
313 | + $val = (string)$obj; |
|
314 | + } |
|
315 | + |
|
316 | + // if it's a range facet, calculate the min and max |
|
317 | + if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { |
|
318 | + if (!isset($facet['MinValue']) || $val < $facet['MinValue']) { |
|
319 | + $facet['MinValue'] = $val; |
|
320 | + $facet['MinLabel'] = $lbl; |
|
321 | + } |
|
322 | + if (!isset($facet['RangeMin']) || $val < $facet['RangeMin']) { |
|
323 | + $facet['RangeMin'] = $val; |
|
324 | + } |
|
325 | + if (!isset($facet['MaxValue']) || $val > $facet['MaxValue']) { |
|
326 | + $facet['MaxValue'] = $val; |
|
327 | + $facet['MaxLabel'] = $lbl; |
|
328 | + } |
|
329 | + if (!isset($facet['RangeMax']) || $val > $facet['RangeMax']) { |
|
330 | + $facet['RangeMax'] = $val; |
|
331 | + } |
|
332 | + } |
|
333 | + |
|
334 | + // Tally the value in the facets |
|
335 | + if (!isset($facet['Values'][$val])) { |
|
336 | + $facet['Values'][$val] = new ArrayData(array( |
|
337 | + 'Label' => $lbl, |
|
338 | + 'Value' => $val, |
|
339 | + 'Count' => 1, |
|
340 | + )); |
|
341 | + } elseif ($facet['Values'][$val]) { |
|
342 | + $facet['Values'][$val]->Count++; |
|
343 | + } |
|
344 | + } |
|
345 | + } |
|
346 | + } |
|
347 | + } |
|
348 | + |
|
349 | + // if we're auto-building the facets based on attributes, |
|
350 | + if ($autoFacetAttributes) { |
|
351 | + $facets = array_merge($this->buildAllAttributeFacets($matches), $facets); |
|
352 | + } |
|
353 | + |
|
354 | + // convert values to arraylist |
|
355 | + $out = new ArrayList(); |
|
356 | + $sortValues = self::config()->sort_facet_values; |
|
357 | + foreach ($facets as $f) { |
|
358 | + if ($sortValues) { |
|
359 | + ksort($f['Values']); |
|
360 | + } |
|
361 | + $f['Values'] = new ArrayList($f['Values']); |
|
362 | + $out->push(new ArrayData($f)); |
|
363 | + } |
|
364 | + |
|
365 | + return $out; |
|
366 | + } |
|
367 | + |
|
368 | + |
|
369 | + /** |
|
370 | + * NOTE: this will break if applied to something that's not a SiteTree subclass. |
|
371 | + * @param DataList|PaginatedList $matches |
|
372 | + * @param array $facet |
|
373 | + * @param int $typeID |
|
374 | + */ |
|
375 | + protected function buildAttributeFacet($matches, array &$facet, $typeID) |
|
376 | + { |
|
377 | + $q = $matches instanceof PaginatedList ? $matches->getList()->dataQuery()->query() : $matches->dataQuery()->query(); |
|
378 | + |
|
379 | + if (empty($facet['Label'])) { |
|
380 | + $type = ProductAttributeType::get()->byID($typeID); |
|
381 | + $facet['Label'] = $type->Label; |
|
382 | + } |
|
383 | + |
|
384 | + $baseTable = $q->getFrom(); |
|
385 | + if (is_array($baseTable)) { |
|
386 | + $baseTable = reset($baseTable); |
|
387 | + } |
|
388 | + |
|
389 | + $q = $q->setSelect(array()) |
|
390 | + ->selectField('"ProductAttributeValue"."ID"', 'Value') |
|
391 | + ->selectField('"ProductAttributeValue"."Value"', 'Label') |
|
392 | + ->selectField('count(distinct '.$baseTable.'."ID")', 'Count') |
|
393 | + ->selectField('"ProductAttributeValue"."Sort"') |
|
394 | + ->addInnerJoin('Product_StaticAttributeValues', $baseTable.'."ID" = "Product_StaticAttributeValues"."ProductID"') |
|
395 | + ->addInnerJoin('ProductAttributeValue', '"Product_StaticAttributeValues"."ProductAttributeValueID" = "ProductAttributeValue"."ID"') |
|
396 | + ->addWhere(sprintf("\"ProductAttributeValue\".\"TypeID\" = '%d'", $typeID)) |
|
397 | + ->setOrderBy('"ProductAttributeValue"."Sort"', 'ASC') |
|
398 | + ->setGroupBy('"ProductAttributeValue"."ID"') |
|
399 | + ->execute() |
|
400 | + ; |
|
401 | + |
|
402 | + $facet['Values'] = array(); |
|
403 | + foreach ($q as $row) { |
|
404 | + $facet['Values'][ $row['Value'] ] = new ArrayData($row); |
|
405 | + } |
|
406 | + } |
|
407 | + |
|
408 | + |
|
409 | + /** |
|
410 | + * Builds facets from all attributes present in the data set. |
|
411 | + * @param DataList|PaginatedList $matches |
|
412 | + * @return array |
|
413 | + */ |
|
414 | + protected function buildAllAttributeFacets($matches) |
|
415 | + { |
|
416 | + $q = $matches instanceof PaginatedList ? $matches->getList()->dataQuery()->query() : $matches->dataQuery()->query(); |
|
417 | + |
|
418 | + // this is the easiest way to get SiteTree vs SiteTree_Live |
|
419 | + $baseTable = $q->getFrom(); |
|
420 | + if (is_array($baseTable)) { |
|
421 | + $baseTable = reset($baseTable); |
|
422 | + } |
|
423 | + |
|
424 | + $q = $q->setSelect(array()) |
|
425 | + ->selectField('"ProductAttributeType"."ID"', 'TypeID') |
|
426 | + ->selectField('"ProductAttributeType"."Label"', 'TypeLabel') |
|
427 | + ->selectField('"ProductAttributeValue"."ID"', 'Value') |
|
428 | + ->selectField('"ProductAttributeValue"."Value"', 'Label') |
|
429 | + ->selectField('count(distinct '.$baseTable.'."ID")', 'Count') |
|
430 | + ->selectField('"ProductAttributeValue"."Sort"') |
|
431 | + ->addInnerJoin('Product_StaticAttributeTypes', $baseTable.'."ID" = "Product_StaticAttributeTypes"."ProductID"') |
|
432 | + ->addInnerJoin('ProductAttributeType', '"Product_StaticAttributeTypes"."ProductAttributeTypeID" = "ProductAttributeType"."ID"') |
|
433 | + ->addInnerJoin('Product_StaticAttributeValues', $baseTable.'."ID" = "Product_StaticAttributeValues"."ProductID"') |
|
434 | + ->addInnerJoin('ProductAttributeValue', '"Product_StaticAttributeValues"."ProductAttributeValueID" = "ProductAttributeValue"."ID"' |
|
435 | + . ' AND "ProductAttributeValue"."TypeID" = "ProductAttributeType"."ID"') |
|
436 | + ->setOrderBy(array( |
|
437 | + '"ProductAttributeType"."Label"' => 'ASC', |
|
438 | + '"ProductAttributeValue"."Sort"' => 'ASC', |
|
439 | + )) |
|
440 | + ->setGroupBy(array('"ProductAttributeValue"."ID"', '"ProductAttributeType"."ID"')) |
|
441 | + ->execute() |
|
442 | + ; |
|
443 | + |
|
444 | + |
|
445 | + $curType = 0; |
|
446 | + $facets = array(); |
|
447 | + $curFacet = null; |
|
448 | + foreach ($q as $row) { |
|
449 | + if ($curType != $row['TypeID']) { |
|
450 | + if ($curType > 0) { |
|
451 | + $facets['ATT'.$curType] = $curFacet; |
|
452 | + } |
|
453 | + $curType = $row['TypeID']; |
|
454 | + $curFacet = array( |
|
455 | + 'Label' => $row['TypeLabel'], |
|
456 | + 'Source' => 'ATT'.$curType, |
|
457 | + 'Type' => ShopSearch::FACET_TYPE_LINK, |
|
458 | + 'Values' => array(), |
|
459 | + ); |
|
460 | + } |
|
461 | + |
|
462 | + unset($row['TypeID']); |
|
463 | + unset($row['TypeLabel']); |
|
464 | + $curFacet['Values'][ $row['Value'] ] = new ArrayData($row); |
|
465 | + } |
|
466 | + |
|
467 | + if ($curType > 0) { |
|
468 | + $facets['ATT'.$curType] = $curFacet; |
|
469 | + } |
|
470 | + return $facets; |
|
471 | + } |
|
472 | + |
|
473 | + |
|
474 | + /** |
|
475 | + * Inserts a "Link" field into the values for each facet which can be |
|
476 | + * used to get a filtered search based on that facets |
|
477 | + * |
|
478 | + * @param ArrayList $facets |
|
479 | + * @param array $baseParams |
|
480 | + * @param string $baseLink |
|
481 | + * @return ArrayList |
|
482 | + */ |
|
483 | + public function insertFacetLinks(ArrayList $facets, array $baseParams, $baseLink) |
|
484 | + { |
|
485 | + $qs_f = Config::inst()->get('ShopSearch', 'qs_filters'); |
|
486 | + $qs_t = Config::inst()->get('ShopSearch', 'qs_title'); |
|
487 | + |
|
488 | + foreach ($facets as $facet) { |
|
489 | + switch ($facet->Type) { |
|
490 | + case ShopSearch::FACET_TYPE_RANGE: |
|
491 | + $params = array_merge($baseParams, array()); |
|
492 | + if (!isset($params[$qs_f])) { |
|
493 | + $params[$qs_f] = array(); |
|
494 | + } |
|
495 | + $params[$qs_f][$facet->Source] = 'RANGEFACETVALUE'; |
|
496 | + $params[$qs_t] = $facet->Label . ': RANGEFACETLABEL'; |
|
497 | + $facet->Link = $baseLink . '?' . http_build_query($params); |
|
498 | + break; |
|
499 | + |
|
500 | + case ShopSearch::FACET_TYPE_CHECKBOX; |
|
501 | + $facet->LinkDetails = json_encode(array( |
|
502 | + 'filter' => $qs_f, |
|
503 | + 'source' => $facet->Source, |
|
504 | + 'leaves' => $facet->FilterOnlyLeaves, |
|
505 | + )); |
|
506 | + |
|
507 | + // fall through on purpose |
|
508 | + |
|
509 | + default: |
|
510 | + foreach ($facet->Values as $value) { |
|
511 | + // make a copy of the existing params |
|
512 | + $params = array_merge($baseParams, array()); |
|
513 | + |
|
514 | + // add the filter for this value |
|
515 | + if (!isset($params[$qs_f])) { |
|
516 | + $params[$qs_f] = array(); |
|
517 | + } |
|
518 | + if ($facet->Type == ShopSearch::FACET_TYPE_CHECKBOX) { |
|
519 | + unset($params[$qs_f][$facet->Source]); // this will be figured out via javascript |
|
520 | + $params[$qs_t] = ($value->Active ? 'Remove ' : '') . $facet->Label . ': ' . $value->Label; |
|
521 | + } else { |
|
522 | + $params[$qs_f][$facet->Source] = $value->Value; |
|
523 | + $params[$qs_t] = $facet->Label . ': ' . $value->Label; |
|
524 | + } |
|
525 | + |
|
526 | + // build a new link |
|
527 | + $value->Link = $baseLink . '?' . http_build_query($params); |
|
528 | + } |
|
529 | + } |
|
530 | + } |
|
531 | + |
|
532 | + return $facets; |
|
533 | + } |
|
534 | + |
|
535 | + |
|
536 | + /** |
|
537 | + * @param ArrayList $children |
|
538 | + * @return array |
|
539 | + */ |
|
540 | + protected function getRecursiveChildValues(ArrayList $children) |
|
541 | + { |
|
542 | + $out = array(); |
|
543 | + |
|
544 | + foreach ($children as $child) { |
|
545 | + $out[$child->Value] = $child->Value; |
|
546 | + if (!empty($child->Children)) { |
|
547 | + $out += $this->getRecursiveChildValues($child->Children); |
|
548 | + } |
|
549 | + } |
|
550 | + |
|
551 | + return $out; |
|
552 | + } |
|
553 | + |
|
554 | + |
|
555 | + /** |
|
556 | + * For checkbox and range facets, this updates the state (checked and min/max) |
|
557 | + * based on current filter values. |
|
558 | + * |
|
559 | + * @param ArrayList $facets |
|
560 | + * @param array $filters |
|
561 | + * @return ArrayList |
|
562 | + */ |
|
563 | + public function updateFacetState(ArrayList $facets, array $filters) |
|
564 | + { |
|
565 | + foreach ($facets as $facet) { |
|
566 | + if ($facet->Type == ShopSearch::FACET_TYPE_CHECKBOX) { |
|
567 | + if (empty($filters[$facet->Source])) { |
|
568 | + // If the filter is not being used at all, we count |
|
569 | + // all values as active. |
|
570 | + foreach ($facet->Values as $value) { |
|
571 | + $value->Active = (bool)FacetHelper::config()->default_checkbox_state; |
|
572 | + } |
|
573 | + } else { |
|
574 | + $filterVals = $filters[$facet->Source]; |
|
575 | + if (!is_array($filterVals)) { |
|
576 | + $filterVals = array($filterVals); |
|
577 | + } |
|
578 | + $this->updateCheckboxFacetState( |
|
579 | + !empty($facet->NestedValues) ? $facet->NestedValues : $facet->Values, |
|
580 | + $filterVals, |
|
581 | + !empty($facet->FilterOnlyLeaves)); |
|
582 | + } |
|
583 | + } elseif ($facet->Type == ShopSearch::FACET_TYPE_RANGE) { |
|
584 | + if (!empty($filters[$facet->Source]) && preg_match('/^RANGE\~(.+)\~(.+)$/', $filters[$facet->Source], $m)) { |
|
585 | + $facet->MinValue = $m[1]; |
|
586 | + $facet->MaxValue = $m[2]; |
|
587 | + } |
|
588 | + } |
|
589 | + } |
|
590 | + |
|
591 | + return $facets; |
|
592 | + } |
|
593 | + |
|
594 | + |
|
595 | + /** |
|
596 | + * For checkboxes, updates the state based on filters. Handles hierarchies and FilterOnlyLeaves |
|
597 | + * @param ArrayList $values |
|
598 | + * @param array $filterVals |
|
599 | + * @param bool $filterOnlyLeaves [optional] |
|
600 | + * @return bool - true if any of the children are true, false if all children are false |
|
601 | + */ |
|
602 | + protected function updateCheckboxFacetState(ArrayList $values, array $filterVals, $filterOnlyLeaves=false) |
|
603 | + { |
|
604 | + $out = false; |
|
605 | + |
|
606 | + foreach ($values as $value) { |
|
607 | + if ($filterOnlyLeaves && !empty($value->Children)) { |
|
608 | + if (in_array($value->Value, $filterVals)) { |
|
609 | + // This wouldn't be normal, but even if it's not a leaf, we want to handle |
|
610 | + // the case where a filter might be set for this node. It should still show up correctly. |
|
611 | + $value->Active = true; |
|
612 | + foreach ($value->Children as $c) { |
|
613 | + $c->Active = true; |
|
614 | + } |
|
615 | + // TODO: handle more than one level of recursion here |
|
616 | + } else { |
|
617 | + $value->Active = $this->updateCheckboxFacetState($value->Children, $filterVals, $filterOnlyLeaves); |
|
618 | + } |
|
619 | + } else { |
|
620 | + $value->Active = in_array($value->Value, $filterVals); |
|
621 | + } |
|
622 | + |
|
623 | + if ($value->Active) { |
|
624 | + $out = true; |
|
625 | + } |
|
626 | + } |
|
627 | + |
|
628 | + return $out; |
|
629 | + } |
|
630 | + |
|
631 | + |
|
632 | + /** |
|
633 | + * If there are any facets (link or checkbox) that have a HierarchyDivider field |
|
634 | + * in the spec, transform them into a hierarchy so they can be displayed as such. |
|
635 | + * |
|
636 | + * @param ArrayList $facets |
|
637 | + * @return ArrayList |
|
638 | + */ |
|
639 | + public function transformHierarchies(ArrayList $facets) |
|
640 | + { |
|
641 | + foreach ($facets as $facet) { |
|
642 | + if (!empty($facet->HierarchyDivider)) { |
|
643 | + $out = new ArrayList(); |
|
644 | + $parentStack = array(); |
|
645 | + |
|
646 | + foreach ($facet->Values as $value) { |
|
647 | + if (empty($value->Label)) { |
|
648 | + continue; |
|
649 | + } |
|
650 | + $value->FullLabel = $value->Label; |
|
651 | + |
|
652 | + // Look for the most recent parent that matches the beginning of this one |
|
653 | + while (count($parentStack) > 0) { |
|
654 | + $curParent = $parentStack[ count($parentStack)-1 ]; |
|
655 | + if (strpos($value->Label, $curParent->FullLabel) === 0) { |
|
656 | + if (!isset($curParent->Children)) { |
|
657 | + $curParent->Children = new ArrayList(); |
|
658 | + } |
|
659 | + |
|
660 | + // Modify the name so we only show the last component |
|
661 | + $value->FullLabel = $value->Label; |
|
662 | + $p = strrpos($value->Label, $facet->HierarchyDivider); |
|
663 | + if ($p > -1) { |
|
664 | + $value->Label = trim(substr($value->Label, $p + 1)); |
|
665 | + } |
|
666 | + |
|
667 | + $curParent->Children->push($value); |
|
668 | + break; |
|
669 | + } else { |
|
670 | + array_pop($parentStack); |
|
671 | + } |
|
672 | + } |
|
673 | + |
|
674 | + // If we went all the way back to the root without a match, this is |
|
675 | + // a new parent item |
|
676 | + if (count($parentStack) == 0) { |
|
677 | + $out->push($value); |
|
678 | + } |
|
679 | + |
|
680 | + // Each item could be a potential parent. If it's not it will get popped |
|
681 | + // immediately on the next iteration |
|
682 | + $parentStack[] = $value; |
|
683 | + } |
|
684 | + |
|
685 | + $facet->NestedValues = $out; |
|
686 | + } |
|
687 | + } |
|
688 | + |
|
689 | + return $facets; |
|
690 | + } |
|
691 | 691 | } |