diff --git a/README.md b/README.md index 22b510c..d4db77d 100644 --- a/README.md +++ b/README.md @@ -12,31 +12,60 @@ interface. Just install the package from Packagist using composer: ```bash -composer require complexheart/php-criteria +composer require complex-heart/criteria ``` ## Usage -Just import the class: +Import the class and use the fluent interface: ```php -namespace ComplexHeart\Test\Domain\Criteria; - -$criteria = Criteria::createDefault() - ->addFilterEqual('name', 'Vincent') - ->addFilterNotEqual('surname', 'Winnfield') - ->addFilterGreaterThan('money', '10000') - ->addFilterGreaterOrEqualThan('age', '35') - ->addFilterLessThan('cars', '2') - ->addFilterLessOrEqualThan('houses', '2') - ->addFilterLike('favoriteMeal', 'pork') - ->addFilterIn('boss', ['marcellus', 'mia']) - ->addFilterNotIn('hates', ['ringo', 'yolanda']) - ->withOrder(Order::createDescBy('name')) +namespace ComplexHeart\Domain\Criteria; + +// Match the users with status active and more than 7k followers and from Spain and France +$g1 = FilterGroup::create() + ->addFilterEqual('status', 1) + ->addFilterGreaterThan('followers', 7000) + ->addFilterIn('country', ['es', 'fr']); + +$criteria = Criteria::default() + ->withFilterGroup($g1) + ->withOrderBy('surname') + ->withOrderType(Order::TYPE_ASC) + ->withPageLimit(10) + ->withPageOffset(5); + +$users = $repository->match($criteria); + +// alternative, same as above +$criteria = Criteria::default() + ->withFilterGroup(FilterGroup::create() + ->addFilterEqual('status', 1) + ->addFilterGreaterThan('followers', 7000) + ->addFilterIn('country', ['es', 'fr'])) ->withOrderBy('surname') ->withOrderType(Order::TYPE_ASC) ->withPageLimit(10) - ->withPageOffset(5) + ->withPageOffset(5); + +// In SQL, we may have something like: +// WHERE status = 1 AND followers >= 700 AND country in ('es', 'fr') -$customers = $customerRepository->match($criteria); +$users = $repository->match($criteria); +``` + +A `FilterGroup` is a set of filters or conditions that must match all together (`AND`). To match one group or another +(OR), just add more `FilterGroup`. + +```php + +// Match articles with given term in title, or in tagline, or in content. +$criteria = Criteria::default() + ->withFilterGroup(FilterGroup::create()->addFilterContains('title', $term)) + ->withFilterGroup(FilterGroup::create()->addFilterContains('tagline', $term)) + ->withFilterGroup(FilterGroup::create()->addFilterContains('content', $term)) + ->withOrderBy('created_at') + ->withOrderType(Order::TYPE_ASC) + ->withPageLimit(10) + ->withPageOffset(5); ``` diff --git a/src/Criteria.php b/src/Criteria.php index 29ab23e..7861f1c 100644 --- a/src/Criteria.php +++ b/src/Criteria.php @@ -4,14 +4,17 @@ namespace ComplexHeart\Domain\Criteria; +use Closure; use ComplexHeart\Domain\Contracts\Model\ValueObject; use ComplexHeart\Domain\Model\IsValueObject; +use function Lambdish\Phunctional\map; + /** * Class Criteria * * @author Unay Santisteban - * @package ComplexHeart\SDK\Domain\Criteria + * @package ComplexHeart\Domain\Criteria */ final class Criteria implements ValueObject { @@ -20,130 +23,97 @@ final class Criteria implements ValueObject /** * Criteria constructor. * - * @param FilterGroup $filters + * @param array> $groups * @param Order $order * @param Page $page */ public function __construct( - private readonly FilterGroup $filters, + private readonly array $groups, private readonly Order $order, private readonly Page $page, ) { - $this->check(); } - public static function create(FilterGroup $filters, Order $order, Page $page): self + /** + * @param array> $groups + * @param Order $order + * @param Page $page + * @return Criteria + */ + public static function create(array $groups, Order $order, Page $page): self { - return new self($filters, $order, $page); + return new self($groups, $order, $page); } - public static function createDefault(): self + public static function default(): self { - return self::create(FilterGroup::create(), Order::none(), Page::create()); + return self::create([], Order::none(), Page::create()); } - public function withFilters(FilterGroup $filters): self + /** + * Returns a new instance of the criteria with the given FilterGroups. + * + * @param array> $groups + * @return Criteria + */ + public function withFilterGroups(array $groups): self { - return new self($filters, $this->order, $this->page); + return self::create($groups, $this->order, $this->page); + } + + /** + * Returns a new instance of the criteria adding the given FilterGroup. + * + * @param FilterGroup|Closure $group + * @return $this + */ + public function withFilterGroup(FilterGroup|Closure $group): self + { + if (is_callable($group)) { + $group = $group(new FilterGroup()); + } + + return $this->withFilterGroups(array_merge($this->groups, [$group])); } public function withOrder(Order $order): self { - return new self($this->filters, $order, $this->page); + return self::create($this->groups, $order, $this->page); } public function withOrderBy(string $field): self { - return new self($this->filters, Order::create($field, $this->orderType()), $this->page); + return self::create($this->groups, Order::create($field, $this->orderType()), $this->page); } public function withOrderType(string $type): self { - return new self($this->filters, Order::create($this->orderBy(), $type), $this->page); + return self::create($this->groups, Order::create($this->orderBy(), $type), $this->page); } public function withPage(Page $page): self { - return new self($this->filters, $this->order, $page); + return self::create($this->groups, $this->order, $page); } public function withPageOffset(int $offset): self { - return new self($this->filters, $this->order, Page::create($this->pageLimit(), $offset)); + return self::create($this->groups, $this->order, Page::create($this->pageLimit(), $offset)); } public function withPageLimit(int $limit): self { - return new self($this->filters, $this->order, Page::create($limit, $this->pageOffset())); - } - - public function filters(): FilterGroup - { - return $this->filters; - } - - public function addFilterEqual(string $field, mixed $value): self - { - $this->filters->addFilter(Filter::createEqual($field, $value)); - return $this; - } - - public function addFilterNotEqual(string $field, mixed $value): self - { - $this->filters->addFilter(Filter::createNotEqual($field, $value)); - return $this; - } - - public function addFilterGreaterThan(string $field, string $value): self - { - $this->filters->addFilter(Filter::createGreaterThan($field, $value)); - return $this; - } - - public function addFilterGreaterOrEqualThan(string $field, string $value): self - { - $this->filters->addFilter(Filter::createGreaterOrEqualThan($field, $value)); - return $this; - } - - public function addFilterLessThan(string $field, string $value): self - { - $this->filters->addFilter(Filter::createLessThan($field, $value)); - return $this; - } - - public function addFilterLessOrEqualThan(string $field, string $value): self - { - $this->filters->addFilter(Filter::createLessOrEqualThan($field, $value)); - return $this; + return self::create($this->groups, $this->order, Page::create($limit, $this->pageOffset())); } /** - * @param string $field - * @param array $value - * @return $this - */ - public function addFilterIn(string $field, array $value): self - { - $this->filters->addFilter(Filter::createIn($field, $value)); - return $this; - } - - /** - * @param string $field - * @param array $value - * @return $this + * Returns the list of group filters. + * + * @return array> */ - public function addFilterNotIn(string $field, array $value): self - { - $this->filters->addFilter(Filter::createNotIn($field, $value)); - return $this; - } - - public function addFilterLike(string $field, string $value): self + public function groups(): array { - $this->filters->addFilter(Filter::createLike($field, $value)); - return $this; + return $this->groups; } public function order(): Order @@ -178,6 +148,10 @@ public function pageLimit(): int public function __toString(): string { - return sprintf('%s#%s#%s', $this->filters, $this->order, $this->page); + $groups = join('||', map(fn(FilterGroup $group): string => $group->__toString(), $this->groups)); + $order = $this->order->__toString(); + $page = $this->page->__toString(); + + return sprintf('%s#%s#%s', $groups, $order, $page); } } diff --git a/src/Filter.php b/src/Filter.php index b9fc2dd..3502131 100644 --- a/src/Filter.php +++ b/src/Filter.php @@ -113,6 +113,21 @@ public static function createLike(string $field, mixed $value): self return self::create($field, Operator::like(), $value); } + public static function createNotLike(string $field, mixed $value): self + { + return self::create($field, Operator::notLike(), $value); + } + + public static function createContains(string $field, mixed $value): self + { + return self::create($field, Operator::contains(), $value); + } + + public static function createNotContains(string $field, mixed $value): self + { + return self::create($field, Operator::notContains(), $value); + } + /** * Retrieve the filter field value object. * diff --git a/src/FilterGroup.php b/src/FilterGroup.php index 73c4c01..3e7f491 100644 --- a/src/FilterGroup.php +++ b/src/FilterGroup.php @@ -70,6 +70,88 @@ public function addFilter(Filter $new): FilterGroup return $this; } + public function addFilterEqual(string $field, mixed $value): FilterGroup + { + $this->addFilter(Filter::createEqual($field, $value)); + return $this; + } + + public function addFilterNotEqual(string $field, mixed $value): FilterGroup + { + $this->addFilter(Filter::createNotEqual($field, $value)); + return $this; + } + + public function addFilterGreaterThan(string $field, mixed $value): FilterGroup + { + $this->addFilter(Filter::createGreaterThan($field, $value)); + return $this; + } + + public function addFilterGreaterOrEqualThan(string $field, mixed $value): FilterGroup + { + $this->addFilter(Filter::createGreaterOrEqualThan($field, $value)); + return $this; + } + + public function addFilterLessThan(string $field, mixed $value): FilterGroup + { + $this->addFilter(Filter::createLessThan($field, $value)); + return $this; + } + + public function addFilterLessOrEqualThan(string $field, mixed $value): FilterGroup + { + $this->addFilter(Filter::createLessOrEqualThan($field, $value)); + return $this; + } + + /** + * @param string $field + * @param array $value + * @return $this + */ + public function addFilterIn(string $field, array $value): FilterGroup + { + $this->addFilter(Filter::createIn($field, $value)); + return $this; + } + + /** + * @param string $field + * @param array $value + * @return $this + */ + public function addFilterNotIn(string $field, array $value): FilterGroup + { + $this->addFilter(Filter::createNotIn($field, $value)); + return $this; + } + + public function addFilterLike(string $field, string $value): FilterGroup + { + $this->addFilter(Filter::createLike($field, $value)); + return $this; + } + + public function addFilterNotLike(string $field, string $value): FilterGroup + { + $this->addFilter(Filter::createNotLike($field, $value)); + return $this; + } + + public function addFilterContains(string $field, string $value): FilterGroup + { + $this->addFilter(Filter::createContains($field, $value)); + return $this; + } + + public function addFilterNotContains(string $field, string $value): FilterGroup + { + $this->addFilter(Filter::createNotContains($field, $value)); + return $this; + } + /** * Transform the FilterGroup to a string. * @@ -77,8 +159,6 @@ public function addFilter(Filter $new): FilterGroup */ public function __toString(): string { - return $this->toBase() - ->map(fn(Filter $filter): string => $filter->__toString()) - ->join('+'); + return join('+', map(fn(Filter $filter): string => $filter->__toString(), $this)); } } diff --git a/src/Operator.php b/src/Operator.php index 5ef1b51..3431225 100644 --- a/src/Operator.php +++ b/src/Operator.php @@ -21,6 +21,9 @@ enum Operator: string case IN = 'in'; case NOT_IN = 'notIn'; case LIKE = 'like'; + case NOT_LIKE = 'notLike'; + case CONTAINS = 'contains'; + case NOT_CONTAINS = 'notContains'; public static function make(string $value): self { @@ -71,4 +74,19 @@ public static function like(): self { return self::LIKE; } + + public static function notLike(): self + { + return self::NOT_LIKE; + } + + public static function contains(): self + { + return self::CONTAINS; + } + + public static function notContains(): self + { + return self::NOT_CONTAINS; + } } diff --git a/tests/CriteriaTest.php b/tests/CriteriaTest.php index d974d65..4c0a2b8 100644 --- a/tests/CriteriaTest.php +++ b/tests/CriteriaTest.php @@ -9,13 +9,13 @@ use ComplexHeart\Domain\Criteria\Order; use ComplexHeart\Domain\Criteria\Page; -test('Change complete and partially the criteria order parameter.', function () { - $c = Criteria::createDefault() +test('Criteria should change complete and partially the criteria order parameter.', function () { + $c = Criteria::default() ->withOrder(Order::createDescBy('name')); - expect($c->orderBy())->toBe('name'); - expect($c->orderType())->toBe('desc'); - expect($c->order()->isNone())->toBe(false); + expect($c->orderBy())->toBe('name') + ->and($c->orderType())->toBe('desc') + ->and($c->order()->isNone())->toBe(false); $c = $c->withOrder(Order::createAscBy('name')); expect($c->orderType())->toBe('asc'); @@ -24,53 +24,63 @@ expect($c->orderBy())->toBe('surname'); $c = $c->withOrderType(Order::TYPE_ASC); - expect($c->orderType())->toBe('asc'); - - expect($c->order())->toBeInstanceOf(Order::class); + expect($c->orderType())->toBe('asc') + ->and($c->order())->toBeInstanceOf(Order::class); }); -test('Change complete and partially the criteria page parameter.', function () { - $c = Criteria::createDefault() +test('Criteria should change complete and partially the criteria page parameter.', function () { + $c = Criteria::default() ->withPage(Page::create(100, 50)); - expect($c->pageLimit())->toBe(100); - expect($c->pageOffset())->toBe(50); + expect($c->pageLimit())->toBe(100) + ->and($c->pageOffset())->toBe(50); $c = $c->withPageLimit(42); expect($c->pageLimit())->toBe(42); $c = $c->withPageOffset(10); - expect($c->pageOffset())->toBe(10); - - expect($c->page())->toBeInstanceOf(Page::class); + expect($c->pageOffset())->toBe(10) + ->and($c->page())->toBeInstanceOf(Page::class); }); -test('Change the complete filter object from criteria.', function () { - $c = Criteria::createDefault() - ->withFilters(FilterGroup::createFromArray([['field', '=', 'value']])); - - expect($c->filters())->toHaveCount(1); +test('Criteria should change the complete filter groups.', function () { + $c = Criteria::default() + ->withFilterGroups([ + FilterGroup::createFromArray([['field', '=', 'one']]) + ]) + ->withFilterGroups([ + FilterGroup::createFromArray([['field', '=', 'two']]), + FilterGroup::createFromArray([['field', '=', 'three']]), + FilterGroup::createFromArray([['field', '=', 'four']]), + ]); + + expect($c->groups())->toHaveCount(3); }); -test('Add filters to criteria object.', function () { - $c = Criteria::createDefault() - ->addFilterEqual('name', 'Vincent') - ->addFilterNotEqual('surname', 'winnfield') - ->addFilterGreaterThan('money', '10000') - ->addFilterGreaterOrEqualThan('age', '35') - ->addFilterLessThan('cars', '2') - ->addFilterLessOrEqualThan('houses', '2') - ->addFilterLike('favoriteMeal', 'pork') - ->addFilterIn('boss', ['marcellus', 'mia']) - ->addFilterNotIn('hates', ['ringo', 'yolanda']); - - expect($c->filters())->toHaveCount(9); +test('Criteria should add or filter group to criteria object.', function () { + $c = Criteria::default() + ->withFilterGroup(fn(FilterGroup $g): FilterGroup => $g + ->addFilterEqual('name', 'Vincent') + ->addFilterEqual('status', 'deceased') + ) + ->withFilterGroup(fn(FilterGroup $g): FilterGroup => $g + ->addFilterEqual('name', 'Jules') + ->addFilterEqual('deceased', 'alive') + ); + + $groups = $c->groups(); + + expect($groups)->toHaveCount(2) + ->and($groups[0])->toHaveCount(2) + ->and($groups[1])->toHaveCount(2); }); -test('Criteria object is correctly serialized to string.', function () { - $c = Criteria::createDefault() - ->addFilterEqual('name', 'Vincent') - ->addFilterGreaterOrEqualThan('age', '35') +test('Criteria should be correctly serialized to string.', function () { + $c = Criteria::default() + ->withFilterGroup(fn(FilterGroup $group): FilterGroup => $group + ->addFilterEqual('name', 'Vincent') + ->addFilterGreaterOrEqualThan('age', '35') + ) ->withPageLimit(100) ->withPageOffset(0) ->withOrderBy('name') diff --git a/tests/FilterGroupTest.php b/tests/FilterGroupTest.php index f0654d6..fd31ab3 100644 --- a/tests/FilterGroupTest.php +++ b/tests/FilterGroupTest.php @@ -6,18 +6,18 @@ use ComplexHeart\Domain\Criteria\FilterGroup; use ComplexHeart\Domain\Criteria\Operator; -test('FilterGroup only accept Filter instances.', function () { +test('FilterGroup should only accept Filter instances.', function () { expect(FilterGroup::create()) ->toBeInstanceOf(FilterGroup::class) ->toHaveCount(0); }); -test('Create FilterGroup from primitive array of values', function () { +test('FilterGroup should be created from primitive array of values.', function () { expect(FilterGroup::createFromArray([['field', '=', 'value']])) ->toHaveCount(1); }); -test('Create FilterGroup without duplicate filters.', function () { +test('FilterGroup should be created without duplicated filters.', function () { $filters = [ ['field', '=', 'value'], ['field', '=', 'value'], @@ -30,3 +30,21 @@ expect($g) ->toHaveCount(2); }); + +test('FilterGroup should add new filter with fluent interface.', function () { + $filters = FilterGroup::create() + ->addFilterEqual('name', 'Vincent') + ->addFilterNotEqual('surname', 'Winnfield') + ->addFilterGreaterThan('money', 10000) + ->addFilterGreaterOrEqualThan('age', 35) + ->addFilterLessThan('cars', 2) + ->addFilterLessOrEqualThan('houses', 2) + ->addFilterLike('bio', 'pork lover') + ->addFilterNotLike('bio', 'dog lover') + ->addFilterContains('name', 'nce') + ->addFilterNotContains('name', 'les') + ->addFilterIn('boss', ['Marcellus', 'Mia']) + ->addFilterNotIn('hates', ['Ringo', 'Yolanda']); + + expect($filters)->toHaveCount(12); +});