* Injector: * DataListFilter.EndsWith: * class: EndsWithFilter * */ abstract class SearchFilter { use Injectable; /** * Classname of the inspected {@link DataObject}. * If pointing to a relation, this will be the classname of the leaf * class in the relation * * @var string */ protected $model; /** * @var string */ protected $name; /** * @var string */ protected $fullName; /** * @var mixed */ protected $value; /** * @var array */ protected $modifiers; /** * @var array Parts of a has-one, has-many or many-many relation (not the classname). * Set in the constructor as part of the name in dot-notation, and used in * {@link applyRelation()}. * * Also used to build table prefix (see getRelationTablePrefix) */ protected $relation = []; /** * An array of data about an aggregate column being used * ex: * [ * 'function' => 'COUNT', * 'column' => 'ID' * ] * @var array */ protected $aggregate; /** * @param string $fullName Determines the name of the field, as well as the searched database * column. Can contain a relation name in dot notation, which will automatically join * the necessary tables (e.g. "Comments.Name" to join the "Comments" has-many relationship and * search the "Name" column when applying this filter to a SiteTree class). * @param mixed $value * @param array $modifiers */ public function __construct($fullName = null, $value = false, array $modifiers = []) { $this->fullName = $fullName; // sets $this->name and $this->relation $this->addRelation($fullName); $this->addAggregate($fullName); $this->value = $value; $this->setModifiers($modifiers); } /** * Called by constructor to convert a string pathname into * a well defined relationship sequence. * * @param string $name */ protected function addRelation($name) { if (strstr($name ?? '', '.')) { $parts = explode('.', $name ?? ''); $this->name = array_pop($parts); $this->relation = $parts; } else { $this->name = $name; } } /** * Parses the name for any aggregate functions and stores them in the $aggregate array * * @param string $name */ protected function addAggregate($name) { if (!$this->relation) { return; } if (!preg_match('/([A-Za-z]+)\(\s*(?:([A-Za-z_*][A-Za-z0-9_]*))?\s*\)$/', $name ?? '', $matches)) { if (stristr($name ?? '', '(') !== false) { throw new InvalidArgumentException(sprintf( 'Malformed aggregate filter %s', $name )); } return; } $this->aggregate = [ 'function' => strtoupper($matches[1] ?? ''), 'column' => isset($matches[2]) ? $matches[2] : null ]; } /** * Set the root model class to be selected by this * search query. * * @param string|DataObject $className */ public function setModel($className) { $this->model = ClassInfo::class_name($className); } /** * Set the current value(s) to be filtered on. * * @param string|array $value */ public function setValue($value) { $this->value = $value; } /** * Accessor for the current value to be filtered on. * * @return string|array */ public function getValue() { return $this->value; } /** * Set the current modifiers to apply to the filter * * @param array $modifiers */ public function setModifiers(array $modifiers) { $modifiers = array_map('strtolower', $modifiers ?? []); // Validate modifiers are supported $allowed = $this->getSupportedModifiers(); $unsupported = array_diff($modifiers ?? [], $allowed); if ($unsupported) { throw new InvalidArgumentException( static::class . ' does not accept ' . implode(', ', $unsupported) . ' as modifiers' ); } $this->modifiers = $modifiers; } /** * Gets supported modifiers for this filter * * @return array */ public function getSupportedModifiers() { // By default support 'not' as a modifier for all filters return ['not']; } /** * Accessor for the current modifiers to apply to the filter. * * @return array */ public function getModifiers() { return $this->modifiers; } /** * The original name of the field. * * @return string */ public function getName() { return $this->name; } /** * @param string $name */ public function setName($name) { $this->name = $name; } /** * The full name passed to the constructor, * including any (optional) relations in dot notation. * * @return string */ public function getFullName() { return $this->fullName; } /** * @param string $name */ public function setFullName($name) { $this->fullName = $name; } /** * Normalizes the field name to table mapping. * * @return string */ public function getDbName() { // Special handler for "NULL" relations if ($this->name === "NULL") { return $this->name; } // Ensure that we're dealing with a DataObject. if (!is_subclass_of($this->model, DataObject::class)) { throw new InvalidArgumentException( "Model supplied to " . static::class . " should be an instance of DataObject." ); } $tablePrefix = DataQuery::applyRelationPrefix($this->relation); $schema = DataObject::getSchema(); if ($this->aggregate) { $column = $this->aggregate['column']; $function = $this->aggregate['function']; $table = $column ? $schema->tableForField($this->model, $column) : $schema->baseDataTable($this->model); if (!$table) { throw new InvalidArgumentException(sprintf( 'Invalid column %s for aggregate function %s on %s', $column, $function, $this->model )); } return sprintf( '%s("%s%s".%s)', $function, $tablePrefix, $table, $column ? "\"$column\"" : '"ID"' ); } // Check if this column is a table on the current model $table = $schema->tableForField($this->model, $this->name); if ($table) { return $schema->sqlColumnForField($this->model, $this->name, $tablePrefix); } // fallback to the provided name in the event of a joined column // name (as the candidate class doesn't check joined records) $parts = explode('.', $this->fullName ?? ''); return '"' . implode('"."', $parts) . '"'; } /** * Return the value of the field as processed by the DBField class * * @return string */ public function getDbFormattedValue() { // SRM: This code finds the table where the field named $this->name lives // Todo: move to somewhere more appropriate, such as DataMapper, the magical class-to-be? if ($this->aggregate) { return intval($this->value); } /** @var DBField $dbField */ $dbField = singleton($this->model)->dbObject($this->name); $dbField->setValue($this->value); return $dbField->RAW(); } /** * Given an escaped HAVING clause, add it along with the appropriate GROUP BY clause * @param DataQuery $query * @param string $having * @return DataQuery */ public function applyAggregate(DataQuery $query, $having) { $schema = DataObject::getSchema(); $baseTable = $schema->baseDataTable($query->dataClass()); return $query ->having($having) ->groupby("\"{$baseTable}\".\"ID\""); } /** * Apply filter criteria to a SQL query. * * @param DataQuery $query * @return DataQuery */ public function apply(DataQuery $query) { if (($key = array_search('not', $this->modifiers ?? [])) !== false) { unset($this->modifiers[$key]); return $this->exclude($query); } if (is_array($this->value)) { return $this->applyMany($query); } else { return $this->applyOne($query); } } /** * Apply filter criteria to a SQL query with a single value. * * @param DataQuery $query * @return DataQuery */ abstract protected function applyOne(DataQuery $query); /** * Apply filter criteria to a SQL query with an array of values. * * @param DataQuery $query * @return DataQuery */ protected function applyMany(DataQuery $query) { throw new InvalidArgumentException(static::class . " can't be used to filter by a list of items."); } /** * Exclude filter criteria from a SQL query. * * @param DataQuery $query * @return DataQuery */ public function exclude(DataQuery $query) { if (($key = array_search('not', $this->modifiers ?? [])) !== false) { unset($this->modifiers[$key]); return $this->apply($query); } if (is_array($this->value)) { return $this->excludeMany($query); } else { return $this->excludeOne($query); } } /** * Exclude filter criteria from a SQL query with a single value. * * @param DataQuery $query * @return DataQuery */ abstract protected function excludeOne(DataQuery $query); /** * Exclude filter criteria from a SQL query with an array of values. * * @param DataQuery $query * @return DataQuery */ protected function excludeMany(DataQuery $query) { throw new InvalidArgumentException(static::class . " can't be used to filter by a list of items."); } /** * Determines if a field has a value, * and that the filter should be applied. * Relies on the field being populated with * {@link setValue()} * * @return boolean */ public function isEmpty() { return false; } /** * Determines case sensitivity based on {@link getModifiers()}. * * @return Mixed TRUE or FALSE to enforce sensitivity, NULL to use field collation. */ protected function getCaseSensitive() { $modifiers = $this->getModifiers(); if (in_array('case', $modifiers ?? [])) { return true; } elseif (in_array('nocase', $modifiers ?? [])) { return false; } else { return null; } } }