diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f72aa..0f5cfb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ - Chg #225: Rename classes: `All` to `AndX`, `Any` to `OrX`. Remove `Group` class (@vjik) - Chg #226: Refactor filter classes to use readonly properties instead of getters (@vjik) - New #213: Add `nextPage()` and `previousPage()` methods to `PaginatorInterface` (@samdark) +- New #200: Add matching mode parameter to `Like` filter (@samdark, @vjik) ## 1.0.1 January 25, 2023 diff --git a/src/Reader/Filter/Like.php b/src/Reader/Filter/Like.php index 8180bea..91b3bc3 100644 --- a/src/Reader/Filter/Like.php +++ b/src/Reader/Filter/Like.php @@ -19,11 +19,13 @@ final class Like implements FilterInterface * - `null` - depends on implementation; * - `true` - case-sensitive; * - `false` - case-insensitive. + * @param LikeMode $mode Matching mode. */ public function __construct( public readonly string $field, public readonly string $value, public readonly ?bool $caseSensitive = null, + public readonly LikeMode $mode = LikeMode::Contains, ) { } } diff --git a/src/Reader/Filter/LikeMode.php b/src/Reader/Filter/LikeMode.php new file mode 100644 index 0000000..fb62a21 --- /dev/null +++ b/src/Reader/Filter/LikeMode.php @@ -0,0 +1,26 @@ +caseSensitive === true - ? str_contains($itemValue, $filter->value) - : mb_stripos($itemValue, $filter->value) !== false; + if ($filter->value === '') { + return true; + } + + return match ($filter->mode) { + LikeMode::Contains => $this->matchContains($itemValue, $filter->value, $filter->caseSensitive), + LikeMode::StartsWith => $this->matchStartsWith($itemValue, $filter->value, $filter->caseSensitive), + LikeMode::EndsWith => $this->matchEndsWith($itemValue, $filter->value, $filter->caseSensitive), + }; + } + + private function matchContains(string $value, string $search, ?bool $caseSensitive): bool + { + return $caseSensitive === true + ? str_contains($value, $search) + : mb_stripos($value, $search) !== false; + } + + private function matchStartsWith(string $value, string $search, ?bool $caseSensitive): bool + { + return $caseSensitive === true + ? str_starts_with($value, $search) + : mb_stripos($value, $search) === 0; + } + + private function matchEndsWith(string $value, string $search, ?bool $caseSensitive): bool + { + if ($caseSensitive === true) { + return str_ends_with($value, $search); + } + + return mb_strtolower(mb_substr($value, -mb_strlen($search))) === mb_strtolower($search); } } diff --git a/tests/Common/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php b/tests/Common/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php index 5e7dbe3..cc9dbae 100644 --- a/tests/Common/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php +++ b/tests/Common/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\Data\Reader\Filter\Like; +use Yiisoft\Data\Reader\Filter\LikeMode; use Yiisoft\Data\Tests\Common\Reader\BaseReaderTestCase; abstract class BaseReaderWithLikeTestCase extends BaseReaderTestCase @@ -34,4 +35,37 @@ public function testWithReader( $reader = $this->getReader()->withFilter(new Like($field, $value, $caseSensitive)); $this->assertFixtures($expectedFixtureIndexes, $reader->read()); } + + public static function dataWithReaderAndMode(): array + { + return [ + // CONTAINS mode + 'contains: same case, case sensitive: null' => ['email', 'ed@be', null, LikeMode::Contains, [2]], // Expects: seed@beat + 'contains: different case, case sensitive: false' => ['email', 'SEED@', false, LikeMode::Contains, [2]], // Expects: seed@beat + + // STARTS_WITH mode + 'starts with: same case, case sensitive: null' => ['email', 'seed@', null, LikeMode::StartsWith, [2]], // Expects: seed@beat + 'starts with: different case, case sensitive: false' => ['email', 'SEED@', false, LikeMode::StartsWith, [2]], // Expects: seed@beat + 'starts with: middle part (should fail)' => ['email', 'ed@be', null, LikeMode::StartsWith, []], // Expects: no matches + + // ENDS_WITH mode + 'ends with: same case, case sensitive: null' => ['email', '@beat', null, LikeMode::EndsWith, [2]], // Expects: seed@beat + 'ends with: different case, case sensitive: false' => ['email', '@BEAT', false, LikeMode::EndsWith, [2]], // Expects: seed@beat + 'ends with: middle part (should fail)' => ['email', 'ed@be', null, LikeMode::EndsWith, []], // Expects: no matches + ]; + } + + #[DataProvider('dataWithReaderAndMode')] + public function testWithReaderAndMode( + string $field, + string $value, + bool|null $caseSensitive, + LikeMode $mode, + array $expectedFixtureIndexes, + ): void { + $reader = $this->getReader()->withFilter(new Like($field, $value, $caseSensitive, $mode)); + $actualData = $reader->read(); + // Assert that we get the expected fixtures based on the filter criteria + $this->assertFixtures($expectedFixtureIndexes, $actualData); + } } diff --git a/tests/Reader/Iterable/FilterHandler/LikeHandlerTest.php b/tests/Reader/Iterable/FilterHandler/LikeHandlerTest.php index 0e16b82..87beeda 100644 --- a/tests/Reader/Iterable/FilterHandler/LikeHandlerTest.php +++ b/tests/Reader/Iterable/FilterHandler/LikeHandlerTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\Data\Reader\Filter\Like; +use Yiisoft\Data\Reader\Filter\LikeMode; use Yiisoft\Data\Reader\Iterable\Context; use Yiisoft\Data\Reader\Iterable\FilterHandler\LikeHandler; use Yiisoft\Data\Reader\Iterable\ValueReader\FlatValueReader; @@ -48,4 +49,79 @@ public function testMatch(bool $expected, array $item, string $field, string $va $context = new Context([], new FlatValueReader()); $this->assertSame($expected, $filterHandler->match($item, new Like($field, $value, $caseSensitive), $context)); } + + public static function matchWithModeDataProvider(): array + { + return [ + // "Contains" mode + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Cat', null, LikeMode::Contains], + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'cat', false, LikeMode::Contains], + [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'cat', true, LikeMode::Contains], + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Great', null, LikeMode::Contains], + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Fighter', null, LikeMode::Contains], + + // "StartsWith" mode + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Great', null, LikeMode::StartsWith], + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'great', false, LikeMode::StartsWith], + [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'great', true, LikeMode::StartsWith], + [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Cat', null, LikeMode::StartsWith], + [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Fighter', null, LikeMode::StartsWith], + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Great Cat', null, LikeMode::StartsWith], + [true, ['id' => 1, 'value' => 'Привет мир'], 'value', 'Привет', null, LikeMode::StartsWith], + [true, ['id' => 1, 'value' => '🙁🙂🙁'], 'value', '🙁', null, LikeMode::StartsWith], + + // "EndsWith" mode + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Fighter', null, LikeMode::EndsWith], + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'fighter', false, LikeMode::EndsWith], + [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'fighter', true, LikeMode::EndsWith], + [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Great', null, LikeMode::EndsWith], + [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Cat', null, LikeMode::EndsWith], + [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Cat Fighter', null, LikeMode::EndsWith], + [true, ['id' => 1, 'value' => 'Привет мир'], 'value', 'мир', null, LikeMode::EndsWith], + [true, ['id' => 1, 'value' => '🙁🙂🙁'], 'value', '🙁', null, LikeMode::EndsWith], + [true, ['id' => 1, 'value' => 'das Öl'], 'value', 'öl', false, LikeMode::EndsWith], + + // Edge cases + [true, ['id' => 1, 'value' => 'test'], 'value', '', null, LikeMode::Contains], + [true, ['id' => 1, 'value' => 'test'], 'value', '', null, LikeMode::StartsWith], + [true, ['id' => 1, 'value' => 'test'], 'value', '', null, LikeMode::EndsWith], + [true, ['id' => 1, 'value' => 'test'], 'value', 'test', null, LikeMode::StartsWith], + [true, ['id' => 1, 'value' => 'test'], 'value', 'test', null, LikeMode::EndsWith], + [false, ['id' => 1, 'value' => 'test'], 'value', 'longer', null, LikeMode::StartsWith], + [false, ['id' => 1, 'value' => 'test'], 'value', 'longer', null, LikeMode::EndsWith], + [true, ['id' => 1, 'value' => 'Çağrı'], 'value', 'çağ', false, LikeMode::StartsWith], + [false, ['id' => 1, 'value' => '🌟'], 'value', 'xyz🌟', false, LikeMode::EndsWith], + [false, ['id' => 1, 'value' => 'é🎉'], 'value', 'abcé🎉', false, LikeMode::EndsWith], + [true, ['id' => 1, 'value' => 'aliİ'], 'value', 'İ', false, LikeMode::EndsWith], + ]; + } + + #[DataProvider('matchWithModeDataProvider')] + public function testMatchWithMode( + bool $expected, + array $item, + string $field, + string $value, + ?bool $caseSensitive, + LikeMode $mode, + ): void { + $handler = new LikeHandler(); + $context = new Context([], new FlatValueReader()); + $filter = new Like($field, $value, $caseSensitive, $mode); + + $this->assertSame( + $expected, + $handler->match($item, $filter, $context) + ); + } + + public function testConstructorDefaultMode(): void + { + $handler = new LikeHandler(); + $context = new Context([], new FlatValueReader()); + $item = ['id' => 1, 'value' => 'Great Cat Fighter']; + + $this->assertTrue($handler->match($item, new Like('value', 'Cat'), $context)); + $this->assertFalse($handler->match($item, new Like('value', 'Hello'), $context)); + } }