Source of file FacetHelper.php
Size: 27,674 Bytes - Last Modified: 2021-12-23T10:03:27+00:00
/var/www/docs.ssmods.com/process/src/code/helpers/FacetHelper.php
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692 | <?php /** * Adds methods for limited kinds of faceting using the silverstripe ORM. * This is used by the default ShopSearchSimple adapter but also can * be added to other contexts (such as ProductCategory). * * TODO: Facet class + subclasses * * @author Mark Guinn <mark@adaircreative.com> * @date 10.21.2013 * @package shop_search * @subpackage helpers */ class FacetHelper extends Object { /** @var bool - if this is turned on it will use an algorithm that doesn't require traversing the data set if possible */ private static $faster_faceting = false; /** @var bool - should the facets (link and checkbox only) be sorted - this can mess with things like category lists */ private static $sort_facet_values = true; /** @var string - I don't know why you'd want to override this, but you could if you wanted */ private static $attribute_facet_regex = '/^ATT(\d+)$/'; /** @var bool - For checkbox facets, is the initial state all checked or all unchecked? */ private static $default_checkbox_state = true; /** * @return FacetHelper */ public static function inst() { return Injector::inst()->get('FacetHelper'); } /** * Performs some quick pre-processing on filters from any source * * @param array $filters * @return array */ public function scrubFilters($filters) { if (!is_array($filters)) { $filters = array(); } foreach ($filters as $k => $v) { if (empty($v)) { unset($filters[$k]); } // this allows you to send an array as a comma-separated list, which is easier on the query string length if (is_string($v) && strpos($v, 'LIST~') === 0) { $filters[$k] = explode(',', substr($v, 5)); } } return $filters; } /** * @param DataList $list * @param array $filters * @param DataObject|string $sing - just a singleton object we can get information off of * @return DataList */ public function addFiltersToDataList($list, array $filters, $sing=null) { if (!$sing) { $sing = singleton($list->dataClass()); } if (is_string($sing)) { $sing = singleton($sing); } if (!empty($filters)) { foreach ($filters as $filterField => $filterVal) { if ($sing->hasExtension('HasStaticAttributes') && preg_match(self::config()->attribute_facet_regex, $filterField, $matches)) { // $sav = $sing->StaticAttributeValues(); // Debug::log("sav = {$sav->getJoinTable()}, {$sav->getLocalKey()}, {$sav->getForeignKey()}"); // $list = $list // ->innerJoin($sav->getJoinTable(), "\"{$sing->baseTable()}\".\"ID\" = \"{$sav->getJoinTable()}\".\"{$sav->getLocalKey()}\"") // ->filter("\"{$sav->getJoinTable()}\".\"{$sav->getForeignKey()}\"", $filterVal) // ; // TODO: This logic should be something like the above, but I don't know // how to get the join table from a singleton (which returns an UnsavedRelationList // instead of a ManyManyList). I've got a deadline to meet, though, so this // will catch the majority of cases as long as the extension is applied to the // Product class instead of a subclass. $list = $list ->innerJoin('Product_StaticAttributeTypes', "\"SiteTree\".\"ID\" = \"Product_StaticAttributeTypes\".\"ProductID\"") ->innerJoin('ProductAttributeValue', "\"Product_StaticAttributeTypes\".\"ProductAttributeTypeID\" = \"ProductAttributeValue\".\"TypeID\"") ->innerJoin('Product_StaticAttributeValues', "\"SiteTree\".\"ID\" = \"Product_StaticAttributeValues\".\"ProductID\" AND \"ProductAttributeValue\".\"ID\" = \"Product_StaticAttributeValues\".\"ProductAttributeValueID\"") ->filter("Product_StaticAttributeValues.ProductAttributeValueID", $filterVal); } else { $list = $list->filter($this->processFilterField($sing, $filterField, $filterVal)); } } } return $list; } /** * @param DataObject $rec This would normally just be a singleton but we don't want to have to create it over and over * @param string $filterField * @param mixed $filterVal * @return array - returns the new filter added */ public function processFilterField($rec, $filterField, $filterVal) { // First check for VFI fields if ($rec->hasExtension('VirtualFieldIndex') && ($spec = $rec->getVFISpec($filterField))) { if ($spec['Type'] == VirtualFieldIndex::TYPE_LIST) { // Lists have to be handled a little differently $f = $rec->getVFIFieldName($filterField) . ':PartialMatch'; if (is_array($filterVal)) { foreach ($filterVal as &$val) { $val = '|' . $val . '|'; } return array($f => $filterVal); } else { return array($f => '|' . $filterVal . '|'); } } else { // Simples are simple $filterField = $rec->getVFIFieldName($filterField); } } // Next check for regular db fields if ($rec->dbObject($filterField)) { // Is it a range value? if (is_string($filterVal) && preg_match('/^RANGE\~(.+)\~(.+)$/', $filterVal, $m)) { $filterField .= ':Between'; $filterVal = array_slice($m, 1, 2); } return array($filterField => $filterVal); } return array(); } /** * Processes the facet spec and removes any shorthand (field => label). * @param array $facetSpec * @return array */ public function expandFacetSpec(array $facetSpec) { if (is_null($facetSpec)) { return array(); } $facets = array(); foreach ($facetSpec as $field => $label) { if (is_array($label)) { $facets[$field] = $label; } else { $facets[$field] = array('Label' => $label); } if (empty($facets[$field]['Source'])) { $facets[$field]['Source'] = $field; } if (empty($facets[$field]['Type'])) { $facets[$field]['Type'] = ShopSearch::FACET_TYPE_LINK; } if (empty($facets[$field]['Values'])) { $facets[$field]['Values'] = array(); } else { $vals = $facets[$field]['Values']; if (is_string($vals)) { $vals = eval('return ' . $vals . ';'); } $facets[$field]['Values'] = array(); foreach ($vals as $val => $lbl) { $facets[$field]['Values'][$val] = new ArrayData(array( 'Label' => $lbl, 'Value' => $val, 'Count' => 0, )); } } } return $facets; } /** * This is super-slow. I'm assuming if you're using facets you * probably also ought to be using Solr or something else. Or * maybe you have unlimited time and can refactor this feature * and submit a pull request... * * TODO: If this is going to be used for categories we're going * to have to really clean it up and speed it up. * Suggestion: * - option to turn off counts * - switch order of nested array so we don't go through results unless needed * - if not doing counts, min/max and link facets can be handled w/ queries * - separate that bit out into a new function * NOTE: This is partially done with the "faster_faceting" config * option but more could be done, particularly by covering link facets as well. * * Output - list of ArrayData in the format: * Label - name of the facet * Source - field name of the facet * Type - one of the ShopSearch::FACET_TYPE_XXXX constants * Values - SS_List of possible values for this facet * * @param SS_List $matches * @param array $facetSpec * @param bool $autoFacetAttributes [optional] * @return ArrayList */ public function buildFacets(SS_List $matches, array $facetSpec, $autoFacetAttributes=false) { $facets = $this->expandFacetSpec($facetSpec); if (!$autoFacetAttributes && (empty($facets) || !$matches || !$matches->count())) { return new ArrayList(); } $fasterMethod = (bool)$this->config()->faster_faceting; // fill them in foreach ($facets as $field => &$facet) { if (preg_match(self::config()->attribute_facet_regex, $field, $m)) { $this->buildAttributeFacet($matches, $facet, $m[1]); continue; } // NOTE: using this method range and checkbox facets don't get counts if ($fasterMethod && $facet['Type'] != ShopSearch::FACET_TYPE_LINK) { if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { if (isset($facet['RangeMin'])) { $facet['MinValue'] = $facet['RangeMin']; } if (isset($facet['RangeMax'])) { $facet['MaxValue'] = $facet['RangeMax']; } } continue; } foreach ($matches as $rec) { // If it's a range facet, set up the min/max if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { if (isset($facet['RangeMin'])) { $facet['MinValue'] = $facet['RangeMin']; } if (isset($facet['RangeMax'])) { $facet['MaxValue'] = $facet['RangeMax']; } } // If the field is accessible via normal methods, including // a user-defined getter, prefer that $fieldValue = $rec->relObject($field); if (is_null($fieldValue) && $rec->hasMethod($meth = "get{$field}")) { $fieldValue = $rec->$meth(); } // If not, look for a VFI field if (!$fieldValue && $rec->hasExtension('VirtualFieldIndex')) { $fieldValue = $rec->getVFI($field); } // If we found something, process it if (!empty($fieldValue)) { // normalize so that it's iterable if (!is_array($fieldValue) && !$fieldValue instanceof SS_List) { $fieldValue = array($fieldValue); } foreach ($fieldValue as $obj) { if (empty($obj)) { continue; } // figure out the right label if (is_object($obj) && $obj->hasMethod('Nice')) { $lbl = $obj->Nice(); } elseif (is_object($obj) && !empty($obj->Title)) { $lbl = $obj->Title; } elseif ( is_numeric($obj) && !empty($facet['LabelFormat']) && $facet['LabelFormat'] === 'Currency' && $facet['Type'] !== ShopSearch::FACET_TYPE_RANGE // this one handles it via javascript ) { $tmp = Currency::create($field); $tmp->setValue($obj); $lbl = $tmp->Nice(); } else { $lbl = (string)$obj; } // figure out the value for sorting if (is_object($obj) && $obj->hasMethod('getAmount')) { $val = $obj->getAmount(); } elseif (is_object($obj) && !empty($obj->ID)) { $val = $obj->ID; } else { $val = (string)$obj; } // if it's a range facet, calculate the min and max if ($facet['Type'] == ShopSearch::FACET_TYPE_RANGE) { if (!isset($facet['MinValue']) || $val < $facet['MinValue']) { $facet['MinValue'] = $val; $facet['MinLabel'] = $lbl; } if (!isset($facet['RangeMin']) || $val < $facet['RangeMin']) { $facet['RangeMin'] = $val; } if (!isset($facet['MaxValue']) || $val > $facet['MaxValue']) { $facet['MaxValue'] = $val; $facet['MaxLabel'] = $lbl; } if (!isset($facet['RangeMax']) || $val > $facet['RangeMax']) { $facet['RangeMax'] = $val; } } // Tally the value in the facets if (!isset($facet['Values'][$val])) { $facet['Values'][$val] = new ArrayData(array( 'Label' => $lbl, 'Value' => $val, 'Count' => 1, )); } elseif ($facet['Values'][$val]) { $facet['Values'][$val]->Count++; } } } } } // if we're auto-building the facets based on attributes, if ($autoFacetAttributes) { $facets = array_merge($this->buildAllAttributeFacets($matches), $facets); } // convert values to arraylist $out = new ArrayList(); $sortValues = self::config()->sort_facet_values; foreach ($facets as $f) { if ($sortValues) { ksort($f['Values']); } $f['Values'] = new ArrayList($f['Values']); $out->push(new ArrayData($f)); } return $out; } /** * NOTE: this will break if applied to something that's not a SiteTree subclass. * @param DataList|PaginatedList $matches * @param array $facet * @param int $typeID */ protected function buildAttributeFacet($matches, array &$facet, $typeID) { $q = $matches instanceof PaginatedList ? $matches->getList()->dataQuery()->query() : $matches->dataQuery()->query(); if (empty($facet['Label'])) { $type = ProductAttributeType::get()->byID($typeID); $facet['Label'] = $type->Label; } $baseTable = $q->getFrom(); if (is_array($baseTable)) { $baseTable = reset($baseTable); } $q = $q->setSelect(array()) ->selectField('"ProductAttributeValue"."ID"', 'Value') ->selectField('"ProductAttributeValue"."Value"', 'Label') ->selectField('count(distinct '.$baseTable.'."ID")', 'Count') ->selectField('"ProductAttributeValue"."Sort"') ->addInnerJoin('Product_StaticAttributeValues', $baseTable.'."ID" = "Product_StaticAttributeValues"."ProductID"') ->addInnerJoin('ProductAttributeValue', '"Product_StaticAttributeValues"."ProductAttributeValueID" = "ProductAttributeValue"."ID"') ->addWhere(sprintf("\"ProductAttributeValue\".\"TypeID\" = '%d'", $typeID)) ->setOrderBy('"ProductAttributeValue"."Sort"', 'ASC') ->setGroupBy('"ProductAttributeValue"."ID"') ->execute() ; $facet['Values'] = array(); foreach ($q as $row) { $facet['Values'][ $row['Value'] ] = new ArrayData($row); } } /** * Builds facets from all attributes present in the data set. * @param DataList|PaginatedList $matches * @return array */ protected function buildAllAttributeFacets($matches) { $q = $matches instanceof PaginatedList ? $matches->getList()->dataQuery()->query() : $matches->dataQuery()->query(); // this is the easiest way to get SiteTree vs SiteTree_Live $baseTable = $q->getFrom(); if (is_array($baseTable)) { $baseTable = reset($baseTable); } $q = $q->setSelect(array()) ->selectField('"ProductAttributeType"."ID"', 'TypeID') ->selectField('"ProductAttributeType"."Label"', 'TypeLabel') ->selectField('"ProductAttributeValue"."ID"', 'Value') ->selectField('"ProductAttributeValue"."Value"', 'Label') ->selectField('count(distinct '.$baseTable.'."ID")', 'Count') ->selectField('"ProductAttributeValue"."Sort"') ->addInnerJoin('Product_StaticAttributeTypes', $baseTable.'."ID" = "Product_StaticAttributeTypes"."ProductID"') ->addInnerJoin('ProductAttributeType', '"Product_StaticAttributeTypes"."ProductAttributeTypeID" = "ProductAttributeType"."ID"') ->addInnerJoin('Product_StaticAttributeValues', $baseTable.'."ID" = "Product_StaticAttributeValues"."ProductID"') ->addInnerJoin('ProductAttributeValue', '"Product_StaticAttributeValues"."ProductAttributeValueID" = "ProductAttributeValue"."ID"' . ' AND "ProductAttributeValue"."TypeID" = "ProductAttributeType"."ID"') ->setOrderBy(array( '"ProductAttributeType"."Label"' => 'ASC', '"ProductAttributeValue"."Sort"' => 'ASC', )) ->setGroupBy(array('"ProductAttributeValue"."ID"', '"ProductAttributeType"."ID"')) ->execute() ; $curType = 0; $facets = array(); $curFacet = null; foreach ($q as $row) { if ($curType != $row['TypeID']) { if ($curType > 0) { $facets['ATT'.$curType] = $curFacet; } $curType = $row['TypeID']; $curFacet = array( 'Label' => $row['TypeLabel'], 'Source' => 'ATT'.$curType, 'Type' => ShopSearch::FACET_TYPE_LINK, 'Values' => array(), ); } unset($row['TypeID']); unset($row['TypeLabel']); $curFacet['Values'][ $row['Value'] ] = new ArrayData($row); } if ($curType > 0) { $facets['ATT'.$curType] = $curFacet; } return $facets; } /** * Inserts a "Link" field into the values for each facet which can be * used to get a filtered search based on that facets * * @param ArrayList $facets * @param array $baseParams * @param string $baseLink * @return ArrayList */ public function insertFacetLinks(ArrayList $facets, array $baseParams, $baseLink) { $qs_f = Config::inst()->get('ShopSearch', 'qs_filters'); $qs_t = Config::inst()->get('ShopSearch', 'qs_title'); foreach ($facets as $facet) { switch ($facet->Type) { case ShopSearch::FACET_TYPE_RANGE: $params = array_merge($baseParams, array()); if (!isset($params[$qs_f])) { $params[$qs_f] = array(); } $params[$qs_f][$facet->Source] = 'RANGEFACETVALUE'; $params[$qs_t] = $facet->Label . ': RANGEFACETLABEL'; $facet->Link = $baseLink . '?' . http_build_query($params); break; case ShopSearch::FACET_TYPE_CHECKBOX; $facet->LinkDetails = json_encode(array( 'filter' => $qs_f, 'source' => $facet->Source, 'leaves' => $facet->FilterOnlyLeaves, )); // fall through on purpose default: foreach ($facet->Values as $value) { // make a copy of the existing params $params = array_merge($baseParams, array()); // add the filter for this value if (!isset($params[$qs_f])) { $params[$qs_f] = array(); } if ($facet->Type == ShopSearch::FACET_TYPE_CHECKBOX) { unset($params[$qs_f][$facet->Source]); // this will be figured out via javascript $params[$qs_t] = ($value->Active ? 'Remove ' : '') . $facet->Label . ': ' . $value->Label; } else { $params[$qs_f][$facet->Source] = $value->Value; $params[$qs_t] = $facet->Label . ': ' . $value->Label; } // build a new link $value->Link = $baseLink . '?' . http_build_query($params); } } } return $facets; } /** * @param ArrayList $children * @return array */ protected function getRecursiveChildValues(ArrayList $children) { $out = array(); foreach ($children as $child) { $out[$child->Value] = $child->Value; if (!empty($child->Children)) { $out += $this->getRecursiveChildValues($child->Children); } } return $out; } /** * For checkbox and range facets, this updates the state (checked and min/max) * based on current filter values. * * @param ArrayList $facets * @param array $filters * @return ArrayList */ public function updateFacetState(ArrayList $facets, array $filters) { foreach ($facets as $facet) { if ($facet->Type == ShopSearch::FACET_TYPE_CHECKBOX) { if (empty($filters[$facet->Source])) { // If the filter is not being used at all, we count // all values as active. foreach ($facet->Values as $value) { $value->Active = (bool)FacetHelper::config()->default_checkbox_state; } } else { $filterVals = $filters[$facet->Source]; if (!is_array($filterVals)) { $filterVals = array($filterVals); } $this->updateCheckboxFacetState( !empty($facet->NestedValues) ? $facet->NestedValues : $facet->Values, $filterVals, !empty($facet->FilterOnlyLeaves)); } } elseif ($facet->Type == ShopSearch::FACET_TYPE_RANGE) { if (!empty($filters[$facet->Source]) && preg_match('/^RANGE\~(.+)\~(.+)$/', $filters[$facet->Source], $m)) { $facet->MinValue = $m[1]; $facet->MaxValue = $m[2]; } } } return $facets; } /** * For checkboxes, updates the state based on filters. Handles hierarchies and FilterOnlyLeaves * @param ArrayList $values * @param array $filterVals * @param bool $filterOnlyLeaves [optional] * @return bool - true if any of the children are true, false if all children are false */ protected function updateCheckboxFacetState(ArrayList $values, array $filterVals, $filterOnlyLeaves=false) { $out = false; foreach ($values as $value) { if ($filterOnlyLeaves && !empty($value->Children)) { if (in_array($value->Value, $filterVals)) { // This wouldn't be normal, but even if it's not a leaf, we want to handle // the case where a filter might be set for this node. It should still show up correctly. $value->Active = true; foreach ($value->Children as $c) { $c->Active = true; } // TODO: handle more than one level of recursion here } else { $value->Active = $this->updateCheckboxFacetState($value->Children, $filterVals, $filterOnlyLeaves); } } else { $value->Active = in_array($value->Value, $filterVals); } if ($value->Active) { $out = true; } } return $out; } /** * If there are any facets (link or checkbox) that have a HierarchyDivider field * in the spec, transform them into a hierarchy so they can be displayed as such. * * @param ArrayList $facets * @return ArrayList */ public function transformHierarchies(ArrayList $facets) { foreach ($facets as $facet) { if (!empty($facet->HierarchyDivider)) { $out = new ArrayList(); $parentStack = array(); foreach ($facet->Values as $value) { if (empty($value->Label)) { continue; } $value->FullLabel = $value->Label; // Look for the most recent parent that matches the beginning of this one while (count($parentStack) > 0) { $curParent = $parentStack[ count($parentStack)-1 ]; if (strpos($value->Label, $curParent->FullLabel) === 0) { if (!isset($curParent->Children)) { $curParent->Children = new ArrayList(); } // Modify the name so we only show the last component $value->FullLabel = $value->Label; $p = strrpos($value->Label, $facet->HierarchyDivider); if ($p > -1) { $value->Label = trim(substr($value->Label, $p + 1)); } $curParent->Children->push($value); break; } else { array_pop($parentStack); } } // If we went all the way back to the root without a match, this is // a new parent item if (count($parentStack) == 0) { $out->push($value); } // Each item could be a potential parent. If it's not it will get popped // immediately on the next iteration $parentStack[] = $value; } $facet->NestedValues = $out; } } return $facets; } } |