Completed
Push — master ( ec8fbc...686b54 )
by Mark
22:56
created
code/helpers/FacetHelper.php 1 patch
Indentation   +681 added lines, -681 removed lines patch added patch discarded remove patch
@@ -1,691 +1,691 @@
 block discarded – undo
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
 }
Please login to merge, or discard this patch.