Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/filters-and-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,34 @@ $searchManager->callback('category_id', [

----------

`Mapped` to map form values to filter conditions with support for defaults that don't
trigger `isSearch()`. Useful for filters with a default value (e.g., "show enabled by
default") where you don't want the Reset button to appear.

```php
// Boolean example: default to enabled=true, allow showing all with -1
$searchManager->mapped('enabled', [
'map' => ['' => true, '0' => false, '-1' => null],
'default' => '',
]);

// Enum example: default to 'pending' status, unmapped values pass through
$searchManager->mapped('status', [
'map' => ['' => 'pending', '-1' => null],
'default' => '',
]);
// Values like 'active', 'completed' pass through directly as filter condition
```

Key features:
- Map keys are form values, map values are the condition to apply
- `null` in map means "no filter condition" (show all)
- `default` key specifies which value doesn't trigger `isSearch()`
- Non-empty values not in map pass through directly as filter condition
- Sets `alwaysRun: true` and `filterEmpty: false` by default

----------

## Multi-field Search Callbacks

When using callback filters that need to access values from multiple form fields,
Expand Down Expand Up @@ -314,6 +342,21 @@ The following options are supported by all filters except `Callback` and `Finder
E.g. `!` for string values or `-` for numeric values. If enabled, the filter
will negate the expression for this value.

### `Mapped`

- `map` (`array`, defaults to `[]`) An associative array mapping form values to filter
conditions. Keys are form values (strings), values are the conditions to apply.
Use `null` as a value to mean "no filter condition" (show all records).

- `default` (`string|null`, defaults to `null`) The form value that should be treated
as the default. When the default value is used, `isSearch()` returns false (no
Reset button appears).

Note: The `Mapped` filter sets `alwaysRun: true` and `filterEmpty: false` by default.
Any non-empty form value not found in the `map` will pass through directly as the
filter condition, making this useful for enum fields where only specific values
need special handling.

### `Finder`

- `finder` (`string`, defaults to the filter name) The [find type](https://book.cakephp.org/4/en/orm/retrieving-data-and-resultsets.html#custom-finder-methods) to use.
Expand Down
17 changes: 17 additions & 0 deletions src/Model/Filter/FilterMethodsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ public function compare(string $name, array $config = [])
return $this;
}

/**
* Mapped method
*
* Maps form values to filter conditions. Useful for boolean/status filters
* with a default value that shouldn't trigger isSearch().
*
* @param string $name Name
* @param array $config Config
* @return $this
*/
public function mapped(string $name, array $config = [])
{
$this->add($name, 'Search.Mapped', $config);

return $this;
}

/**
* Custom method
*
Expand Down
108 changes: 108 additions & 0 deletions src/Model/Filter/Mapped.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);

namespace Search\Model\Filter;

use Search\Manager;

/**
* Mapped Filter
*
* Maps form values to filter conditions. Useful for filters with a default
* value that shouldn't trigger isSearch().
*
* Boolean example:
* ```
* $this->mapped('enabled', [
* 'map' => ['' => true, '0' => false, '-1' => null],
* 'default' => '',
* ]);
* ```
*
* Enum example (unmapped values pass through directly):
* ```
* $this->mapped('status', [
* 'map' => ['' => 'pending', '-1' => null],
* 'default' => '',
* ]);
* // 'active', 'completed' etc. pass through as-is
* ```
*
* - Map keys are form values, map values are the condition to apply
* - `null` in map means "no filter condition" (show all)
* - `default` key specifies which value doesn't trigger isSearch()
* - Non-empty values not in map pass through directly as filter condition
*/
class Mapped extends Base
{
/**
* @var array<string, mixed>
*/
protected array $_defaultConfig = [
'fields' => null,
'map' => [],
'default' => null,
];

/**
* Constructor - sets alwaysRun and filterEmpty defaults for this filter type.
*
* @param string $name Filter name
* @param \Search\Manager $manager Search Manager
* @param array<string, mixed> $config Config
*/
public function __construct(string $name, Manager $manager, array $config = [])
{
// Mapped filter needs alwaysRun=true to apply default, filterEmpty=false to allow empty string
$config += [
'alwaysRun' => true,
'filterEmpty' => false,
];

parent::__construct($name, $manager, $config);
}

/**
* Process the mapped filter.
*
* @return bool Whether this counts as an active search
*/
public function process(): bool
{
$value = $this->value();
$map = $this->getConfig('map');
$default = $this->getConfig('default');
$fields = $this->fields();

// Normalize null to empty string for map lookup
$lookupValue = $value ?? '';
$isDefault = false;
$condition = null;

if (array_key_exists($lookupValue, $map)) {
// Value is in map - use mapped condition
$condition = $map[$lookupValue];
$isDefault = ($lookupValue === $default);
} elseif ($lookupValue !== '') {
// Non-empty value not in map - pass through directly
$condition = $lookupValue;
} elseif ($default !== null && array_key_exists($default, $map)) {
// Empty value, fall back to default
$condition = $map[$default];
$isDefault = true;
} else {
// No value, no default - skip entirely
return false;
}

// null in map = no filter condition
if ($condition !== null) {
foreach ($fields as $field) {
$this->getQuery()->where([$field => $condition]);
}
}

// Return false (not a search) only when using default
return !$isDefault;
}
}
198 changes: 198 additions & 0 deletions tests/TestCase/Model/Filter/MappedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);

namespace Search\Test\TestCase\Model\Filter;

use Cake\TestSuite\TestCase;
use Cake\Utility\Hash;
use Search\Manager;
use Search\Model\Filter\Mapped;

class MappedTest extends TestCase
{
protected array $fixtures = [
'plugin.Search.Articles',
];

/**
* Test default value is applied when no arg provided.
*
* @return void
*/
public function testProcessWithDefault()
{
$articles = $this->getTableLocator()->get('Articles');
$manager = new Manager($articles);
$filter = new Mapped('is_active', $manager, [
'map' => ['' => true, '0' => false, '-1' => null],
'default' => '',
]);
$filter->setArgs([]);
$filter->setQuery($articles->find());
$result = $filter->process();

// Default should not count as active search
$this->assertFalse($result);
$this->assertMatchesRegularExpression(
'/WHERE Articles\.is_active = \:c0$/',
$filter->getQuery()->sql(),
);
$this->assertSame(
[true],
Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value'),
);
}

/**
* Test explicit value from map is applied.
*
* @return void
*/
public function testProcessWithMappedValue()
{
$articles = $this->getTableLocator()->get('Articles');
$manager = new Manager($articles);
$filter = new Mapped('is_active', $manager, [
'map' => ['' => true, '0' => false, '-1' => null],
'default' => '',
]);
$filter->setArgs(['is_active' => '0']);
$filter->setQuery($articles->find());
$result = $filter->process();

// Explicit value should count as active search
$this->assertTrue($result);
$this->assertMatchesRegularExpression(
'/WHERE Articles\.is_active = \:c0$/',
$filter->getQuery()->sql(),
);
$this->assertSame(
[false],
Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value'),
);
}

/**
* Test null in map means no filter condition.
*
* @return void
*/
public function testProcessWithNullMapping()
{
$articles = $this->getTableLocator()->get('Articles');
$manager = new Manager($articles);
$filter = new Mapped('is_active', $manager, [
'map' => ['' => true, '0' => false, '-1' => null],
'default' => '',
]);
$filter->setArgs(['is_active' => '-1']);
$filter->setQuery($articles->find());
$result = $filter->process();

// Should count as active search
$this->assertTrue($result);
// But no WHERE clause should be added
$this->assertEmpty($filter->getQuery()->clause('where'));
}

/**
* Test unmapped non-empty values pass through directly.
*
* @return void
*/
public function testProcessUnmappedValuePassthrough()
{
$articles = $this->getTableLocator()->get('Articles');
$manager = new Manager($articles);
$filter = new Mapped('category', $manager, [
'map' => ['' => 'default_category', '-1' => null],
'default' => '',
]);
$filter->setArgs(['category' => 'custom_value']);
$filter->setQuery($articles->find());
$result = $filter->process();

// Passthrough value should count as active search
$this->assertTrue($result);
$this->assertMatchesRegularExpression(
'/WHERE Articles\.category = \:c0$/',
$filter->getQuery()->sql(),
);
$this->assertSame(
['custom_value'],
Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value'),
);
}

/**
* Test mapped values take precedence over passthrough.
*
* @return void
*/
public function testProcessMappedValueTakesPrecedence()
{
$articles = $this->getTableLocator()->get('Articles');
$manager = new Manager($articles);
$filter = new Mapped('category', $manager, [
'map' => ['' => 'default_category', '-1' => null, 'special' => 'mapped_special'],
'default' => '',
]);
// 'special' is in the map, so it should use the mapped value, not passthrough
$filter->setArgs(['category' => 'special']);
$filter->setQuery($articles->find());
$result = $filter->process();

$this->assertTrue($result);
$this->assertMatchesRegularExpression(
'/WHERE Articles\.category = \:c0$/',
$filter->getQuery()->sql(),
);
$this->assertSame(
['mapped_special'],
Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value'),
);
}

/**
* Test alwaysRun is true by default.
*
* @return void
*/
public function testAlwaysRunDefault()
{
$articles = $this->getTableLocator()->get('Articles');
$manager = new Manager($articles);
$filter = new Mapped('is_active', $manager, [
'map' => ['' => true],
'default' => '',
]);

$this->assertTrue($filter->getConfig('alwaysRun'));
$this->assertFalse($filter->getConfig('filterEmpty'));
}

/**
* Test with empty string explicitly provided as arg.
*
* @return void
*/
public function testProcessWithEmptyStringArg()
{
$articles = $this->getTableLocator()->get('Articles');
$manager = new Manager($articles);
$filter = new Mapped('is_active', $manager, [
'map' => ['' => true, '0' => false, '-1' => null],
'default' => '',
]);
$filter->setArgs(['is_active' => '']);
$filter->setQuery($articles->find());
$result = $filter->process();

// Empty string is the default, so not an active search
$this->assertFalse($result);
$this->assertMatchesRegularExpression(
'/WHERE Articles\.is_active = \:c0$/',
$filter->getQuery()->sql(),
);
}
}