Skip to content

Commit

Permalink
NEW Handle WithinRangeFilter in searchable_fields and SearchContext
Browse files Browse the repository at this point in the history
This is especially helpful with date ranges, e.g. all records edited
within a certain range, but can be used out of the box with basically
any numeric, date, or time-based fields.
  • Loading branch information
GuySartorelli committed Feb 11, 2025
1 parent e53b2b2 commit b81437c
Show file tree
Hide file tree
Showing 16 changed files with 919 additions and 57 deletions.
2 changes: 2 additions & 0 deletions src/Core/Validation/FieldValidation/BigIntFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public function __construct(
if ($bits === 32) {
throw new RunTimeException('Cannot use BigIntFieldValidator on a 32-bit system');
}
$minValue ??= (int) DBBigInt::getMinValue();
$maxValue ??= (int) DBBigInt::getMaxValue();
}
$this->minValue = $minValue;
$this->maxValue = $maxValue;
Expand Down
148 changes: 112 additions & 36 deletions src/ORM/DataObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Dev\Debug;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Forms\FieldGroup;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\FormField;
use SilverStripe\Forms\FormScaffolder;
Expand Down Expand Up @@ -46,6 +47,7 @@
use SilverStripe\Security\Security;
use SilverStripe\View\SSViewer;
use SilverStripe\Model\ModelData;
use SilverStripe\ORM\Filters\WithinRangeFilter;
use stdClass;

/**
Expand Down Expand Up @@ -2438,40 +2440,7 @@ public function scaffoldSearchFields($_params = null)
continue;
}

// If a custom fieldclass is provided as a string, use it
$field = null;
if ($params['fieldClasses'] && isset($params['fieldClasses'][$fieldName])) {
$fieldClass = $params['fieldClasses'][$fieldName];
$field = new $fieldClass($fieldName);
// If we explicitly set a field, then construct that
} elseif (isset($spec['field'])) {
// If it's a string, use it as a class name and construct
if (is_string($spec['field'])) {
$fieldClass = $spec['field'];
$field = new $fieldClass($fieldName);

// If it's a FormField object, then just use that object directly.
} elseif ($spec['field'] instanceof FormField) {
$field = $spec['field'];

// Otherwise we have a bug
} else {
user_error("Bad value for searchable_fields, 'field' value: "
. var_export($spec['field'], true), E_USER_WARNING);
}

// Otherwise, use the database field's scaffolder
} elseif ($object = $this->relObject($fieldName)) {
if (is_object($object) && $object->hasMethod('scaffoldSearchField')) {
$field = $object->scaffoldSearchField();
} else {
throw new Exception(sprintf(
"SearchField '%s' on '%s' does not return a valid DBField instance.",
$fieldName,
get_class($this)
));
}
}
$field = $this->scaffoldSingleSearchField($fieldName, $spec, $params);

// Allow fields to opt out of search
if (!$field) {
Expand All @@ -2483,6 +2452,24 @@ public function scaffoldSearchFields($_params = null)
}
$field->setTitle($spec['title']);

// If we're using a WithinRangeFilter, split the field into two separate fields (from and to)
if (is_a($spec['filter'] ?? '', WithinRangeFilter::class, true)) {
$fieldFrom = $field;
$fieldTo = clone $field;
$originalTitle = $field->Title();
$originalName = $field->getName();

$fieldFrom->setName($originalName . '_SearchFrom');
$fieldFrom->setTitle(_t(__CLASS__ . '.FILTER_WITHINRANGE_FROM', 'From'));
$fieldTo->setName($originalName . '_SearchTo');
$fieldTo->setTitle(_t(__CLASS__ . '.FILTER_WITHINRANGE_TO', 'To'));

$field = FieldGroup::create(
$originalTitle,
[$fieldFrom, $fieldTo]
)->setName($originalName)->addExtraClass('fieldgroup--fill-width');
}

$fields->push($field);
}

Expand All @@ -2498,6 +2485,56 @@ public function scaffoldSearchFields($_params = null)
return $fields;
}

/**
* Scaffold a single form field for use by the search context form.
*/
private function scaffoldSingleSearchField(string $fieldName, array $spec, ?array $params): ?FormField
{
// If a custom fieldclass is provided as a string, use it
$field = null;
if ($params['fieldClasses'] && isset($params['fieldClasses'][$fieldName])) {
$fieldClass = $params['fieldClasses'][$fieldName];
$field = new $fieldClass($fieldName);
// If we explicitly set a field, then construct that
} elseif (isset($spec['field'])) {
// If it's a string, use it as a class name and construct
if (is_string($spec['field'])) {
$fieldClass = $spec['field'];
$field = new $fieldClass($fieldName);

// If it's a FormField object, then just use that object directly.
} elseif ($spec['field'] instanceof FormField) {
$field = $spec['field'];

// Otherwise we have a bug
} else {
user_error("Bad value for searchable_fields, 'field' value: "
. var_export($spec['field'], true), E_USER_WARNING);
}
// Use the explicitly defined dataType if one was set
} elseif (isset($spec['dataType'])) {
$object = Injector::inst()->get($spec['dataType'], true);
$field = $this->scaffoldFieldFromObject($object, $fieldName);
$field->setName($fieldName);
// Otherwise, use the database field's scaffolder
} elseif ($object = $this->relObject($fieldName)) {
$field = $this->scaffoldFieldFromObject($object, $fieldName);
}
return $field;
}

private function scaffoldFieldFromObject(mixed $object, string $fieldName): FormField
{
if (!is_object($object) || !$object->hasMethod('scaffoldSearchField')) {
throw new LogicException(sprintf(
"SearchField '%s' on '%s' does not return a valid DBField instance.",
$fieldName,
get_class($this)
));
}
return $object->scaffoldSearchField();
}

/**
* Scaffold a simple edit form for all properties on this dataobject,
* based on default {@link FormField} mapping in {@link DBField::scaffoldFormField()}.
Expand Down Expand Up @@ -3896,28 +3933,40 @@ public function searchableFields()
$rewrite = [];
foreach ($fields as $name => $specOrName) {
$identifier = (is_int($name)) ? $specOrName : $name;
$relObject = isset($specOrName['match_any']) ? null : $this->relObject($identifier);

if (is_int($name)) {
// Format: array('MyFieldName')
$rewrite[$identifier] = [];
} elseif (is_array($specOrName) && (isset($specOrName['match_any']))) {
$rewrite[$identifier] = $fields[$identifier];
$rewrite[$identifier]['match_any'] = $specOrName['match_any'];
} elseif (is_array($specOrName) && ($relObject = $this->relObject($identifier))) {
} elseif (is_array($specOrName)) {
// Format: array('MyFieldName' => array(
// 'filter => 'ExactMatchFilter',
// 'field' => 'NumericField', // optional
// 'title' => 'My Title', // optional
// 'dataType' => DBInt::class // optional
// These two are only required if using WithinRangeFilter with a data type that doesn't
// inherently represent a date, time, or number
// 'rangeFromDefault' => PHP_INT_MIN
// 'rangeToDefault' => PHP_INT_MAX
// ))
$rewrite[$identifier] = array_merge(
['filter' => $relObject->config()->get('default_search_filter_class')],
[
'filter' => $relObject?->config()->get('default_search_filter_class'),
'dataType' => $relObject ? get_class($relObject) : null,
],
(array)$specOrName
);
} else {
// Format: array('MyFieldName' => 'ExactMatchFilter')
$rewrite[$identifier] = [
'filter' => $specOrName,
];
if ($relObject !== null) {
$rewrite[$identifier]['dataType'] ??= get_class($relObject);
}
}
if (!isset($rewrite[$identifier]['title'])) {
$rewrite[$identifier]['title'] = (isset($labels[$identifier]))
Expand All @@ -3926,6 +3975,33 @@ public function searchableFields()
if (!isset($rewrite[$identifier]['filter'])) {
$rewrite[$identifier]['filter'] = 'PartialMatchFilter';
}
// When using a WithinRangeFilter we need to know what the default from and to values
// should be, so that if a user only enters one of the two fields the other can be
// populated appropriately within the filter.
if (is_a($rewrite[$identifier]['filter'], WithinRangeFilter::class, true)) {
// The dataType requirement here is explicitly for WithinRangeFilter.
// DO NOT make it mandatory for other filters without first checking if this breaks
// anything for filtering a relation, where the class on the other end of the relation
// implements scaffoldSearchField().
$dataType = $rewrite[$identifier]['dataType'] ?? null;
if (!is_a($dataType ?? '', DBField::class, true)) {
throw new LogicException("dataType must be set to a DBField class for '$identifier'");
}
if (!isset($rewrite[$identifier]['rangeFromDefault'])) {
$fromDefault = $dataType::getMinValue();
if ($fromDefault === null) {
throw new LogicException("rangeFromDefault must be set for '$identifier'");
}
$rewrite[$identifier]['rangeFromDefault'] = $fromDefault;
}
if (!isset($rewrite[$identifier]['rangeToDefault'])) {
$toDefault = $dataType::getMaxValue();
if ($toDefault === null) {
throw new LogicException("rangeToDefault must be set for '$identifier'");
}
$rewrite[$identifier]['rangeToDefault'] = $toDefault;
}
}
}

$fields = $rewrite;
Expand Down
1 change: 1 addition & 0 deletions src/ORM/FieldType/DBDatetime.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use SilverStripe\Model\ModelData;
use SilverStripe\Core\Validation\FieldValidation\DateFieldValidator;
use SilverStripe\Core\Validation\FieldValidation\DatetimeFieldValidator;
use SilverStripe\ORM\Tests\Search\SearchContextTest\WithinRangeFilterModel;

/**
* Represents a date-time field.
Expand Down
19 changes: 14 additions & 5 deletions src/ORM/Filters/WithinRangeFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,29 @@

class WithinRangeFilter extends SearchFilter
{
private mixed $min = null;
private mixed $max = null;

private $min;
private $max;

public function setMin($min)
public function setMin(mixed $min)
{
$this->min = $min;
}

public function setMax($max)
public function getMin(): mixed
{
return $this->min;
}

public function setMax(mixed $max)
{
$this->max = $max;
}

public function getMax(): mixed
{
return $this->max;
}

protected function applyOne(DataQuery $query)
{
$this->model = $query->applyRelation($this->relation);
Expand Down
59 changes: 49 additions & 10 deletions src/ORM/Search/SearchContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
use Exception;
use LogicException;
use SilverStripe\Core\Config\Config;
use SilverStripe\Forms\DateField;
use SilverStripe\ORM\DataQuery;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\Filters\WithinRangeFilter;

/**
* Manages searching of properties on one or more {@link DataObject}
Expand Down Expand Up @@ -78,6 +81,13 @@ class SearchContext
*/
protected $searchParams = [];

/**
* A list of fields that use WithinRangeFilter that have already been included in the query.
* This prevents the "from" and "to" fields from both independently affecting the query since they
* have to be paired together in the same filter.
*/
protected $withinRangeFieldsChecked = [];

/**
* A key value pair of values that should be searched for.
* The keys should match the field names specified in {@link SearchContext::$fields}.
Expand Down Expand Up @@ -161,6 +171,7 @@ public function getQuery($searchParams, $sort = false, int|array|null $limit = n
*/
private function search(DataList $query): DataList
{
$this->withinRangeFieldsChecked = [];
/** @var DataObject $modelObj */
$modelObj = Injector::inst()->create($this->modelClass);
$searchableFields = $modelObj->searchableFields();
Expand Down Expand Up @@ -296,8 +307,35 @@ private function individualFieldSearch(DataList $query, array $searchableFields,
return $query;
}
$filter->setModel($this->modelClass);
$filter->setValue($searchPhrase);
$searchableFieldSpec = $searchableFields[$searchField] ?? [];
// WithinRangeFilter needs a bit of help knowing what its "from" and "to" values are
if ($filter instanceof WithinRangeFilter) {
$baseName = preg_replace('/_Search(From|To)$/', '', $searchField);
if (array_key_exists($baseName, $this->withinRangeFieldsChecked)) {
return $query;
}
$allSearchParams = $this->getSearchParams();
$searchableFieldSpec = $searchableFields[$baseName] ?? [];
$from = $allSearchParams[$baseName . '_SearchFrom'] ?? $searchableFieldSpec['rangeFromDefault'];
$to = $allSearchParams[$baseName . '_SearchTo'] ?? $searchableFieldSpec['rangeToDefault'];
// If we're using DateField for a DBDateTime, set "from" to the start of the day, and "to" to the end of the day.
// Though if we're using the default values, we don't need to add this as it should already be there.
if (is_a($searchableFieldSpec['dataType'] ?? '', DBDatetime::class, true)
&& is_a($searchableFieldSpec['field'] ?? '', DateField::class, true)
) {
if ($from !== $searchableFieldSpec['rangeFromDefault']) {
$from .= ' 00:00:00';
}
if ($to !== $searchableFieldSpec['rangeToDefault']) {
$to .= ' 23:59:59';
}
}
$filter->setMin($from);
$filter->setMax($to);
$this->withinRangeFieldsChecked[$baseName] = true;
} else {
$filter->setValue($searchPhrase);
$searchableFieldSpec = $searchableFields[$searchField] ?? [];
}
return $query->alterDataQuery(function ($dataQuery) use ($filter, $searchableFieldSpec) {
$this->applyFilter($filter, $dataQuery, $searchableFieldSpec);
});
Expand All @@ -318,9 +356,13 @@ private function applyFilter(SearchFilter $filter, DataQuery $dataQuery, array $
$modifiers = $filter->getModifiers();
$subGroup = $dataQuery->disjunctiveGroup();
foreach ($searchFields as $matchField) {
/** @var SearchFilter $filter */
$filter = Injector::inst()->create($filterClass, $matchField, $value, $modifiers);
$filter->apply($subGroup);
/** @var SearchFilter $subFilter */
$subFilter = Injector::inst()->create($filterClass, $matchField, $value, $modifiers);
if ($subFilter instanceof WithinRangeFilter) {
$subFilter->setMin($filter->getMin());
$subFilter->setMax($filter->getMax());
}
$subFilter->apply($subGroup);
}
} else {
$filter->apply($dataQuery);
Expand Down Expand Up @@ -364,11 +406,8 @@ public function clearEmptySearchFields($value)
*/
public function getFilter($name)
{
if (isset($this->filters[$name])) {
return $this->filters[$name];
} else {
return null;
}
$withinRangeFilterCheck = preg_replace('/_Search(From|To)$/', '', $name);
return $this->filters[$name] ?? $this->filters[$withinRangeFilterCheck] ?? null;
}

/**
Expand Down
Loading

0 comments on commit b81437c

Please sign in to comment.