From c75326b974ab963fcfc7daba88bcff4507c8684a Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Mon, 26 Feb 2024 16:35:38 +0100 Subject: [PATCH] Add casting for Finder. (#342) Add casting for Finder filter --- .phive/phars.xml | 2 +- docs/filters-and-examples.md | 4 + phpstan.neon | 2 +- src/Model/Filter/Base.php | 6 +- src/Model/Filter/Boolean.php | 2 +- src/Model/Filter/Compare.php | 4 +- src/Model/Filter/Escaper/DefaultEscaper.php | 2 +- src/Model/Filter/Escaper/SqlserverEscaper.php | 2 +- src/Model/Filter/Exists.php | 2 +- src/Model/Filter/Finder.php | 20 +- src/Model/Filter/Like.php | 2 +- src/Model/Filter/Value.php | 2 +- .../Model/Table/FinderArticlesTable.php | 36 ++++ tests/TestCase/Model/Filter/BaseTest.php | 8 +- tests/TestCase/Model/Filter/BooleanTest.php | 26 +-- tests/TestCase/Model/Filter/CallbackTest.php | 2 +- tests/TestCase/Model/Filter/ExistsTest.php | 4 +- tests/TestCase/Model/Filter/FinderTest.php | 174 +++++++++++++++++- tests/TestCase/Model/Filter/LikeTest.php | 6 +- tests/TestCase/Model/Filter/ValueTest.php | 2 +- 20 files changed, 268 insertions(+), 40 deletions(-) diff --git a/.phive/phars.xml b/.phive/phars.xml index a8aff295..17952746 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,5 +1,5 @@ - + diff --git a/docs/filters-and-examples.md b/docs/filters-and-examples.md index 6cf294cf..cef4288f 100644 --- a/docs/filters-and-examples.md +++ b/docs/filters-and-examples.md @@ -238,6 +238,10 @@ The following options are supported by all filters except `Callback` and `Finder - `options` (`array`, defaults to `[]`) Additional options to pass to the finder. +- `cast` (`array`, defaults to `[]`) Additional casts to be used on the (mapped + field values. You can use `'int'`, `'bool'`, `'float'`, etc as strings. You can also + use callable functions like `function ($value) { ... }` for more complex scenarios. + ## Filtering by `belongsToMany` and `hasMany` associations If you want to filter values related to a `belongsToMany` or `hasMany` association, diff --git a/phpstan.neon b/phpstan.neon index 1bb5f948..67a1977e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 7 + level: 8 checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false paths: diff --git a/src/Model/Filter/Base.php b/src/Model/Filter/Base.php index 334d4c8c..d33cec19 100644 --- a/src/Model/Filter/Base.php +++ b/src/Model/Filter/Base.php @@ -20,7 +20,7 @@ abstract class Base /** * Default configuration. * - * @var array + * @var array */ protected array $_defaultConfig = []; @@ -200,9 +200,9 @@ public function value(): mixed } /** - * @return array|string|null + * @return mixed */ - protected function passedValue(): string|array|null + protected function passedValue(): mixed { if (!isset($this->_args[$this->name()])) { return null; diff --git a/src/Model/Filter/Boolean.php b/src/Model/Filter/Boolean.php index 4ad7d182..a246e775 100644 --- a/src/Model/Filter/Boolean.php +++ b/src/Model/Filter/Boolean.php @@ -10,7 +10,7 @@ class Boolean extends Base /** * Default configuration. * - * @var array + * @var array */ protected array $_defaultConfig = [ 'mode' => 'OR', diff --git a/src/Model/Filter/Compare.php b/src/Model/Filter/Compare.php index 02e9dcc0..9e3701b9 100644 --- a/src/Model/Filter/Compare.php +++ b/src/Model/Filter/Compare.php @@ -10,7 +10,7 @@ class Compare extends Base /** * Default configuration. * - * @var array + * @var array */ protected array $_defaultConfig = [ 'operator' => '>=', @@ -20,7 +20,7 @@ class Compare extends Base /** * Allowed operators. * - * @var array + * @var array */ protected array $_operators = [ '>=', '<=', '<', '>', diff --git a/src/Model/Filter/Escaper/DefaultEscaper.php b/src/Model/Filter/Escaper/DefaultEscaper.php index 021a1009..93f5403b 100644 --- a/src/Model/Filter/Escaper/DefaultEscaper.php +++ b/src/Model/Filter/Escaper/DefaultEscaper.php @@ -12,7 +12,7 @@ class DefaultEscaper implements EscaperInterface /** * Default configuration. * - * @var array + * @var array */ protected array $_defaultConfig = [ 'fromWildCardAny' => '%', diff --git a/src/Model/Filter/Escaper/SqlserverEscaper.php b/src/Model/Filter/Escaper/SqlserverEscaper.php index ec2311ea..6697902c 100644 --- a/src/Model/Filter/Escaper/SqlserverEscaper.php +++ b/src/Model/Filter/Escaper/SqlserverEscaper.php @@ -8,7 +8,7 @@ class SqlserverEscaper extends DefaultEscaper /** * Default configuration. * - * @var array + * @var array */ protected array $_defaultConfig = [ 'fromWildCardAny' => '%', diff --git a/src/Model/Filter/Exists.php b/src/Model/Filter/Exists.php index 2d0af247..dd715988 100644 --- a/src/Model/Filter/Exists.php +++ b/src/Model/Filter/Exists.php @@ -10,7 +10,7 @@ class Exists extends Base /** * Default configuration. * - * @var array + * @var array */ protected array $_defaultConfig = [ 'mode' => 'OR', diff --git a/src/Model/Filter/Finder.php b/src/Model/Filter/Finder.php index 1063cc53..b8d25147 100644 --- a/src/Model/Filter/Finder.php +++ b/src/Model/Filter/Finder.php @@ -3,14 +3,17 @@ namespace Search\Model\Filter; +use Closure; + class Finder extends Base { /** - * @var array + * @var array */ protected array $_defaultConfig = [ 'map' => [], 'options' => [], + 'cast' => [], ]; /** @@ -37,6 +40,21 @@ public function process(): bool foreach ($map as $to => $from) { $args[$to] = $args[$from] ?? null; } + $casts = $this->getConfig('cast'); + foreach ($casts as $field => $toType) { + $value = $args[$field] ?? null; + if ($value === null) { + continue; + } + + if ($toType instanceof Closure) { + $value = $toType($value); + } else { + settype($value, $toType); + } + + $args[$field] = $value; + } $options = $this->getConfig('options'); $args += $options; diff --git a/src/Model/Filter/Like.php b/src/Model/Filter/Like.php index efc2e380..f27c2020 100644 --- a/src/Model/Filter/Like.php +++ b/src/Model/Filter/Like.php @@ -22,7 +22,7 @@ class Like extends Base /** * Default configuration. * - * @var array + * @var array */ protected array $_defaultConfig = [ 'before' => false, diff --git a/src/Model/Filter/Value.php b/src/Model/Filter/Value.php index 6a62d588..9f2975a4 100644 --- a/src/Model/Filter/Value.php +++ b/src/Model/Filter/Value.php @@ -12,7 +12,7 @@ class Value extends Base /** * Default configuration. * - * @var array + * @var array */ protected array $_defaultConfig = [ 'mode' => 'OR', diff --git a/tests/TestApp/Model/Table/FinderArticlesTable.php b/tests/TestApp/Model/Table/FinderArticlesTable.php index e492dc59..64423ba7 100644 --- a/tests/TestApp/Model/Table/FinderArticlesTable.php +++ b/tests/TestApp/Model/Table/FinderArticlesTable.php @@ -45,4 +45,40 @@ public function findSlugged(SelectQuery $query, string $slug): SelectQuery { return $query->where(['title' => $slug]); } + + /** + * Requires nullable slug key to be present in $options array. + * + * @param \Cake\ORM\Query\SelectQuery $query + * @param string|null $slug + * @return \Cake\ORM\Query\SelectQuery + */ + public function findSluggedNullable(SelectQuery $query, ?string $slug): SelectQuery + { + return $query->where(['title IS' => $slug]); + } + + /** + * Requires uid key to be present in $options array. + * + * @param \Cake\ORM\Query\SelectQuery $query + * @param int $uid + * @return \Cake\ORM\Query\SelectQuery + */ + public function findUser(SelectQuery $query, int $uid): SelectQuery + { + return $query->where(['user_id' => $uid]); + } + + /** + * Requires nullable uid key to be present in $options array. + * + * @param \Cake\ORM\Query\SelectQuery $query + * @param int|null $uid + * @return \Cake\ORM\Query\SelectQuery + */ + public function findUserNullable(SelectQuery $query, ?int $uid): SelectQuery + { + return $query->where(['user_id IS' => $uid]); + } } diff --git a/tests/TestCase/Model/Filter/BaseTest.php b/tests/TestCase/Model/Filter/BaseTest.php index b61547e6..3d2988c7 100644 --- a/tests/TestCase/Model/Filter/BaseTest.php +++ b/tests/TestCase/Model/Filter/BaseTest.php @@ -105,7 +105,7 @@ public function testConstructNonEmptyNameArgument($nonEmptyValue) $this->Manager, ['fields' => 'fields'] ); - $this->assertEquals($filter->name(), $nonEmptyValue); + $this->assertSame($filter->name(), $nonEmptyValue); } /** @@ -144,13 +144,13 @@ public function testValue() ); $filter->setArgs(['fields' => 'value']); - $this->assertEquals('value', $filter->value()); + $this->assertSame('value', $filter->value()); $filter->setArgs(['other_field' => 'value']); - $this->assertEquals('default', $filter->value()); + $this->assertSame('default', $filter->value()); $filter->setArgs(['fields' => ['value1', 'value2']]); - $this->assertEquals('default', $filter->value()); + $this->assertSame('default', $filter->value()); } /** diff --git a/tests/TestCase/Model/Filter/BooleanTest.php b/tests/TestCase/Model/Filter/BooleanTest.php index aa7d234b..7eee9198 100644 --- a/tests/TestCase/Model/Filter/BooleanTest.php +++ b/tests/TestCase/Model/Filter/BooleanTest.php @@ -31,7 +31,7 @@ public function testProcessWithFlagOn() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -53,7 +53,7 @@ public function testProcessWithFlagOff() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [false], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -75,7 +75,7 @@ public function testProcessWithStringFlagTrue() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -97,7 +97,7 @@ public function testProcessWithStringFlagFalse() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [false], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -119,7 +119,7 @@ public function testProcessWithBooleanFlagTrue() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -141,7 +141,7 @@ public function testProcessWithBooleanFlagFalse() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [false], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -163,7 +163,7 @@ public function testProcessWithStringFlag1() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -185,7 +185,7 @@ public function testProcessWithStringFlag0() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [false], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -207,7 +207,7 @@ public function testProcessWithIntegerFlag1() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -226,7 +226,7 @@ public function testProcessWithIntegerFlag0() '/WHERE Articles\.is_active = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [false], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -285,7 +285,7 @@ public function testProcessMultiField() '/WHERE \(Articles\.is_active = :c0 OR Articles\.other = :c1\)$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true, true], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -310,7 +310,7 @@ public function testProcessMultiFieldWithAndMode() '/WHERE \(Articles\.is_active = :c0 AND Articles\.other = :c1\)$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true, true], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -332,7 +332,7 @@ public function testProcessDefaultFallbackForDisallowedMultiValue() '/WHERE Articles\.is_active = :c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); diff --git a/tests/TestCase/Model/Filter/CallbackTest.php b/tests/TestCase/Model/Filter/CallbackTest.php index dd3046c3..16c20469 100644 --- a/tests/TestCase/Model/Filter/CallbackTest.php +++ b/tests/TestCase/Model/Filter/CallbackTest.php @@ -36,7 +36,7 @@ public function testProcess() '/WHERE title = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( ['test'], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); diff --git a/tests/TestCase/Model/Filter/ExistsTest.php b/tests/TestCase/Model/Filter/ExistsTest.php index c2131db5..f1b3ca22 100644 --- a/tests/TestCase/Model/Filter/ExistsTest.php +++ b/tests/TestCase/Model/Filter/ExistsTest.php @@ -74,7 +74,7 @@ public function testProcessWithFlagOnNotNullable() '/WHERE Articles\.number != \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [''], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -100,7 +100,7 @@ public function testProcessWithFlagOffNotNullable() '/WHERE Articles\.number = \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [''], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); diff --git a/tests/TestCase/Model/Filter/FinderTest.php b/tests/TestCase/Model/Filter/FinderTest.php index 59827b54..36f9beaa 100644 --- a/tests/TestCase/Model/Filter/FinderTest.php +++ b/tests/TestCase/Model/Filter/FinderTest.php @@ -33,7 +33,7 @@ public function testProcess() '/WHERE \(Articles\.is_active = \:c0 AND foo = \:c1\)$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( [true, 'bar'], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -60,12 +60,182 @@ public function testProcessMap() '/WHERE title = :c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( ['foo'], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); } + /** + * Tests that a custom finder that requires certain values to be cast, usually from + * string to int, float or bool. + * + * @return void + */ + public function testProcessCast() + { + $articles = $this->getTableLocator()->get('FinderArticles', [ + 'className' => '\Search\Test\TestApp\Model\Table\FinderArticlesTable', + ]); + $manager = new Manager($articles); + $filter = new Finder('user', $manager, ['cast' => ['uid' => 'int']]); + $filter->setArgs(['uid' => '1']); + $filter->setQuery($articles->find()); + $filter->process(); + + $this->assertMatchesRegularExpression( + '/WHERE user_id = :c0$/', + $filter->getQuery()->sql() + ); + $this->assertSame( + [1], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') + ); + } + + /** + * Tests that a custom finder that requires certain values to be cast, using + * a custom callable. + * + * @return void + */ + public function testProcessCastCallback() + { + $articles = $this->getTableLocator()->get('FinderArticles', [ + 'className' => '\Search\Test\TestApp\Model\Table\FinderArticlesTable', + ]); + $manager = new Manager($articles); + $options = [ + 'map' => [ + 'uid' => 'user_id', + ], + 'cast' => [ + 'uid' => function ($value) { + return (int)$value; + }, + ], + ]; + $filter = new Finder('user', $manager, $options); + $filter->setArgs(['user_id' => '1']); + $filter->setQuery($articles->find()); + $filter->process(); + + $this->assertMatchesRegularExpression( + '/WHERE user_id = :c0$/', + $filter->getQuery()->sql() + ); + $this->assertSame( + [1], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') + ); + } + + /** + * Tests that a null input does not use casting. + * + * @return void + */ + public function testProcessCastCallbackNull() + { + $articles = $this->getTableLocator()->get('FinderArticles', [ + 'className' => '\Search\Test\TestApp\Model\Table\FinderArticlesTable', + ]); + $manager = new Manager($articles); + $options = [ + 'cast' => [ + 'slug' => function ($value) { + return (string)$value; + }, + ], + ]; + $filter = new Finder('sluggedNullable', $manager, $options); + $filter->setArgs(['slug' => null]); + $filter->setQuery($articles->find()); + $filter->process(); + + $this->assertSame( + [], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') + ); + } + + /** + * Tests that a custom finder that requires certain values to be cast, using + * a custom callable and null input. In this case, the callable should return + * null if already empty string. + * + * @return void + */ + public function testProcessCastCallbackNullableString() + { + $articles = $this->getTableLocator()->get('FinderArticles', [ + 'className' => '\Search\Test\TestApp\Model\Table\FinderArticlesTable', + ]); + $manager = new Manager($articles); + $options = [ + 'cast' => [ + 'slug' => function ($value) { + if ($value === '') { + return null; + } + + return (string)$value; + }, + ], + ]; + $filter = new Finder('sluggedNullable', $manager, $options); + $filter->setArgs(['slug' => '']); + $filter->setQuery($articles->find()); + $filter->process(); + + $this->assertMatchesRegularExpression( + '/WHERE \(title\) IS NULL$/', + $filter->getQuery()->sql() + ); + $this->assertSame( + [], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') + ); + } + + /** + * Tests that a custom finder that requires certain values to be cast, using + * a custom callable and null input. In this case, the callable should return + * null if already empty string. + * + * @return void + */ + public function testProcessCastCallbackNullableInt() + { + $articles = $this->getTableLocator()->get('FinderArticles', [ + 'className' => '\Search\Test\TestApp\Model\Table\FinderArticlesTable', + ]); + $manager = new Manager($articles); + $options = [ + 'cast' => [ + 'uid' => function ($value) { + if ($value === '') { + return null; + } + + return (int)$value; + }, + ], + ]; + $filter = new Finder('userNullable', $manager, $options); + $filter->setArgs(['uid' => '']); + $filter->setQuery($articles->find()); + $filter->process(); + $this->assertMatchesRegularExpression( + '/WHERE \(user_id\) IS NULL$/', + $filter->getQuery()->sql() + ); + $this->assertSame( + [], + Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') + ); + } + /** * @return void */ diff --git a/tests/TestCase/Model/Filter/LikeTest.php b/tests/TestCase/Model/Filter/LikeTest.php index e1595760..09ed6a2d 100644 --- a/tests/TestCase/Model/Filter/LikeTest.php +++ b/tests/TestCase/Model/Filter/LikeTest.php @@ -31,7 +31,7 @@ public function testProcess() '/WHERE Articles\.title LIKE \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( ['test'], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -44,7 +44,7 @@ public function testProcess() '/WHERE Articles\.title ILIKE \:c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( ['test'], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); @@ -66,7 +66,7 @@ public function testProcessSingleValueWithAndValueMode() '/WHERE Articles\.title LIKE :c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( ['foo'], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') ); diff --git a/tests/TestCase/Model/Filter/ValueTest.php b/tests/TestCase/Model/Filter/ValueTest.php index 0ce4bfe8..c923f82d 100644 --- a/tests/TestCase/Model/Filter/ValueTest.php +++ b/tests/TestCase/Model/Filter/ValueTest.php @@ -326,7 +326,7 @@ public function testProcessNegation() '/WHERE Articles\.number != :c0$/', $filter->getQuery()->sql() ); - $this->assertEquals( + $this->assertSame( ['3'], Hash::extract($filter->getQuery()->getValueBinder()->bindings(), '{s}.value') );