├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── composer.json
├── rector.php
├── src
├── Paginator
│ ├── InvalidPageException.php
│ ├── KeysetFilterContext.php
│ ├── KeysetPaginator.php
│ ├── OffsetPaginator.php
│ ├── PageNotFoundException.php
│ ├── PageToken.php
│ └── PaginatorInterface.php
├── Processor
│ ├── DataProcessorException.php
│ └── DataProcessorInterface.php
├── Reader
│ ├── CountableDataInterface.php
│ ├── DataReaderException.php
│ ├── DataReaderInterface.php
│ ├── Filter
│ │ ├── All.php
│ │ ├── Any.php
│ │ ├── Between.php
│ │ ├── Compare.php
│ │ ├── Equals.php
│ │ ├── EqualsNull.php
│ │ ├── GreaterThan.php
│ │ ├── GreaterThanOrEqual.php
│ │ ├── Group.php
│ │ ├── In.php
│ │ ├── LessThan.php
│ │ ├── LessThanOrEqual.php
│ │ ├── Like.php
│ │ └── Not.php
│ ├── FilterHandlerInterface.php
│ ├── FilterInterface.php
│ ├── FilterableDataInterface.php
│ ├── Iterable
│ │ ├── FilterHandler
│ │ │ ├── AllHandler.php
│ │ │ ├── AnyHandler.php
│ │ │ ├── BetweenHandler.php
│ │ │ ├── EqualsHandler.php
│ │ │ ├── EqualsNullHandler.php
│ │ │ ├── GreaterThanHandler.php
│ │ │ ├── GreaterThanOrEqualHandler.php
│ │ │ ├── InHandler.php
│ │ │ ├── LessThanHandler.php
│ │ │ ├── LessThanOrEqualHandler.php
│ │ │ ├── LikeHandler.php
│ │ │ └── NotHandler.php
│ │ ├── IterableDataReader.php
│ │ └── IterableFilterHandlerInterface.php
│ ├── LimitableDataInterface.php
│ ├── OffsetableDataInterface.php
│ ├── OrderHelper.php
│ ├── ReadableDataInterface.php
│ ├── Sort.php
│ └── SortableDataInterface.php
└── Writer
│ ├── DataWriterException.php
│ ├── DataWriterInterface.php
│ ├── DeletableInterface.php
│ └── WriteableInterface.php
└── tests
├── Common
├── FixtureTrait.php
└── Reader
│ ├── BaseReaderTestCase.php
│ └── ReaderWithFilter
│ ├── BaseReaderWithAllTestCase.php
│ ├── BaseReaderWithAnyTestCase.php
│ ├── BaseReaderWithBetweenTestCase.php
│ ├── BaseReaderWithEqualsNullTestCase.php
│ ├── BaseReaderWithEqualsTestCase.php
│ ├── BaseReaderWithGreaterThanOrEqualTestCase.php
│ ├── BaseReaderWithGreaterThanTestCase.php
│ ├── BaseReaderWithInTestCase.php
│ ├── BaseReaderWithLessThanOrEqualTestCase.php
│ ├── BaseReaderWithLessThanTestCase.php
│ ├── BaseReaderWithLikeTestCase.php
│ └── BaseReaderWithNotTestCase.php
├── Paginator
├── KeysetPaginatorTest.php
├── OffsetPaginatorTest.php
└── PageTokenAssertTrait.php
├── Reader
├── Filter
│ ├── CompareTest.php
│ ├── InTest.php
│ └── LikeTest.php
├── Iterable
│ ├── FilterHandler
│ │ ├── AllHandlerTest.php
│ │ ├── AnyHandlerTest.php
│ │ ├── BetweenHandlerTest.php
│ │ ├── EqualsHandlerTest.php
│ │ ├── EqualsNullHandlerTest.php
│ │ ├── GreaterThanHandlerTest.php
│ │ ├── GreaterThanOrEqualHandlerTest.php
│ │ ├── InHandlerTest.php
│ │ ├── LessThanHandlerTest.php
│ │ ├── LessThanOrEqualHandlerTest.php
│ │ ├── LikeHandlerTest.php
│ │ └── NotHandlerTest.php
│ ├── IterableDataReaderTest.php
│ └── ReaderWithFilter
│ │ ├── ReaderTrait.php
│ │ ├── ReaderWithAllTest.php
│ │ ├── ReaderWithAnyTest.php
│ │ ├── ReaderWithBetweenTest.php
│ │ ├── ReaderWithEqualsNullTest.php
│ │ ├── ReaderWithEqualsTest.php
│ │ ├── ReaderWithGreaterThanOrEqualTest.php
│ │ ├── ReaderWithGreaterThanTest.php
│ │ ├── ReaderWithInTest.php
│ │ ├── ReaderWithLessThanOrEqualTest.php
│ │ ├── ReaderWithLessThanTest.php
│ │ ├── ReaderWithLikeTest.php
│ │ └── ReaderWithNotTest.php
└── SortTest.php
├── Support
├── Car.php
├── CustomFilter
│ ├── Digital.php
│ ├── DigitalHandler.php
│ └── FilterWithoutHandler.php
├── MutationDataReader.php
└── StubOffsetData.php
└── TestCase.php
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Yii Data Change Log
2 |
3 | ## 2.0.0 under development
4 |
5 | - New #150: Extract `withLimit()` from `ReadableDataInterface` into `LimitableDataInterface` (@vjik)
6 | - Enh #150: `PaginatorInterface` now extends `ReadableDataInterface` (@vjik)
7 | - Chg #151: Rename `isRequired()` method in `PaginatorInterface` to `isPaginationRequired()` (@vjik)
8 | - New #153, #154: Add `KeysetPaginator::withFilterCallback()` method that allows set closure for preparing filter passed to
9 | the data reader (@vjik)
10 | - New #153: Add `Compare::withValue()` method (@vjik)
11 | - Chg #154: Raise the minimum required PHP version to 8.1 (@vjik)
12 | - Bug #155: Fix `Sort` configuration preparation (@vjik)
13 | - Bug #155: Fix same named order fields in `Sort` were not overriding previous ones (@vjik)
14 | - New #158: Add methods `PaginatorInterface::isSortable()` and `PaginatorInterface::withSort()` (@vjik)
15 | - New #164: Add methods `PaginatorInterface::isFilterable()` and `PaginatorInterface::withFilter()` (@vjik)
16 | - Chg #159: Replace `withNextPageToken()` and `withPreviousPageToken()` of `PaginatorInterface` with `withToken()`,
17 | `getNextPageToken()` with `getNextToken()`, `getPreviousPageToken()` with `getPreviousToken()`, and add `getToken()`.
18 | These methods use new `PageToken` class (@vjik)
19 | - New #160: Add `Sort::hasFieldInConfig()` method that checks for the presence of a field in the sort config (@vjik)
20 | - New #161: Add `PageNotFoundException` (@vjik)
21 | - Chg #161: `PaginatorInterface::getCurrentPageSize()` returns 0 instead throws exception if page specified is
22 | not found (@vjik)
23 | - Enh #161: Add more specified psalm annotations to `CountableDataInterface::count()`,
24 | `PaginatorInterface::getCurrentPageSize()` and `OffsetPaginator::getTotalItems()` (@vjik)
25 | - Chg #165: Simplify `FilterInterface` and `FilterHandlerInterface` (@vjik)
26 | - Chg #166: Remove `EqualsEmpty` filter (@vjik)
27 | - New #176: Add `OrderHelper` (@vjik)
28 | - New #173, #184, #220: Add `$caseSensitive` parameter to `Like` filter to control whether the search must be
29 | case-sensitive or not (@arogachev, @vjik)
30 | - Enh #187, #196: Limit set in data reader is now taken into account by offset paginator. Keyset paginator throws
31 | an exception in this case (@samdark, @vjik)
32 | - Chg #187: Add `FilterableDataInterface::getFilter()`, `LimitableDataInterface::getLimit()`,
33 | `OffsetableDataInterface::getOffset()` (@samdark)
34 | - Chg #187: `LimitableDataInterface::withLimit()` now accepts `null` to indicate "no limit". `0` is now a valid limit
35 | value meaning `return nothing` (@samdark)
36 | - Chg #163: Rename `FilterableDataInterface::withFilterHandlers()` to `FilterableDataInterface::withAddedFilterHandlers()` (@samdark)
37 | - Enh #190: Use `str_contains` for case-sensitive match in `LikeHandler` (@samdark)
38 | - Enh #194: Improve psalm annotations in `LimitableDataInterface` (@vjik)
39 | - Bug #195: Fix invalid count in `IterableDataReader` when limit or/and offset used (@vjik)
40 | - Enh #201: Disable sorting when limit is set explicitly in a paginator (@samdark)
41 | - Enh #202: Check that correct sort is passed to `withSort()` of keyset paginator (@samdark)
42 | - Enh #207: More specific Psalm type for `OffsetPaginator::withCurrentPage()` (@samdark)
43 | - Enh #214: Improved interface hierarchy (@samdark)
44 | - Enh #207: More specific Psalm type for `OffsetPaginator::withCurrentPage()` parameter (@samdark)
45 | - Enh #210: More specific Psalm type for `PaginatorInterface::getPageSize()` result (@vjik)
46 | - Chg #219: Narrow type of page size in `PaginatorInterface::withPageSize()` method to positive int by psalm
47 | annotation and throw `InvalidArgumentException` if non-positive value is passed (@vjik)
48 | - Enh #219: Add page to message of `PageNotFoundException` exception (@vjik)
49 | - Chg #219: Throw `InvalidArgumentException` instead of `PaginatorException` in `OffsetPaginator::withCurrentPage()`
50 | method when non-positive value is passed (@vjik)
51 | - Chg #219: Don't check correctness of current page in `PaginatorInterface::isOnLastPage()` method (@vjik)
52 | - Chg #219: Rename `PaginatorException` to `InvalidPageException` (@vjik)
53 | - Chg #211, #221: Change PHP constraint in `composer.json` to `8.1 - 8.4` (@vjik)
54 |
55 | ## 1.0.1 January 25, 2023
56 |
57 | - Chg #137: In `FilterableDataInterface::withFilterHandlers()` rename parameter `$iterableFilterHandlers` to
58 | `$filterHandlers` (@vjik)
59 |
60 | ## 1.0.0 January 14, 2023
61 |
62 | - Initial release.
63 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright © 2008 by Yii Software ()
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions
6 | are met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in
12 | the documentation and/or other materials provided with the
13 | distribution.
14 | * Neither the name of Yii Software nor the names of its
15 | contributors may be used to endorse or promote products derived
16 | from this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29 | POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Yii Data
6 |
7 |
8 |
9 | [](https://packagist.org/packages/yiisoft/data)
10 | [](https://packagist.org/packages/yiisoft/data)
11 | [](https://github.com/yiisoft/data/actions/workflows/build.yml)
12 | [](https://codecov.io/gh/yiisoft/data)
13 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/data/master)
14 | [](https://github.com/yiisoft/data/actions?query=workflow%3A%22static+analysis%22)
15 | [](https://shepherd.dev/github/yiisoft/data)
16 | [](https://shepherd.dev/github/yiisoft/data)
17 |
18 | The package provides generic data abstractions. The aim is to hide a storage aspect from the operations of reading,
19 | writing and processing data.
20 |
21 | Features are:
22 |
23 | - Data reader abstraction with counting, sorting, limiting and offsetting, reading criteria filter and post-filter.
24 | - Pagination abstraction along with offset and keyset implementations.
25 | - Data writer abstraction.
26 | - Data processor abstraction.
27 |
28 | ## Requirements
29 |
30 | - PHP 8.1 or higher.
31 |
32 | ## Installation
33 |
34 | The package could be installed with [Composer](https://getcomposer.org):
35 |
36 | ```shell
37 | composer require yiisoft/data
38 | ```
39 |
40 | ## General usage
41 |
42 | ## Concepts
43 |
44 | - Each data set consists of items.
45 | - Each item has multiple named fields.
46 | - All items in a data set have the same structure.
47 |
48 | ## Reading data
49 |
50 | Data reader aim is to read data from a storage such as database, array or API and convert it to a simple iterator of
51 | field => value items.
52 |
53 | ```php
54 | $reader = new MyDataReader(...);
55 | $result = $reader->read();
56 | ```
57 |
58 | Result is `iterable` so you can use `foreach` on it. If you need an array, it could be achieved the following way:
59 |
60 | ```php
61 | // using is foreach
62 | foreach ($result as $item) {
63 | // ...
64 | }
65 |
66 | // preparing array
67 | $dataArray = $result instanceof \Traversable ? iterator_to_array($result, true) : (array)$result;
68 | ```
69 |
70 | ### Limiting the number of items to read
71 |
72 | You can limit the number of items in an iterator:
73 |
74 | ```php
75 | $reader = (new MyDataReader(...))->withLimit(10);
76 | foreach ($reader->read() as $item) {
77 | // ...
78 | }
79 | ```
80 |
81 | ### Counting the total number of items
82 |
83 | To know total number of items in a data reader implementing `CountableDataInterface`:
84 |
85 | ```php
86 | $reader = new MyDataReader(...);
87 | $total = count($reader);
88 | ```
89 |
90 | ### Filtering
91 |
92 | Filtering of data could be done in two steps:
93 |
94 | 1. Forming a criteria for getting the data. That is done by "filter."
95 | 2. Post-filtering data by iteration and checking each item.
96 | That is done by `IterableDataReader` with filters.
97 |
98 | Whenever possible, it is best to stick to using criteria because usually it gives much better performance.
99 |
100 | To filter data in a data reader implementing `FilterableDataInterface` you need to supply filter to
101 | `withFilter()` method:
102 |
103 | ```php
104 | $filter = new All(
105 | new GreaterThan('id', 3),
106 | new Like('name', 'agent')
107 | );
108 |
109 | $reader = (new MyDataReader(...))
110 | ->withFilter($filter);
111 |
112 | $data = $reader->read();
113 | ```
114 |
115 | Filter could be composed with:
116 |
117 | - `All`
118 | - `Any`
119 | - `Between`
120 | - `Equals`
121 | - `EqualsNull`
122 | - `GreaterThan`
123 | - `GreaterThanOrEqual`
124 | - `ILike`
125 | - `In`
126 | - `LessThan`
127 | - `LessThanOrEqual`
128 | - `Like`
129 | - `Not`
130 |
131 | #### Filtering with arrays
132 |
133 | The `All` and `Any` filters have a `withCriteriaArray()` method, which allows you to define filters with arrays.
134 |
135 | ```php
136 | $dataReader->withFilter((new All())->withCriteriaArray([
137 | ['=', 'id', 88],
138 | [
139 | 'or',
140 | [
141 | ['=', 'color', 'red'],
142 | ['=', 'state', 1],
143 | ]
144 | ]
145 | ]));
146 | ```
147 |
148 | #### Implementing your own filter
149 |
150 | To have your own filter:
151 |
152 | - Implement at least `FilterInterface`, which includes:
153 | - `getOperator()` method that returns a string that represents a filter operation.
154 | - `toArray()` method that returns an array with filtering parameters.
155 | - If you want to create a filter handler for a specific data reader type, then you need to implement at least
156 | `FilterHandlerInterface`. It has a single `getOperator()` method that returns a string representing a filter operation.
157 | Also, each data reader specifies an extended interface required for handling or building the operation.
158 | *For example, `IterableDataFilter` defines `IterableFilterHandlerInterface`, which contains additional `match()`
159 | method to execute a filter on PHP variables.*
160 |
161 | You can add your own filter handlers to the data reader using the `withFilterHandlers()` method. You can add any filter
162 | handler to the reader. If a reader is not able to use a filter, filter is ignored.
163 |
164 | ```php
165 | // own filter for filtering
166 | class OwnNotTwoFilter implements FilterInterface
167 | {
168 | private $field;
169 |
170 | public function __construct($field)
171 | {
172 | $this->field = $field;
173 | }
174 | public static function getOperator(): string
175 | {
176 | return 'my!2';
177 | }
178 | public function toArray(): array
179 | {
180 | return [static::getOperator(), $this->field];
181 | }
182 | }
183 |
184 | // own iterable filter handler for matching
185 | class OwnIterableNotTwoFilterHandler implements IterableFilterHandlerInterface
186 | {
187 | public function getOperator(): string
188 | {
189 | return OwnNotTwoFilter::getOperator();
190 | }
191 |
192 | public function match(array $item, array $arguments, array $filterHandlers): bool
193 | {
194 | [$field] = $arguments;
195 | return $item[$field] != 2;
196 | }
197 | }
198 |
199 | // and using it on a data reader
200 | $filter = new All(
201 | new LessThan('id', 8),
202 | new OwnNotTwoFilter('id'),
203 | );
204 |
205 | $reader = (new MyDataReader(...))
206 | ->withFilter($filter)
207 | ->withFilterHandlers(
208 | new OwnIterableNotTwoFilter()
209 | new OwnSqlNotTwoFilter() // for SQL
210 | // and for any supported readers...
211 | );
212 |
213 | $data = $reader->read();
214 | ```
215 |
216 | ### Sorting
217 |
218 | To sort data in a data reader implementing `SortableDataInterface` you need to supply a sort object to
219 | `withSort()` method:
220 |
221 | ```php
222 | $sorting = Sort::only([
223 | 'id',
224 | 'name'
225 | ]);
226 |
227 | $sorting = $sorting->withOrder(['name' => 'asc']);
228 | // or $sorting = $sorting->withOrderString('name');
229 |
230 | $reader = (new MyDataReader(...))
231 | ->withSort($sorting);
232 |
233 | $data = $reader->read();
234 | ```
235 |
236 | The goal of the `Sort` is to map logical fields sorting to real data set fields sorting and form a criteria for the data
237 | reader. Logical fields are the ones user operates with. Real fields are the ones actually present in a data set.
238 | Such a mapping helps when you need to sort by a single logical field that, in fact, consists of multiple fields
239 | in the underlying data set. For example, you provide a user with a username which consists of first name and last name
240 | fields in the actual data set.
241 |
242 | To get a `Sort` instance, you can use either `Sort::only()` or `Sort::any()`. `Sort::only()` ignores user-specified order
243 | for logical fields that have no configuration. `Sort::any()` uses user-specified logical field name and order directly
244 | for fields that have no configuration.
245 |
246 | Either way, you pass a config array that specifies which logical fields should be order-able and, optionally, details on
247 | how these should map to real fields order.
248 |
249 | The current order to apply is specified via `withOrder()` where you supply an array with keys corresponding to logical
250 | field names and values correspond to order (`asc` or `desc`). Alternatively `withOrderString()` can be used. In this case,
251 | ordering is represented as a single string containing comma separate logical field names. If the name is prefixed by `-`,
252 | ordering direction is set to `desc`.
253 |
254 | ### Skipping some items
255 |
256 | In case you need to skip some items from the beginning of data reader implementing `OffsetableDataInterface`:
257 |
258 | ```php
259 | $reader = (new MyDataReader(...))->withOffset(10);
260 | ```
261 |
262 | ### Implementing your own data reader
263 |
264 | To have your own data reader, you need to implement at least `DataReaderInteface`. It has a single `read()`
265 | method that returns iterable representing a set of items.
266 |
267 | Additional interfaces could be implemented to support different pagination types, ordering and filtering:
268 |
269 | - `CountableDataInterface` - allows getting total number of items in data reader.
270 | - `FilterableDataInterface` - allows returning subset of items based on criteria.
271 | - `LimitableDataInterface` - allows returning limited subset of items.
272 | - `SortableDataInterface` - allows sorting by one or multiple fields.
273 | - `OffsetableDataInterface` - allows skipping first N items when reading data.
274 |
275 | Note that when implementing these, methods, instead of modifying data, should only define criteria that is later used
276 | in `read()` to affect what data is returned.
277 |
278 | ## Pagination
279 |
280 | Pagination allows getting a limited subset of data that is both handy for displaying items page by page and for getting
281 | acceptable performance on big data sets.
282 |
283 | There are two types of pagination provided: traditional offset pagination and keyset pagination.
284 |
285 | ### Offset pagination
286 |
287 | Offset pagination is a common pagination method that selects OFFSET + LIMIT items and then skips OFFSET items.
288 |
289 | Advantages:
290 |
291 | - The total number of pages is available
292 | - Can get to specific page
293 | - Data can be unordered
294 | - The limit set in the data reader is taken into account
295 |
296 | Disadvantages:
297 |
298 | - Performance degrades with page number increase
299 | - Insertions or deletions in the middle of the data are making results inconsistent
300 |
301 | Usage is the following:
302 |
303 | ```php
304 | $reader = (new MyDataReader(...));
305 |
306 | $paginator = (new OffsetPaginator($dataReader))
307 | ->withPageSize(10)
308 | ->withCurrentPage(2);
309 |
310 |
311 | $total = $paginator->getTotalPages();
312 | $data = $paginator->read();
313 | ```
314 |
315 | ### Keyset pagination
316 |
317 | Keyset pagination is an alternative pagination method that is good for infinite scrolling and "load more." It is selecting
318 | LIMIT items that have key field greater or lesser (depending on the sorting) than the value specified.
319 |
320 | Advantages:
321 |
322 | - Performance doesn't depend on page number
323 | - Consistent results regardless of insertions and deletions
324 |
325 | Disadvantages:
326 |
327 | - The total number of pages is not available
328 | - Can't get to specific page, only "previous" and "next"
329 | - Data cannot be unordered
330 | - The limit set in the data reader leads to an exception
331 |
332 | Usage is the following:
333 |
334 | ```php
335 | $sort = Sort::only(['id', 'name'])->withOrderString('id');
336 |
337 | $dataReader = (new MyDataReader(...))
338 | ->withSort($sort);
339 |
340 | $paginator = (new KeysetPaginator($dataReader))
341 | ->withPageSize(10)
342 | ->withToken(PageToken::next('13'));
343 | ```
344 |
345 | When displaying first page ID (or another field name to paginate by) of the item displayed last is used with `withNextPageToken()`
346 | to get next page.
347 |
348 | ## Writing data
349 |
350 | ```php
351 | $writer = new MyDataWriter(...);
352 | $writer->write($arrayOfItems);
353 | ```
354 |
355 | ## Processing data
356 |
357 | ```php
358 | $processor = new MyDataProcessor(...);
359 | $processor->process($arrayOfItems);
360 | ```
361 |
362 | ## Documentation
363 |
364 | - Guide: [Português - Brasil](docs/guide/pt-BR/README.md)
365 | - [Internals](docs/internals.md)
366 |
367 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for
368 | that. You may also check out other [Yii Community Resources](https://www.yiiframework.com/community).
369 |
370 | ## License
371 |
372 | The Yii Data is free software. It is released under the terms of the BSD License.
373 | Please see [`LICENSE`](./LICENSE.md) for more information.
374 |
375 | Maintained by [Yii Software](https://www.yiiframework.com/).
376 |
377 | ## Support the project
378 |
379 | [](https://opencollective.com/yiisoft)
380 |
381 | ## Follow updates
382 |
383 | [](https://www.yiiframework.com/)
384 | [](https://twitter.com/yiiframework)
385 | [](https://t.me/yii3en)
386 | [](https://www.facebook.com/groups/yiitalk)
387 | [](https://yiiframework.com/go/slack)
388 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yiisoft/data",
3 | "type": "library",
4 | "description": "Data providers, pagination and related abstractions",
5 | "keywords": [
6 | "data provider",
7 | "data reader",
8 | "data writer",
9 | "data processor",
10 | "filter",
11 | "pagination"
12 | ],
13 | "homepage": "https://www.yiiframework.com/",
14 | "license": "BSD-3-Clause",
15 | "support": {
16 | "issues": "https://github.com/yiisoft/data/issues?state=open",
17 | "source": "https://github.com/yiisoft/data",
18 | "forum": "https://www.yiiframework.com/forum/",
19 | "wiki": "https://www.yiiframework.com/wiki/",
20 | "irc": "ircs://irc.libera.chat:6697/yii",
21 | "chat": "https://t.me/yii3en"
22 | },
23 | "funding": [
24 | {
25 | "type": "opencollective",
26 | "url": "https://opencollective.com/yiisoft"
27 | },
28 | {
29 | "type": "github",
30 | "url": "https://github.com/sponsors/yiisoft"
31 | }
32 | ],
33 | "require": {
34 | "php": "8.1 - 8.4",
35 | "ext-mbstring": "*",
36 | "yiisoft/arrays": "^3.0"
37 | },
38 | "require-dev": {
39 | "maglnet/composer-require-checker": "^4.7.1",
40 | "phpunit/phpunit": "^10.5.46",
41 | "rector/rector": "^2.0.15",
42 | "roave/infection-static-analysis-plugin": "^1.35",
43 | "spatie/phpunit-watcher": "^1.24",
44 | "vimeo/psalm": "^5.26.1 || ^6.10.3"
45 | },
46 | "autoload": {
47 | "psr-4": {
48 | "Yiisoft\\Data\\": "src"
49 | }
50 | },
51 | "autoload-dev": {
52 | "psr-4": {
53 | "Yiisoft\\Data\\Tests\\": "tests"
54 | }
55 | },
56 | "config": {
57 | "sort-packages": true,
58 | "allow-plugins": {
59 | "infection/extension-installer": true,
60 | "composer/package-versions-deprecated": true
61 | }
62 | },
63 | "scripts": {
64 | "test": "phpunit --testdox --no-interaction",
65 | "test-watch": "phpunit-watcher watch"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | paths([
13 | __DIR__ . '/src',
14 | __DIR__ . '/tests',
15 | ]);
16 |
17 | // register a single rule
18 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class);
19 |
20 | // define sets of rules
21 | $rectorConfig->sets([
22 | LevelSetList::UP_TO_PHP_81,
23 | ]);
24 |
25 | $rectorConfig->skip([
26 | ClosureToArrowFunctionRector::class,
27 | ReadOnlyPropertyRector::class,
28 | ]);
29 | };
30 |
--------------------------------------------------------------------------------
/src/Paginator/InvalidPageException.php:
--------------------------------------------------------------------------------
1 |
48 | *
49 | * @psalm-type FilterCallback = Closure(GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual,KeysetFilterContext):FilterInterface
50 | */
51 | final class KeysetPaginator implements PaginatorInterface
52 | {
53 | /**
54 | * Data reader being paginated.
55 | *
56 | * @psalm-var ReadableDataInterface&LimitableDataInterface&FilterableDataInterface&SortableDataInterface
57 | */
58 | private ReadableDataInterface $dataReader;
59 |
60 | /**
61 | * @var int Maximum number of items per page.
62 | * @psalm-var positive-int
63 | */
64 | private int $pageSize = self::DEFAULT_PAGE_SIZE;
65 | private ?PageToken $token = null;
66 | private ?string $currentFirstValue = null;
67 | private ?string $currentLastValue = null;
68 |
69 | /**
70 | * @var bool Whether there is a previous page.
71 | */
72 | private bool $hasPreviousPage = false;
73 |
74 | /**
75 | * @var bool Whether there is next page.
76 | */
77 | private bool $hasNextPage = false;
78 |
79 | /**
80 | * @psalm-var FilterCallback|null
81 | */
82 | private ?Closure $filterCallback = null;
83 |
84 | /**
85 | * Reader cache against repeated scans.
86 | * See more {@see __clone()} and {@see initialize()}.
87 | *
88 | * @psalm-var null|array
89 | */
90 | private ?array $readCache = null;
91 |
92 | /**
93 | * @param ReadableDataInterface $dataReader Data reader being paginated.
94 | * @psalm-param ReadableDataInterface&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader
95 | * @psalm-suppress DocblockTypeContradiction Needed to allow validating `$dataReader`
96 | */
97 | public function __construct(ReadableDataInterface $dataReader)
98 | {
99 | if (!$dataReader instanceof FilterableDataInterface) {
100 | throw new InvalidArgumentException(sprintf(
101 | 'Data reader should implement "%s" to be used with keyset paginator.',
102 | FilterableDataInterface::class,
103 | ));
104 | }
105 |
106 | if (!$dataReader instanceof SortableDataInterface) {
107 | throw new InvalidArgumentException(sprintf(
108 | 'Data reader should implement "%s" to be used with keyset paginator.',
109 | SortableDataInterface::class,
110 | ));
111 | }
112 |
113 | if (!$dataReader instanceof LimitableDataInterface) {
114 | throw new InvalidArgumentException(sprintf(
115 | 'Data reader should implement "%s" to be used with keyset paginator.',
116 | LimitableDataInterface::class,
117 | ));
118 | }
119 |
120 | if ($dataReader->getLimit() !== null) {
121 | throw new InvalidArgumentException('Limited data readers are not supported by keyset pagination.');
122 | }
123 |
124 | $sort = $dataReader->getSort();
125 | $this->assertSort($sort);
126 |
127 | $this->dataReader = $dataReader;
128 | }
129 |
130 | public function __clone()
131 | {
132 | $this->readCache = null;
133 | $this->hasPreviousPage = false;
134 | $this->hasNextPage = false;
135 | $this->currentFirstValue = null;
136 | $this->currentLastValue = null;
137 | }
138 |
139 | public function withToken(?PageToken $token): static
140 | {
141 | $new = clone $this;
142 | $new->token = $token;
143 | return $new;
144 | }
145 |
146 | public function getToken(): ?PageToken
147 | {
148 | return $this->token;
149 | }
150 |
151 | public function withPageSize(int $pageSize): static
152 | {
153 | /** @psalm-suppress DocblockTypeContradiction We don't believe in psalm types */
154 | if ($pageSize < 1) {
155 | throw new InvalidArgumentException('Page size should be at least 1.');
156 | }
157 |
158 | $new = clone $this;
159 | $new->pageSize = $pageSize;
160 | return $new;
161 | }
162 |
163 | /**
164 | * Returns a new instance with defined closure for preparing data reader filters.
165 | *
166 | * @psalm-param FilterCallback|null $callback Closure with signature:
167 | *
168 | * ```php
169 | * function(
170 | * GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual $filter,
171 | * KeysetFilterContext $context
172 | * ): FilterInterface
173 | * ```
174 | */
175 | public function withFilterCallback(?Closure $callback): self
176 | {
177 | $new = clone $this;
178 | $new->filterCallback = $callback;
179 | return $new;
180 | }
181 |
182 | /**
183 | * Reads items of the page.
184 | *
185 | * This method uses the read cache to prevent duplicate reads from the data source. See more {@see resetInternal()}.
186 | */
187 | public function read(): iterable
188 | {
189 | if ($this->readCache !== null) {
190 | return $this->readCache;
191 | }
192 |
193 | /** @var Sort $sort */
194 | $sort = $this->dataReader->getSort();
195 | /** @infection-ignore-all Any value more than one in the line below will be ignored in `readData()` method */
196 | $dataReader = $this->dataReader->withLimit($this->pageSize + 1);
197 |
198 | if ($this->token?->isPrevious === true) {
199 | $sort = $this->reverseSort($sort);
200 | $dataReader = $dataReader->withSort($sort);
201 | }
202 |
203 | if ($this->token !== null) {
204 | $dataReader = $dataReader->withFilter($this->getFilter($sort));
205 | $this->hasPreviousPage = $this->previousPageExist($dataReader, $sort);
206 | }
207 |
208 | $data = $this->readData($dataReader, $sort);
209 |
210 | if ($this->token?->isPrevious === true) {
211 | $data = $this->reverseData($data);
212 | }
213 |
214 | return $this->readCache = $data;
215 | }
216 |
217 | public function readOne(): array|object|null
218 | {
219 | foreach ($this->read() as $item) {
220 | return $item;
221 | }
222 |
223 | return null;
224 | }
225 |
226 | public function getPageSize(): int
227 | {
228 | return $this->pageSize;
229 | }
230 |
231 | public function getCurrentPageSize(): int
232 | {
233 | $this->initialize();
234 | return count($this->readCache);
235 | }
236 |
237 | public function getPreviousToken(): ?PageToken
238 | {
239 | return $this->isOnFirstPage()
240 | ? null
241 | : ($this->currentFirstValue === null ? null : PageToken::previous($this->currentFirstValue));
242 | }
243 |
244 | public function getNextToken(): ?PageToken
245 | {
246 | return $this->isOnLastPage()
247 | ? null
248 | : ($this->currentLastValue === null ? null : PageToken::next($this->currentLastValue));
249 | }
250 |
251 | public function isSortable(): bool
252 | {
253 | return true;
254 | }
255 |
256 | public function withSort(?Sort $sort): static
257 | {
258 | $this->assertSort($sort);
259 |
260 | $new = clone $this;
261 | $new->dataReader = $this->dataReader->withSort($sort);
262 | return $new;
263 | }
264 |
265 | public function getSort(): ?Sort
266 | {
267 | return $this->dataReader->getSort();
268 | }
269 |
270 | public function isFilterable(): bool
271 | {
272 | return true;
273 | }
274 |
275 | public function withFilter(FilterInterface $filter): static
276 | {
277 | $new = clone $this;
278 | $new->dataReader = $this->dataReader->withFilter($filter);
279 | return $new;
280 | }
281 |
282 | public function isOnFirstPage(): bool
283 | {
284 | if ($this->token === null) {
285 | return true;
286 | }
287 |
288 | $this->initialize();
289 | return !$this->hasPreviousPage;
290 | }
291 |
292 | public function isOnLastPage(): bool
293 | {
294 | $this->initialize();
295 | return !$this->hasNextPage;
296 | }
297 |
298 | public function isPaginationRequired(): bool
299 | {
300 | return !$this->isOnFirstPage() || !$this->isOnLastPage();
301 | }
302 |
303 | /**
304 | * @psalm-assert array $this->readCache
305 | */
306 | private function initialize(): void
307 | {
308 | if ($this->readCache !== null) {
309 | return;
310 | }
311 |
312 | $cache = [];
313 |
314 | foreach ($this->read() as $key => $value) {
315 | $cache[$key] = $value;
316 | }
317 |
318 | $this->readCache = $cache;
319 | }
320 |
321 | /**
322 | * @psalm-param ReadableDataInterface $dataReader
323 | * @psalm-return array
324 | */
325 | private function readData(ReadableDataInterface $dataReader, Sort $sort): array
326 | {
327 | $data = [];
328 | [$field] = $this->getFieldAndSortingFromSort($sort);
329 |
330 | foreach ($dataReader->read() as $key => $item) {
331 | if ($this->currentFirstValue === null) {
332 | $this->currentFirstValue = (string) ArrayHelper::getValue($item, $field);
333 | }
334 |
335 | if (count($data) === $this->pageSize) {
336 | $this->hasNextPage = true;
337 | } else {
338 | $this->currentLastValue = (string) ArrayHelper::getValue($item, $field);
339 | $data[$key] = $item;
340 | }
341 | }
342 |
343 | return $data;
344 | }
345 |
346 | /**
347 | * @psalm-param array $data
348 | * @psalm-return array
349 | */
350 | private function reverseData(array $data): array
351 | {
352 | [$this->currentFirstValue, $this->currentLastValue] = [$this->currentLastValue, $this->currentFirstValue];
353 | [$this->hasPreviousPage, $this->hasNextPage] = [$this->hasNextPage, $this->hasPreviousPage];
354 | return array_reverse($data, true);
355 | }
356 |
357 | /**
358 | * @psalm-param ReadableDataInterface&LimitableDataInterface&FilterableDataInterface&SortableDataInterface $dataReader
359 | */
360 | private function previousPageExist(ReadableDataInterface $dataReader, Sort $sort): bool
361 | {
362 | $reverseFilter = $this->getReverseFilter($sort);
363 |
364 | return !empty($dataReader->withFilter($reverseFilter)->readOne());
365 | }
366 |
367 | private function getFilter(Sort $sort): FilterInterface
368 | {
369 | /**
370 | * @psalm-var PageToken $this->token The code calling this method must ensure that page token is not null.
371 | */
372 | $value = $this->token->value;
373 | [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
374 |
375 | $filter = $sorting === SORT_ASC ? new GreaterThan($field, $value) : new LessThan($field, $value);
376 | if ($this->filterCallback === null) {
377 | return $filter;
378 | }
379 |
380 | return ($this->filterCallback)(
381 | $filter,
382 | new KeysetFilterContext(
383 | $field,
384 | $value,
385 | $sorting,
386 | false,
387 | )
388 | );
389 | }
390 |
391 | private function getReverseFilter(Sort $sort): FilterInterface
392 | {
393 | /**
394 | * @psalm-var PageToken $this->token The code calling this method must ensure that page token is not null.
395 | */
396 | $value = $this->token->value;
397 | [$field, $sorting] = $this->getFieldAndSortingFromSort($sort);
398 |
399 | $filter = $sorting === SORT_ASC ? new LessThanOrEqual($field, $value) : new GreaterThanOrEqual($field, $value);
400 | if ($this->filterCallback === null) {
401 | return $filter;
402 | }
403 |
404 | return ($this->filterCallback)(
405 | $filter,
406 | new KeysetFilterContext(
407 | $field,
408 | $value,
409 | $sorting,
410 | true,
411 | )
412 | );
413 | }
414 |
415 | private function reverseSort(Sort $sort): Sort
416 | {
417 | $order = $sort->getOrder();
418 |
419 | foreach ($order as &$sorting) {
420 | $sorting = $sorting === 'asc' ? 'desc' : 'asc';
421 | }
422 |
423 | return $sort->withOrder($order);
424 | }
425 |
426 | /**
427 | * @psalm-return array{0: string, 1: int}
428 | */
429 | private function getFieldAndSortingFromSort(Sort $sort): array
430 | {
431 | $order = $sort->getOrder();
432 |
433 | return [
434 | (string) key($order),
435 | reset($order) === 'asc' ? SORT_ASC : SORT_DESC,
436 | ];
437 | }
438 |
439 | private function assertSort(?Sort $sort): void
440 | {
441 | if ($sort === null) {
442 | throw new InvalidArgumentException('Data sorting should be configured to work with keyset pagination.');
443 | }
444 |
445 | if (empty($sort->getOrder())) {
446 | throw new InvalidArgumentException('Data should be always sorted to work with keyset pagination.');
447 | }
448 | }
449 | }
450 |
--------------------------------------------------------------------------------
/src/Paginator/OffsetPaginator.php:
--------------------------------------------------------------------------------
1 |
42 | */
43 | final class OffsetPaginator implements PaginatorInterface
44 | {
45 | /**
46 | * @var PageToken Current page token
47 | */
48 | private PageToken $token;
49 |
50 | /**
51 | * @var int Maximum number of items per page.
52 | * @psalm-var positive-int
53 | */
54 | private int $pageSize = self::DEFAULT_PAGE_SIZE;
55 |
56 | /**
57 | * Data reader being paginated.
58 | *
59 | * @psalm-var ReadableDataInterface&LimitableDataInterface&OffsetableDataInterface&CountableDataInterface
60 | */
61 | private ReadableDataInterface $dataReader;
62 |
63 | /**
64 | * @param ReadableDataInterface $dataReader Data reader being paginated.
65 | * @psalm-param ReadableDataInterface&LimitableDataInterface&OffsetableDataInterface&CountableDataInterface $dataReader
66 | * @psalm-suppress DocblockTypeContradiction Needed to allow validating `$dataReader`
67 | */
68 | public function __construct(ReadableDataInterface $dataReader)
69 | {
70 | if (!$dataReader instanceof OffsetableDataInterface) {
71 | throw new InvalidArgumentException(sprintf(
72 | 'Data reader should implement "%s" in order to be used with offset paginator.',
73 | OffsetableDataInterface::class,
74 | ));
75 | }
76 |
77 | if (!$dataReader instanceof CountableDataInterface) {
78 | throw new InvalidArgumentException(sprintf(
79 | 'Data reader should implement "%s" in order to be used with offset paginator.',
80 | CountableDataInterface::class,
81 | ));
82 | }
83 |
84 | if (!$dataReader instanceof LimitableDataInterface) {
85 | throw new InvalidArgumentException(sprintf(
86 | 'Data reader should implement "%s" in order to be used with offset paginator.',
87 | LimitableDataInterface::class,
88 | ));
89 | }
90 |
91 | $this->dataReader = $dataReader;
92 | $this->token = PageToken::next('1');
93 | }
94 |
95 | public function withToken(?PageToken $token): static
96 | {
97 | if ($token === null) {
98 | $page = 1;
99 | } else {
100 | $page = (int) $token->value;
101 | if ($page < 1) {
102 | throw new InvalidPageException('Current page should be at least 1.');
103 | }
104 | }
105 |
106 | return $this->withCurrentPage($page);
107 | }
108 |
109 | public function withPageSize(int $pageSize): static
110 | {
111 | /** @psalm-suppress DocblockTypeContradiction We don't believe in psalm types */
112 | if ($pageSize < 1) {
113 | throw new InvalidArgumentException('Page size should be at least 1.');
114 | }
115 |
116 | $new = clone $this;
117 | $new->pageSize = $pageSize;
118 | return $new;
119 | }
120 |
121 | /**
122 | * Get a new instance with the given current page number set.
123 | *
124 | * @param int $page Page number.
125 | *
126 | * @throws InvalidArgumentException If page is not a positive number.
127 | *
128 | * @return self New instance.
129 | *
130 | * @psalm-param positive-int $page
131 | */
132 | public function withCurrentPage(int $page): self
133 | {
134 | /** @psalm-suppress DocblockTypeContradiction */
135 | if ($page < 1) {
136 | throw new InvalidArgumentException('Current page should be at least 1.');
137 | }
138 |
139 | $new = clone $this;
140 | $new->token = PageToken::next((string) $page);
141 | return $new;
142 | }
143 |
144 | public function getToken(): PageToken
145 | {
146 | return $this->token;
147 | }
148 |
149 | public function getNextToken(): ?PageToken
150 | {
151 | return $this->isOnLastPage() ? null : PageToken::next((string) ($this->getCurrentPage() + 1));
152 | }
153 |
154 | public function getPreviousToken(): ?PageToken
155 | {
156 | return $this->isOnFirstPage() ? null : PageToken::next((string) ($this->getCurrentPage() - 1));
157 | }
158 |
159 | public function getPageSize(): int
160 | {
161 | return $this->pageSize;
162 | }
163 |
164 | /**
165 | * Get the current page number.
166 | *
167 | * @return int Current page number.
168 | * @psalm-return positive-int
169 | */
170 | public function getCurrentPage(): int
171 | {
172 | /** @var positive-int */
173 | return (int) $this->token->value;
174 | }
175 |
176 | public function getCurrentPageSize(): int
177 | {
178 | $pages = $this->getInternalTotalPages();
179 |
180 | if ($pages === 1) {
181 | return $this->getTotalItems();
182 | }
183 |
184 | $currentPage = $this->getCurrentPage();
185 |
186 | if ($currentPage < $pages) {
187 | return $this->pageSize;
188 | }
189 |
190 | if ($currentPage === $pages) {
191 | /** @psalm-var positive-int Because the total items number is more than offset */
192 | return $this->getTotalItems() - $this->getOffset();
193 | }
194 |
195 | return 0;
196 | }
197 |
198 | /**
199 | * Get offset for the current page, that is the number of items to skip before the current page is reached.
200 | *
201 | * @return int Offset.
202 | */
203 | public function getOffset(): int
204 | {
205 | return $this->pageSize * ($this->getCurrentPage() - 1);
206 | }
207 |
208 | /**
209 | * Get total number of items in the whole data reader being paginated.
210 | *
211 | * @return int Total items number.
212 | *
213 | * @psalm-return non-negative-int
214 | */
215 | public function getTotalItems(): int
216 | {
217 | $count = $this->dataReader->count();
218 |
219 | $dataReaderLimit = $this->dataReader->getLimit();
220 | if ($dataReaderLimit !== null && $count > $dataReaderLimit) {
221 | return $dataReaderLimit;
222 | }
223 |
224 | return $count;
225 | }
226 |
227 | /**
228 | * Get total number of pages in a data reader being paginated.
229 | *
230 | * @return int Total pages number.
231 | */
232 | public function getTotalPages(): int
233 | {
234 | return (int) ceil($this->getTotalItems() / $this->pageSize);
235 | }
236 |
237 | /**
238 | * @psalm-assert-if-true SortableDataInterface $this->dataReader
239 | */
240 | public function isSortable(): bool
241 | {
242 | if ($this->dataReader instanceof LimitableDataInterface && $this->dataReader->getLimit() !== null) {
243 | return false;
244 | }
245 |
246 | return $this->dataReader instanceof SortableDataInterface;
247 | }
248 |
249 | public function withSort(?Sort $sort): static
250 | {
251 | if (!$this->isSortable()) {
252 | throw new LogicException('Changing sorting is not supported.');
253 | }
254 |
255 | $new = clone $this;
256 | $new->dataReader = $this->dataReader->withSort($sort);
257 | return $new;
258 | }
259 |
260 | public function getSort(): ?Sort
261 | {
262 | return $this->dataReader instanceof SortableDataInterface ? $this->dataReader->getSort() : null;
263 | }
264 |
265 | /**
266 | * @psalm-assert-if-true FilterableDataInterface $this->dataReader
267 | */
268 | public function isFilterable(): bool
269 | {
270 | return $this->dataReader instanceof FilterableDataInterface;
271 | }
272 |
273 | public function withFilter(FilterInterface $filter): static
274 | {
275 | if (!$this->isFilterable()) {
276 | throw new LogicException('Changing filtering is not supported.');
277 | }
278 |
279 | $new = clone $this;
280 | $new->dataReader = $this->dataReader->withFilter($filter);
281 | return $new;
282 | }
283 |
284 | /**
285 | * @psalm-return Generator
286 | */
287 | public function read(): iterable
288 | {
289 | $currentPage = $this->getCurrentPage();
290 | if ($currentPage > $this->getInternalTotalPages()) {
291 | throw new PageNotFoundException($currentPage);
292 | }
293 |
294 | $limit = $this->pageSize;
295 | $dataReaderLimit = $this->dataReader->getLimit();
296 |
297 | if ($dataReaderLimit !== null && ($this->getOffset() + $this->pageSize) > $dataReaderLimit) {
298 | /** @psalm-var non-negative-int $limit */
299 | $limit = $dataReaderLimit - $this->getOffset();
300 | }
301 |
302 | yield from $this->dataReader
303 | ->withLimit($limit)
304 | ->withOffset($this->getOffset())
305 | ->read();
306 | }
307 |
308 | public function readOne(): array|object|null
309 | {
310 | $limit = 1;
311 |
312 | $dataReaderLimit = $this->dataReader->getLimit();
313 | if ($dataReaderLimit !== null && ($this->getOffset() + 1) > $dataReaderLimit) {
314 | $limit = 0;
315 | }
316 |
317 | return $this->dataReader
318 | ->withLimit($limit)
319 | ->withOffset($this->getOffset())
320 | ->readOne();
321 | }
322 |
323 | public function isOnFirstPage(): bool
324 | {
325 | return $this->token->value === '1';
326 | }
327 |
328 | public function isOnLastPage(): bool
329 | {
330 | return $this->getCurrentPage() >= $this->getInternalTotalPages();
331 | }
332 |
333 | public function isPaginationRequired(): bool
334 | {
335 | return $this->getTotalPages() > 1;
336 | }
337 |
338 | /**
339 | * @psalm-return positive-int
340 | */
341 | private function getInternalTotalPages(): int
342 | {
343 | return max(1, $this->getTotalPages());
344 | }
345 | }
346 |
--------------------------------------------------------------------------------
/src/Paginator/PageNotFoundException.php:
--------------------------------------------------------------------------------
1 |
23 | */
24 | interface PaginatorInterface extends ReadableDataInterface
25 | {
26 | /**
27 | * Page size that is used in case it is not set explicitly.
28 | *
29 | * @psalm-suppress MissingClassConstType
30 | */
31 | public const DEFAULT_PAGE_SIZE = 10;
32 |
33 | /**
34 | * Get a new instance with page token.
35 | *
36 | * @param PageToken|null $token Page token. `Null` if current page is first.
37 | *
38 | * @throws InvalidPageException If page token is incorrect.
39 | *
40 | * @return static New instance.
41 | *
42 | * @see PageToken
43 | */
44 | public function withToken(?PageToken $token): static;
45 |
46 | /**
47 | * Get a new instance with a page size set.
48 | *
49 | * @param int $pageSize Maximum number of items per page.
50 | *
51 | * @throws InvalidArgumentException If page size is not a positive number.
52 | *
53 | * @return static New instance.
54 | *
55 | * @psalm-param positive-int $pageSize
56 | */
57 | public function withPageSize(int $pageSize): static;
58 |
59 | /**
60 | * @return PageToken|null Current page token or `null` if not set.
61 | */
62 | public function getToken(): ?PageToken;
63 |
64 | /**
65 | * Get token for the next page.
66 | *
67 | * @return PageToken|null Page token for the next page. `null` if the current page is last.
68 | */
69 | public function getNextToken(): ?PageToken;
70 |
71 | /**
72 | * Get token for the previous page.
73 | *
74 | * @return PageToken|null Page token for the previous page. `null` if current page is first.
75 | */
76 | public function getPreviousToken(): ?PageToken;
77 |
78 | /**
79 | * Get the maximum number of items per page.
80 | *
81 | * Note that there could be less current page items.
82 | *
83 | * @see getCurrentPageSize()
84 | *
85 | * @return int Page size.
86 | *
87 | * @psalm-return positive-int
88 | */
89 | public function getPageSize(): int;
90 |
91 | /**
92 | * Get number of items at the current page.
93 | *
94 | * Note that it is an actual number of items, not the limit.
95 | *
96 | * @see getPageSize()
97 | *
98 | * @return int Current page size.
99 | *
100 | * @psalm-return non-negative-int
101 | */
102 | public function getCurrentPageSize(): int;
103 |
104 | /**
105 | * @return bool Whether changing sorting via {@see withSorting()} is supported.
106 | */
107 | public function isSortable(): bool;
108 |
109 | /**
110 | * Get a new instance with a sorting set.
111 | *
112 | * @param Sort|null $sort Sorting criteria or null for no sorting.
113 | *
114 | * @throws LogicException When changing sorting isn't supported.
115 | * @return static New instance.
116 | */
117 | public function withSort(?Sort $sort): static;
118 |
119 | /**
120 | * Get current sort object.
121 | *
122 | * @return Sort|null Current sort object or null if no sorting is used.
123 | */
124 | public function getSort(): ?Sort;
125 |
126 | /**
127 | * @return bool Whether changing filter via {@see withFilter()} is supported.
128 | */
129 | public function isFilterable(): bool;
130 |
131 | /**
132 | * Returns new instance with data reading criteria set.
133 | *
134 | * @param FilterInterface $filter Data reading criteria.
135 | *
136 | * @throws LogicException When changing filter isn't supported.
137 | * @return static New instance.
138 | */
139 | public function withFilter(FilterInterface $filter): static;
140 |
141 | /**
142 | * Get an iterator that could be used to read currently active page items.
143 | *
144 | * @throws PageNotFoundException If the page specified isn't found.
145 | *
146 | * @return iterable Iterator with items for the current page.
147 | * @psalm-return iterable
148 | */
149 | public function read(): iterable;
150 |
151 | /**
152 | * Get whether the current page is the last one.
153 | *
154 | * @return bool Whether the current page is the last one.
155 | */
156 | public function isOnLastPage(): bool;
157 |
158 | /**
159 | * Get whether the current page is the first one.
160 | *
161 | * @return bool Whether the current page is the first one.
162 | */
163 | public function isOnFirstPage(): bool;
164 |
165 | /**
166 | * Check that there is more than a single page so pagination is necessary.
167 | *
168 | * @return bool Whether pagination is required.
169 | */
170 | public function isPaginationRequired(): bool;
171 | }
172 |
--------------------------------------------------------------------------------
/src/Processor/DataProcessorException.php:
--------------------------------------------------------------------------------
1 | $items
20 | *
21 | * @throws DataProcessorException If there is an error while processing items.
22 | *
23 | * @return iterable Processed items iterator.
24 | * @psalm-return iterable
25 | */
26 | public function process(iterable $items): iterable;
27 | }
28 |
--------------------------------------------------------------------------------
/src/Reader/CountableDataInterface.php:
--------------------------------------------------------------------------------
1 |
23 | * @extends OffsetableDataInterface
24 | * @extends SortableDataInterface
25 | * @extends FilterableDataInterface
26 | * @extends IteratorAggregate
27 | */
28 | interface DataReaderInterface extends
29 | LimitableDataInterface,
30 | OffsetableDataInterface,
31 | CountableDataInterface,
32 | SortableDataInterface,
33 | FilterableDataInterface,
34 | IteratorAggregate
35 | {
36 | }
37 |
--------------------------------------------------------------------------------
/src/Reader/Filter/All.php:
--------------------------------------------------------------------------------
1 | withFilter(
12 | * new All(
13 | * new GreaterThan('id', 88),
14 | * new Equals('state', 2),
15 | * new Like('name', 'eva'),
16 | * )
17 | * );
18 | * ```
19 | */
20 | final class All extends Group
21 | {
22 | }
23 |
--------------------------------------------------------------------------------
/src/Reader/Filter/Any.php:
--------------------------------------------------------------------------------
1 | withFilter(
12 | * new Any(
13 | * new GreaterThan('id', 88),
14 | * new Equals('state', 2),
15 | * )
16 | * );
17 | * ```
18 | */
19 | final class Any extends Group
20 | {
21 | }
22 |
--------------------------------------------------------------------------------
/src/Reader/Filter/Between.php:
--------------------------------------------------------------------------------
1 | field;
31 | }
32 |
33 | public function getMinValue(): float|DateTimeInterface|bool|int|string
34 | {
35 | return $this->minValue;
36 | }
37 |
38 | public function getMaxValue(): float|DateTimeInterface|bool|int|string
39 | {
40 | return $this->maxValue;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Reader/Filter/Compare.php:
--------------------------------------------------------------------------------
1 | field;
28 | }
29 |
30 | public function getValue(): float|DateTimeInterface|bool|int|string
31 | {
32 | return $this->value;
33 | }
34 |
35 | /**
36 | * @param bool|DateTimeInterface|float|int|string $value Value to compare to.
37 | */
38 | final public function withValue(bool|DateTimeInterface|float|int|string $value): static
39 | {
40 | $new = clone $this;
41 | $new->value = $value;
42 | return $new;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Reader/Filter/Equals.php:
--------------------------------------------------------------------------------
1 | field;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Reader/Filter/GreaterThan.php:
--------------------------------------------------------------------------------
1 | filters = $filters;
25 | }
26 |
27 | /**
28 | * @return FilterInterface[]
29 | */
30 | public function getFilters(): array
31 | {
32 | return $this->filters;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Reader/Filter/In.php:
--------------------------------------------------------------------------------
1 | values = $values;
43 | }
44 |
45 | public function getField(): string
46 | {
47 | return $this->field;
48 | }
49 |
50 | /**
51 | * @return bool[]|float[]|int[]|string[]
52 | */
53 | public function getValues(): array
54 | {
55 | return $this->values;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Reader/Filter/LessThan.php:
--------------------------------------------------------------------------------
1 | field;
33 | }
34 |
35 | public function getValue(): string
36 | {
37 | return $this->value;
38 | }
39 |
40 | public function getCaseSensitive(): ?bool
41 | {
42 | return $this->caseSensitive;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Reader/Filter/Not.php:
--------------------------------------------------------------------------------
1 | filter;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Reader/FilterHandlerInterface.php:
--------------------------------------------------------------------------------
1 |
22 | */
23 | interface FilterableDataInterface extends ReadableDataInterface
24 | {
25 | /**
26 | * Returns new instance with data reading criteria set.
27 | *
28 | * @param ?FilterInterface $filter Data reading criteria.
29 | *
30 | * @return static New instance.
31 | * @psalm-return $this
32 | */
33 | public function withFilter(?FilterInterface $filter): static;
34 |
35 | /**
36 | * Get current data reading criteria.
37 | *
38 | * @return FilterInterface|null Data reading criteria.
39 | */
40 | public function getFilter(): ?FilterInterface;
41 |
42 | /**
43 | * Returns new instance with additional handlers set.
44 | *
45 | * @param FilterHandlerInterface ...$filterHandlers Additional filter handlers.
46 | *
47 | * @return static New instance.
48 | * @psalm-return $this
49 | */
50 | public function withAddedFilterHandlers(FilterHandlerInterface ...$filterHandlers): static;
51 | }
52 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/AllHandler.php:
--------------------------------------------------------------------------------
1 | getFilters() as $subFilter) {
30 | $filterHandler = $iterableFilterHandlers[$subFilter::class] ?? null;
31 | if ($filterHandler === null) {
32 | throw new LogicException(
33 | sprintf('Filter "%s" is not supported.', $subFilter::class),
34 | );
35 | }
36 | if (!$filterHandler->match($item, $subFilter, $iterableFilterHandlers)) {
37 | return false;
38 | }
39 | }
40 |
41 | return true;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/AnyHandler.php:
--------------------------------------------------------------------------------
1 | getFilters() as $subFilter) {
30 | $filterHandler = $iterableFilterHandlers[$subFilter::class] ?? null;
31 | if ($filterHandler === null) {
32 | throw new LogicException(
33 | sprintf('Filter "%s" is not supported.', $subFilter::class),
34 | );
35 | }
36 | if ($filterHandler->match($item, $subFilter, $iterableFilterHandlers)) {
37 | return true;
38 | }
39 | }
40 |
41 | return false;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/BetweenHandler.php:
--------------------------------------------------------------------------------
1 | getField());
29 | $min = $filter->getMinValue();
30 | $max = $filter->getMaxValue();
31 |
32 | if (!$value instanceof DateTimeInterface) {
33 | return $value >= $min && $value <= $max;
34 | }
35 |
36 | return $min instanceof DateTimeInterface
37 | && $max instanceof DateTimeInterface
38 | && $value->getTimestamp() >= $min->getTimestamp()
39 | && $value->getTimestamp() <= $max->getTimestamp();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/EqualsHandler.php:
--------------------------------------------------------------------------------
1 | getField());
28 | $argumentValue = $filter->getValue();
29 |
30 | if (!$itemValue instanceof DateTimeInterface) {
31 | return $itemValue == $argumentValue;
32 | }
33 |
34 | return $argumentValue instanceof DateTimeInterface
35 | && $itemValue->getTimestamp() === $argumentValue->getTimestamp();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/EqualsNullHandler.php:
--------------------------------------------------------------------------------
1 | getField()) === null;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/GreaterThanHandler.php:
--------------------------------------------------------------------------------
1 | getField());
28 | $argumentValue = $filter->getValue();
29 |
30 | if (!$itemValue instanceof DateTimeInterface) {
31 | return $itemValue > $argumentValue;
32 | }
33 |
34 | return $argumentValue instanceof DateTimeInterface
35 | && $itemValue->getTimestamp() > $argumentValue->getTimestamp();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/GreaterThanOrEqualHandler.php:
--------------------------------------------------------------------------------
1 | getField());
29 | $argumentValue = $filter->getValue();
30 |
31 | if (!$itemValue instanceof DateTimeInterface) {
32 | return $itemValue >= $argumentValue;
33 | }
34 |
35 | return $argumentValue instanceof DateTimeInterface
36 | && $itemValue->getTimestamp() >= $argumentValue->getTimestamp();
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/InHandler.php:
--------------------------------------------------------------------------------
1 | getField());
29 | $argumentValue = $filter->getValues();
30 |
31 | return in_array($itemValue, $argumentValue);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/LessThanHandler.php:
--------------------------------------------------------------------------------
1 | getField());
28 | $argumentValue = $filter->getValue();
29 |
30 | if (!$itemValue instanceof DateTimeInterface) {
31 | return $itemValue < $argumentValue;
32 | }
33 |
34 | return $argumentValue instanceof DateTimeInterface
35 | && $itemValue->getTimestamp() < $argumentValue->getTimestamp();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/LessThanOrEqualHandler.php:
--------------------------------------------------------------------------------
1 | getField());
29 | $argumentValue = $filter->getValue();
30 |
31 | if (!$itemValue instanceof DateTimeInterface) {
32 | return $itemValue <= $argumentValue;
33 | }
34 |
35 | return $argumentValue instanceof DateTimeInterface
36 | && $itemValue->getTimestamp() <= $argumentValue->getTimestamp();
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/LikeHandler.php:
--------------------------------------------------------------------------------
1 | getField());
29 | if (!is_string($itemValue)) {
30 | return false;
31 | }
32 |
33 | return $filter->getCaseSensitive() === true
34 | ? str_contains($itemValue, $filter->getValue())
35 | : mb_stripos($itemValue, $filter->getValue()) !== false;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/FilterHandler/NotHandler.php:
--------------------------------------------------------------------------------
1 | getFilter();
29 |
30 | $filterHandler = $iterableFilterHandlers[$subFilter::class] ?? null;
31 | if ($filterHandler === null) {
32 | throw new LogicException(sprintf('Filter "%s" is not supported.', $subFilter::class));
33 | }
34 | return !$filterHandler->match($item, $subFilter, $iterableFilterHandlers);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/IterableDataReader.php:
--------------------------------------------------------------------------------
1 |
49 | */
50 | final class IterableDataReader implements DataReaderInterface
51 | {
52 | private ?Sort $sort = null;
53 | private ?FilterInterface $filter = null;
54 |
55 | /**
56 | * @psalm-var non-negative-int|null
57 | */
58 | private ?int $limit = null;
59 | private int $offset = 0;
60 |
61 | /**
62 | * @psalm-var array
63 | */
64 | private array $iterableFilterHandlers;
65 |
66 | /**
67 | * @param iterable $data Data to iterate.
68 | * @psalm-param iterable $data
69 | */
70 | public function __construct(private iterable $data)
71 | {
72 | $this->iterableFilterHandlers = $this->prepareFilterHandlers([
73 | new AllHandler(),
74 | new AnyHandler(),
75 | new BetweenHandler(),
76 | new EqualsHandler(),
77 | new EqualsNullHandler(),
78 | new GreaterThanHandler(),
79 | new GreaterThanOrEqualHandler(),
80 | new InHandler(),
81 | new LessThanHandler(),
82 | new LessThanOrEqualHandler(),
83 | new LikeHandler(),
84 | new NotHandler(),
85 | ]);
86 | }
87 |
88 | /**
89 | * @psalm-return $this
90 | */
91 | public function withAddedFilterHandlers(FilterHandlerInterface ...$filterHandlers): static
92 | {
93 | $new = clone $this;
94 | $new->iterableFilterHandlers = array_merge(
95 | $this->iterableFilterHandlers,
96 | $this->prepareFilterHandlers($filterHandlers)
97 | );
98 | return $new;
99 | }
100 |
101 | /**
102 | * @psalm-return $this
103 | */
104 | public function withFilter(?FilterInterface $filter): static
105 | {
106 | $new = clone $this;
107 | $new->filter = $filter;
108 | return $new;
109 | }
110 |
111 | /**
112 | * @psalm-return $this
113 | */
114 | public function withLimit(?int $limit): static
115 | {
116 | if ($limit < 0) {
117 | throw new InvalidArgumentException('The limit must not be less than 0.');
118 | }
119 |
120 | $new = clone $this;
121 | $new->limit = $limit;
122 | return $new;
123 | }
124 |
125 | /**
126 | * @psalm-return $this
127 | */
128 | public function withOffset(int $offset): static
129 | {
130 | $new = clone $this;
131 | $new->offset = $offset;
132 | return $new;
133 | }
134 |
135 | /**
136 | * @psalm-return $this
137 | */
138 | public function withSort(?Sort $sort): static
139 | {
140 | $new = clone $this;
141 | $new->sort = $sort;
142 | return $new;
143 | }
144 |
145 | /**
146 | * @psalm-return Generator
147 | */
148 | public function getIterator(): Generator
149 | {
150 | yield from $this->read();
151 | }
152 |
153 | public function getSort(): ?Sort
154 | {
155 | return $this->sort;
156 | }
157 |
158 | public function count(): int
159 | {
160 | return count($this->internalRead(useLimitAndOffset: false));
161 | }
162 |
163 | /**
164 | * @psalm-return array
165 | */
166 | public function read(): array
167 | {
168 | return $this->internalRead(useLimitAndOffset: true);
169 | }
170 |
171 | public function readOne(): array|object|null
172 | {
173 | if ($this->limit === 0) {
174 | return null;
175 | }
176 |
177 | /** @infection-ignore-all Any value more than one in `withLimit()` will be ignored because returned `current()` */
178 | return $this
179 | ->withLimit(1)
180 | ->getIterator()
181 | ->current();
182 | }
183 |
184 | /**
185 | * @psalm-return array
186 | */
187 | private function internalRead(bool $useLimitAndOffset): array
188 | {
189 | $data = [];
190 | $skipped = 0;
191 | $sortedData = $this->sort === null ? $this->data : $this->sortItems($this->data, $this->sort);
192 |
193 | foreach ($sortedData as $key => $item) {
194 | // Don't return more than limit items.
195 | if ($useLimitAndOffset && $this->limit > 0 && count($data) === $this->limit) {
196 | /** @infection-ignore-all Here continue === break */
197 | break;
198 | }
199 |
200 | // Skip offset items.
201 | if ($useLimitAndOffset && $skipped < $this->offset) {
202 | ++$skipped;
203 | continue;
204 | }
205 |
206 | // Filter items.
207 | if ($this->filter === null || $this->matchFilter($item, $this->filter)) {
208 | $data[$key] = $item;
209 | }
210 | }
211 |
212 | return $data;
213 | }
214 |
215 | /**
216 | * Return whether an item matches iterable filter.
217 | *
218 | * @param array|object $item Item to check.
219 | * @param FilterInterface $filter Filter.
220 | *
221 | * @return bool Whether an item matches iterable filter.
222 | */
223 | private function matchFilter(array|object $item, FilterInterface $filter): bool
224 | {
225 | $handler = $this->iterableFilterHandlers[$filter::class] ?? null;
226 |
227 | if ($handler === null) {
228 | throw new RuntimeException(sprintf('Filter "%s" is not supported.', $filter::class));
229 | }
230 |
231 | return $handler->match($item, $filter, $this->iterableFilterHandlers);
232 | }
233 |
234 | /**
235 | * Sorts data items according to the given sort definition.
236 | *
237 | * @param iterable $items The items to be sorted.
238 | * @param Sort $sort The sort definition.
239 | *
240 | * @return array The sorted items.
241 | *
242 | * @psalm-param iterable $items
243 | * @psalm-return iterable
244 | */
245 | private function sortItems(iterable $items, Sort $sort): iterable
246 | {
247 | $criteria = $sort->getCriteria();
248 |
249 | if ($criteria !== []) {
250 | $items = $this->iterableToArray($items);
251 | /** @infection-ignore-all */
252 | uasort(
253 | $items,
254 | static function (array|object $itemA, array|object $itemB) use ($criteria) {
255 | foreach ($criteria as $key => $order) {
256 | $valueA = ArrayHelper::getValue($itemA, $key);
257 | $valueB = ArrayHelper::getValue($itemB, $key);
258 |
259 | if ($valueB === $valueA) {
260 | continue;
261 | }
262 |
263 | return ($valueA > $valueB xor $order === SORT_DESC) ? 1 : -1;
264 | }
265 |
266 | return 0;
267 | }
268 | );
269 | }
270 |
271 | return $items;
272 | }
273 |
274 | /**
275 | * @param FilterHandlerInterface[] $filterHandlers
276 | *
277 | * @return IterableFilterHandlerInterface[]
278 | * @psalm-return array
279 | */
280 | private function prepareFilterHandlers(array $filterHandlers): array
281 | {
282 | $result = [];
283 |
284 | foreach ($filterHandlers as $filterHandler) {
285 | if (!$filterHandler instanceof IterableFilterHandlerInterface) {
286 | throw new DataReaderException(
287 | sprintf(
288 | '%s::withFilterHandlers() accepts instances of %s only.',
289 | self::class,
290 | IterableFilterHandlerInterface::class
291 | )
292 | );
293 | }
294 | $result[$filterHandler->getFilterClass()] = $filterHandler;
295 | }
296 |
297 | return $result;
298 | }
299 |
300 | /**
301 | * Convert iterable to array.
302 | *
303 | * @param iterable $iterable Iterable to convert.
304 | *
305 | * @psalm-param iterable $iterable
306 | *
307 | * @return array Resulting array.
308 | * @psalm-return array
309 | */
310 | private function iterableToArray(iterable $iterable): array
311 | {
312 | return $iterable instanceof Traversable ? iterator_to_array($iterable) : $iterable;
313 | }
314 |
315 | public function getFilter(): ?FilterInterface
316 | {
317 | return $this->filter;
318 | }
319 |
320 | public function getLimit(): ?int
321 | {
322 | return $this->limit;
323 | }
324 |
325 | public function getOffset(): int
326 | {
327 | return $this->offset;
328 | }
329 | }
330 |
--------------------------------------------------------------------------------
/src/Reader/Iterable/IterableFilterHandlerInterface.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | interface LimitableDataInterface extends ReadableDataInterface
18 | {
19 | /**
20 | * Get a new instance with the limit set.
21 | *
22 | * @param int|null $limit Limit. `null` means no limit.
23 | *
24 | * @throws InvalidArgumentException If the limit is less than zero.
25 | *
26 | * @return static New instance.
27 | *
28 | * @psalm-param non-negative-int|null $limit
29 | * @psalm-return $this
30 | */
31 | public function withLimit(?int $limit): static;
32 |
33 | /**
34 | * Get current limit.
35 | *
36 | * @return int|null Limit. `null` means no limit.
37 | *
38 | * @psalm-return non-negative-int|null
39 | */
40 | public function getLimit(): ?int;
41 | }
42 |
--------------------------------------------------------------------------------
/src/Reader/OffsetableDataInterface.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | interface OffsetableDataInterface extends ReadableDataInterface
16 | {
17 | /**
18 | * Get a new instance with an offset set.
19 | *
20 | * @param int $offset Offset.
21 | *
22 | * @return $this New instance.
23 | * @psalm-return $this
24 | */
25 | public function withOffset(int $offset): static;
26 |
27 | /**
28 | * Get current offset.
29 | *
30 | * @return int Offset.
31 | */
32 | public function getOffset(): int;
33 | }
34 |
--------------------------------------------------------------------------------
/src/Reader/OrderHelper.php:
--------------------------------------------------------------------------------
1 | $direction) {
65 | $parts[] = ($direction === 'desc' ? '-' : '') . $field;
66 | }
67 |
68 | return implode(',', $parts);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Reader/ReadableDataInterface.php:
--------------------------------------------------------------------------------
1 |
20 | */
21 | public function read(): iterable;
22 |
23 | /**
24 | * Get one item from the data set. Which item is returned is up to implementation.
25 | * Note that invoking this method doesn't impact the data set or its pointer.
26 | *
27 | * @return array|object|null An item or null if there is none.
28 | * @psalm-return TValue|null
29 | */
30 | public function readOne(): array|object|null;
31 | }
32 |
--------------------------------------------------------------------------------
/src/Reader/Sort.php:
--------------------------------------------------------------------------------
1 | real fields along with their order. The config also contains the default
19 | * order for each logical field.
20 | * - Currently specified logical fields order such as field1 => asc, field2 => desc. Usually it is passed directly
21 | * from the end user.
22 | *
23 | * Logical fields are the ones user operates with. Real fields are the ones actually present in a data set.
24 | * Such a mapping helps when you need to sort by a single logical field that, in fact, consists of multiple fields
25 | * in the underlying data set. For example, you provide a user with a username which consists of first name and last name
26 | * fields in the actual data set.
27 | *
28 | * Based on the settings, the class can produce a criteria to be applied to {@see SortableDataInterface}
29 | * when getting the data that is a list of real fields along with their order directions.
30 | *
31 | * There are two modes of forming a criteria available:
32 | *
33 | * - {@see Sort::only()} ignores user-specified order for logical fields that have no configuration.
34 | * - {@see Sort::any()} uses user-specified logical field name and order directly for fields that have no configuration.
35 | *
36 | * @psalm-type TOrder = array
37 | * @psalm-type TSortFieldItem = array
38 | * @psalm-type TConfigItem = array{asc: TSortFieldItem, desc: TSortFieldItem, default: "asc"|"desc"}
39 | * @psalm-type TConfig = array
40 | * @psalm-type TUserConfigItem = array{
41 | * asc?: int|"asc"|"desc"|array,
42 | * desc?: int|"asc"|"desc"|array,
43 | * default?: "asc"|"desc"
44 | * }
45 | * @psalm-type TUserConfig = array|array
46 | */
47 | final class Sort
48 | {
49 | /**
50 | * Logical fields config.
51 | *
52 | * @psalm-var TConfig
53 | */
54 | private array $config;
55 |
56 | /**
57 | * @var bool Whether to add default sorting when forming criteria.
58 | */
59 | private bool $withDefaultSorting = true;
60 |
61 | /**
62 | * @var array Logical fields to order by in form of [name => direction].
63 | * @psalm-var TOrder
64 | */
65 | private array $currentOrder = [];
66 |
67 | /**
68 | * @param array $config Logical fields config.
69 | * @psalm-param TUserConfig $config
70 | *
71 | * @param bool $ignoreExtraFields Whether to ignore logical fields not present in the config when forming criteria.
72 | */
73 | private function __construct(private bool $ignoreExtraFields, array $config)
74 | {
75 | $normalizedConfig = [];
76 |
77 | foreach ($config as $fieldName => $fieldConfig) {
78 | if (
79 | !(is_int($fieldName) && is_string($fieldConfig))
80 | && !(is_string($fieldName) && is_array($fieldConfig))
81 | ) {
82 | throw new InvalidArgumentException('Invalid config format.');
83 | }
84 |
85 | if (is_int($fieldName)) {
86 | /** @var string $fieldConfig */
87 | $fieldName = $fieldConfig;
88 | $fieldConfig = [];
89 | } else {
90 | /** @psalm-var TUserConfigItem $fieldConfig */
91 | foreach ($fieldConfig as $key => &$criteria) {
92 | // 'default' => 'asc' or 'desc'
93 | if ($key === 'default') {
94 | continue;
95 | }
96 | // 'asc'/'desc' => SORT_*
97 | if (is_int($criteria)) {
98 | continue;
99 | }
100 | // 'asc'/'desc' => 'asc' or 'asc'/'desc' => 'desc'
101 | if (is_string($criteria)) {
102 | $criteria = [$fieldName => $criteria === 'desc' ? SORT_DESC : SORT_ASC];
103 | continue;
104 | }
105 | // 'asc'/'desc' => ['field' => SORT_*|'asc'|'desc']
106 | foreach ($criteria as &$subCriteria) {
107 | if (is_string($subCriteria)) {
108 | $subCriteria = $subCriteria === 'desc' ? SORT_DESC : SORT_ASC;
109 | }
110 | }
111 | }
112 | }
113 |
114 | $normalizedConfig[$fieldName] = array_merge(
115 | [
116 | 'asc' => [$fieldName => SORT_ASC],
117 | 'desc' => [$fieldName => SORT_DESC],
118 | 'default' => 'asc',
119 | ],
120 | $fieldConfig,
121 | );
122 | }
123 |
124 | /** @psalm-var TConfig $normalizedConfig */
125 | $this->config = $normalizedConfig;
126 | }
127 |
128 | /**
129 | * Create a sort instance that ignores the current order for extra logical fields that have no configuration.
130 | *
131 | * @param array $config Logical fields config.
132 | * @psalm-param TUserConfig $config
133 | *
134 | * ```php
135 | * [
136 | * 'age', // means will be sorted as is
137 | * 'name' => [
138 | * 'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
139 | * 'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
140 | * 'default' => 'desc',
141 | * ],
142 | * ]
143 | * ```
144 | *
145 | * In the above, two fields are declared: `age` and `name`. The `age` field is
146 | * a simple field that is equivalent to the following:
147 | *
148 | * ```php
149 | * 'age' => [
150 | * 'asc' => ['age' => SORT_ASC],
151 | * 'desc' => ['age' => SORT_DESC],
152 | * 'default' => 'asc',
153 | * ]
154 | * ```
155 | *
156 | * The name field is a virtual field name that consists of two real fields, `first_name` and `last_name`. Virtual
157 | * field name is used in order string or order array while real fields are used in final sorting criteria.
158 | *
159 | * Each configuration has the following options:
160 | *
161 | * - `asc` - criteria for ascending sorting.
162 | * - `desc` - criteria for descending sorting.
163 | * - `default` - default sorting. Could be either `asc` or `desc`. If not specified, `asc` is used.
164 | */
165 | public static function only(array $config): self
166 | {
167 | return new self(true, $config);
168 | }
169 |
170 | /**
171 | * Create a sort instance that uses logical field itself and direction provided when there is no configuration.
172 | *
173 | * @param array $config Logical fields config.
174 | * @psalm-param TUserConfig $config
175 | *
176 | * ```php
177 | * [
178 | * 'age', // means will be sorted as is
179 | * 'name' => [
180 | * 'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
181 | * 'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
182 | * 'default' => 'desc',
183 | * ],
184 | * ]
185 | * ```
186 | *
187 | * In the above, two fields are declared: `age` and `name`. The `age` field is
188 | * a simple field that is equivalent to the following:
189 | *
190 | * ```php
191 | * 'age' => [
192 | * 'asc' => ['age' => SORT_ASC],
193 | * 'desc' => ['age' => SORT_DESC],
194 | * 'default' => 'asc',
195 | * ]
196 | * ```
197 | *
198 | * The name field is a virtual field name that consists of two real fields, `first_name` and `last_name`. Virtual
199 | * field name is used in order string or order array while real fields are used in final sorting criteria.
200 | *
201 | * Each configuration has the following options:
202 | *
203 | * - `asc` - criteria for ascending sorting.
204 | * - `desc` - criteria for descending sorting.
205 | * - `default` - default sorting. Could be either `asc` or `desc`. If not specified, `asc` is used.
206 | */
207 | public static function any(array $config = []): self
208 | {
209 | return new self(false, $config);
210 | }
211 |
212 | /**
213 | * Get a new instance with a logical field order set from an order string.
214 | *
215 | * The string consists of comma-separated field names.
216 | * If the name is prefixed with `-`, field order is descending.
217 | * Otherwise, the order is ascending.
218 | *
219 | * @param string $orderString Logical fields order as comma-separated string.
220 | *
221 | * @return self New instance.
222 | */
223 | public function withOrderString(string $orderString): self
224 | {
225 | return $this->withOrder(
226 | OrderHelper::stringToArray($orderString)
227 | );
228 | }
229 |
230 | /**
231 | * Return a new instance with a logical field order set.
232 | *
233 | * @param array $order A map with logical field names to order by as keys, direction as values.
234 | * @psalm-param TOrder $order
235 | *
236 | * @return self New instance.
237 | */
238 | public function withOrder(array $order): self
239 | {
240 | $new = clone $this;
241 | $new->currentOrder = $order;
242 | return $new;
243 | }
244 |
245 | /**
246 | * Return a new instance without a default sorting set.
247 | *
248 | * @return self New instance.
249 | */
250 | public function withoutDefaultSorting(): self
251 | {
252 | $new = clone $this;
253 | $new->withDefaultSorting = false;
254 | return $new;
255 | }
256 |
257 | /**
258 | * Get current logical fields order.
259 | *
260 | * @return array Logical fields order.
261 | * @psalm-return TOrder
262 | */
263 | public function getOrder(): array
264 | {
265 | return $this->currentOrder;
266 | }
267 |
268 | /**
269 | * Get an order string based on current logical fields order.
270 | *
271 | * The string consists of comma-separated field names.
272 | * If the name is prefixed with `-`, field order is descending.
273 | * Otherwise, the order is ascending.
274 | *
275 | * @return string An order string.
276 | */
277 | public function getOrderAsString(): string
278 | {
279 | return OrderHelper::arrayToString($this->currentOrder);
280 | }
281 |
282 | /**
283 | * Get a sorting criteria to be applied to {@see SortableDataInterface}
284 | * when getting the data that is a list of real fields along with their order directions.
285 | *
286 | * @return array Sorting criteria.
287 | * @psalm-return array
288 | */
289 | public function getCriteria(): array
290 | {
291 | $criteria = [];
292 | $config = $this->config;
293 |
294 | foreach ($this->currentOrder as $field => $direction) {
295 | if (array_key_exists($field, $config)) {
296 | $criteria = array_merge($criteria, $config[$field][$direction]);
297 | unset($config[$field]);
298 | } else {
299 | if ($this->ignoreExtraFields) {
300 | continue;
301 | }
302 | $criteria = array_merge($criteria, [$field => $direction === 'desc' ? SORT_DESC : SORT_ASC]);
303 | }
304 | }
305 |
306 | if ($this->withDefaultSorting) {
307 | foreach ($config as $fieldConfig) {
308 | $criteria += $fieldConfig[$fieldConfig['default']];
309 | }
310 | }
311 |
312 | return $criteria;
313 | }
314 |
315 | /**
316 | * @param string $name The field name.
317 | *
318 | * @return bool Whether the field is present in the config.
319 | */
320 | public function hasFieldInConfig(string $name): bool
321 | {
322 | return isset($this->config[$name]);
323 | }
324 | }
325 |
--------------------------------------------------------------------------------
/src/Reader/SortableDataInterface.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | interface SortableDataInterface extends ReadableDataInterface
17 | {
18 | /**
19 | * Get a new instance with a sorting set.
20 | *
21 | * @param Sort|null $sort Sorting criteria or null for no sorting.
22 | *
23 | * @return static New instance.
24 | * @psalm-return $this
25 | */
26 | public function withSort(?Sort $sort): static;
27 |
28 | /**
29 | * Get a current sorting criteria.
30 | *
31 | * @return Sort|null Current sorting criteria or null for no sorting.
32 | */
33 | public function getSort(): ?Sort;
34 | }
35 |
--------------------------------------------------------------------------------
/src/Writer/DataWriterException.php:
--------------------------------------------------------------------------------
1 | 1, 'email' => 'foo@bar\\baz', 'balance' => 10.25, 'born_at' => null],
11 | ['number' => 2, 'email' => 'bar@foo', 'balance' => 1.0, 'born_at' => null],
12 | ['number' => 3, 'email' => 'seed@beat', 'balance' => 100.0, 'born_at' => null],
13 | ['number' => 4, 'email' => 'the@best', 'balance' => 500.0, 'born_at' => null],
14 | ['number' => 5, 'email' => 'test@test', 'balance' => 42.0, 'born_at' => '1990-01-01'],
15 | ];
16 |
17 | protected function assertFixtures(array $expectedFixtureIndexes, array $actualFixtures): void
18 | {
19 | $expectedFixtures = [];
20 | foreach ($expectedFixtureIndexes as $index) {
21 | $expectedFixtures[$index] = $this->getFixture($index);
22 | }
23 |
24 | $this->assertSame($expectedFixtures, $actualFixtures);
25 | }
26 |
27 | protected function getFixture(int $index): array
28 | {
29 | return self::$fixtures[$index];
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tests/Common/Reader/BaseReaderTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()
17 | ->withFilter(new All(new Equals('balance', 100), new Equals('email', 'seed@beat')));
18 | $this->assertFixtures([2], $reader->read());
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithAnyTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()
21 | ->withFilter(new Any(new Equals('number', 2), new Equals('number', 3)));
22 | $this->assertFixtures([1, 2], $reader->read());
23 | }
24 |
25 | public function testNested(): void
26 | {
27 | $reader = $this
28 | ->getReader()
29 | ->withFilter(
30 | new Any(
31 | new All(new GreaterThan('balance', 500), new LessThan('number', 5)),
32 | new Like('email', 'st'),
33 | )
34 | );
35 | $this->assertFixtures([3, 4], $reader->read());
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithBetweenTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()->withFilter(new Between('balance', 10.25, 100.0));
15 | $this->assertFixtures([0, 2, 4], $reader->read());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithEqualsNullTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()->withFilter(new EqualsNull('born_at'));
15 | $this->assertFixtures(range(0, 3), $reader->read());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithEqualsTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()->withFilter(new Equals('number', 2));
15 | $this->assertFixtures([1], $reader->read());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithGreaterThanOrEqualTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()->withFilter(new GreaterThanOrEqual('balance', 500));
15 | $this->assertFixtures([3], $reader->read());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithGreaterThanTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()->withFilter(new GreaterThan('balance', 499));
15 | $this->assertFixtures([3], $reader->read());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithInTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()->withFilter(new In('number', [2, 3]));
15 | $this->assertFixtures([1, 2], $reader->read());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithLessThanOrEqualTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()->withFilter(new LessThanOrEqual('balance', 1.0));
15 | $this->assertFixtures([1], $reader->read());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithLessThanTestCase.php:
--------------------------------------------------------------------------------
1 | getReader()->withFilter(new LessThan('balance', 1.1));
15 | $this->assertFixtures([1], $reader->read());
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithLikeTestCase.php:
--------------------------------------------------------------------------------
1 | ['email', 'seed@', null, [2]],
17 | 'search: ends with, same case, case sensitive: null' => ['email', '@beat', null, [2]],
18 | 'search: contains, same case, case sensitive: null' => ['email', 'ed@be', null, [2]],
19 | 'search: contains, same case, case sensitive: false' => ['email', 'ed@be', false, [2]],
20 | 'search: contains, different case, case sensitive: false' => ['email', 'SEED@', false, [2]],
21 | 'wildcard is not supported, %' => ['email', '%st', null, []],
22 | 'wildcard is not supported, _' => ['email', '____@___t', null, []],
23 | 'search: contains backslash' => ['email', 'foo@bar\\baz', null, [0]],
24 | ];
25 | }
26 |
27 | #[DataProvider('dataWithReader')]
28 | public function testWithReader(
29 | string $field,
30 | string $value,
31 | bool|null $caseSensitive,
32 | array $expectedFixtureIndexes,
33 | ): void {
34 | $reader = $this->getReader()->withFilter(new Like($field, $value, $caseSensitive));
35 | $this->assertFixtures($expectedFixtureIndexes, $reader->read());
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tests/Common/Reader/ReaderWithFilter/BaseReaderWithNotTestCase.php:
--------------------------------------------------------------------------------
1 | [new Not(new All(new Equals('number', 1), new Equals('number', 2))), range(1, 5)],
28 | 'any' => [new Not(new Any(new Equals('number', 1), new Equals('number', 2))), range(3, 5)],
29 | 'between' => [new Not(new Between('balance', 10.25, 100.0)), [2, 4]],
30 | 'equals' => [new Not(new Equals('number', 1)), range(2, 5)],
31 | 'equals null' => [new Not(new EqualsNull('born_at')), [5]],
32 | 'greater than' => [new Not(new GreaterThan('number', 2)), [1, 2]],
33 | 'greater than or equal' => [new Not(new GreaterThanOrEqual('number', 2)), [1]],
34 | 'less than' => [new Not(new LessThan('number', 2)), range(2, 5)],
35 | 'less than or equal' => [new Not(new LessThanOrEqual('number', 2)), range(3, 5)],
36 | 'in' => [new Not(new In('number', [1, 3, 5])), [2, 4]],
37 | 'like' => [new Not(new Like('email', 'st')), range(1, 3)],
38 | 'not, even, 2' => [new Not(new Not(new Equals('number', 1))), [1]],
39 | 'not, even, 4' => [new Not(new Not(new Not(new Not(new Equals('number', 1))))), [1]],
40 | 'not, odd, 3' => [new Not(new Not(new Not(new Equals('number', 1)))), range(2, 5)],
41 | 'not, odd, 5' => [new Not(new Not(new Not(new Not(new Not(new Equals('number', 1)))))), range(2, 5)],
42 | ];
43 | }
44 |
45 | #[DataProvider('dataWithReader')]
46 | public function testWithReader(Not $filter, array $expectedFixtureNumbers): void
47 | {
48 | $expectedFixtureIndexes = array_map(static fn (int $number): int => $number - 1, $expectedFixtureNumbers);
49 | $this->assertFixtures(
50 | $expectedFixtureIndexes,
51 | $this->getReader()->withFilter($filter)->read(),
52 | );
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/tests/Paginator/PageTokenAssertTrait.php:
--------------------------------------------------------------------------------
1 | value, new IsIdentical($expectedValue), $message);
25 | static::assertThat($pageToken->isPrevious, new IsIdentical($expectedIsPrevious), $message);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/Reader/Filter/CompareTest.php:
--------------------------------------------------------------------------------
1 | assertNotSame($filter, $filter->withValue(1));
17 | $this->assertSame(2, $filter->withValue(2)->getValue());
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tests/Reader/Filter/InTest.php:
--------------------------------------------------------------------------------
1 | expectException(InvalidArgumentException::class);
17 | $this->expectExceptionMessage('The value should be scalar. "' . stdClass::class . '" is received.');
18 | new In('test', [new stdClass()]);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tests/Reader/Filter/LikeTest.php:
--------------------------------------------------------------------------------
1 | assertSame('name', $like->getField());
17 | $this->assertSame('Kesha', $like->getValue());
18 | $this->assertNull($like->getCaseSensitive());
19 | }
20 |
21 | public function testWithCaseSensitive(): void
22 | {
23 | $like = new Like('name', 'Kesha', true);
24 |
25 | $this->assertSame('name', $like->getField());
26 | $this->assertSame('Kesha', $like->getValue());
27 | $this->assertTrue($like->getCaseSensitive());
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/AllHandlerTest.php:
--------------------------------------------------------------------------------
1 | new EqualsHandler(),
26 | GreaterThanOrEqual::class => new GreaterThanOrEqualHandler(),
27 | LessThanOrEqual::class => new LessThanOrEqualHandler(),
28 | ];
29 |
30 | return [
31 | [
32 | true,
33 | [new Equals('value', 45), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 45)],
34 | $handlers,
35 | ],
36 | [
37 | true,
38 | [new Equals('value', '45'), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 45)],
39 | $handlers,
40 | ],
41 | [
42 | false,
43 | [new Equals('value', 44), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 45)],
44 | $handlers,
45 | ],
46 | [
47 | false,
48 | [new Equals('value', 45), new GreaterThanOrEqual('value', 46), new LessThanOrEqual('value', 45)],
49 | $handlers,
50 | ],
51 | [
52 | false,
53 | [new Equals('value', 45), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 44)],
54 | $handlers,
55 | ],
56 | [
57 | false,
58 | [new Equals('value', 45), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 44)],
59 | $handlers,
60 | ],
61 | ];
62 | }
63 |
64 | #[DataProvider('matchDataProvider')]
65 | public function testMatch(bool $expected, array $filters, array $filterHandlers): void
66 | {
67 | $handler = new AllHandler();
68 |
69 | $item = [
70 | 'id' => 1,
71 | 'value' => 45,
72 | ];
73 |
74 | $this->assertSame($expected, $handler->match($item, new All(...$filters), $filterHandlers));
75 | }
76 |
77 | public function testMatchFailIfFilterOperatorIsNotSupported(): void
78 | {
79 | $this->expectException(LogicException::class);
80 | $this->expectExceptionMessage('Filter "' . FilterWithoutHandler::class . '" is not supported.');
81 |
82 | (new AllHandler())->match(['id' => 1], new All(new FilterWithoutHandler()), []);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/AnyHandlerTest.php:
--------------------------------------------------------------------------------
1 | new EqualsHandler(),
26 | GreaterThanOrEqual::class => new GreaterThanOrEqualHandler(),
27 | LessThanOrEqual::class => new LessThanOrEqualHandler(),
28 | ];
29 | return [
30 | [
31 | true,
32 | [new Equals('value', 45), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 45)],
33 | $handlers,
34 | ],
35 | [
36 | true,
37 | [new Equals('value', '45'), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 45)],
38 | $handlers,
39 | ],
40 | [
41 | true,
42 | [new Equals('value', 45), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 45)],
43 | $handlers,
44 | ],
45 | [
46 | true,
47 | [new Equals('value', 45), new GreaterThanOrEqual('value', 46), new LessThanOrEqual('value', 45)],
48 | $handlers,
49 | ],
50 | [
51 | true,
52 | [new Equals('value', 45), new GreaterThanOrEqual('value', 45), new LessThanOrEqual('value', 44)],
53 | $handlers,
54 | ],
55 | [
56 | false,
57 | [new Equals('value', 44), new GreaterThanOrEqual('value', 46), new LessThanOrEqual('value', 44)],
58 | $handlers,
59 | ],
60 | ];
61 | }
62 |
63 | #[DataProvider('matchDataProvider')]
64 | public function testMatch(bool $expected, array $filters, array $filterHandlers): void
65 | {
66 | $handler = new AnyHandler();
67 |
68 | $item = [
69 | 'id' => 1,
70 | 'value' => 45,
71 | ];
72 |
73 | $this->assertSame($expected, $handler->match($item, new Any(...$filters), $filterHandlers));
74 | }
75 |
76 | public function testMatchFailIfFilterOperatorIsNotSupported(): void
77 | {
78 | $this->expectException(LogicException::class);
79 | $this->expectExceptionMessage('Filter "' . FilterWithoutHandler::class . '" is not supported.');
80 |
81 | (new AnyHandler())->match(['id' => 1], new Any(new FilterWithoutHandler()), []);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/BetweenHandlerTest.php:
--------------------------------------------------------------------------------
1 | 1,
35 | 'value' => 45,
36 | ];
37 |
38 | $this->assertSame($expected, $handler->match($item, $filter, []));
39 | }
40 |
41 | public static function matchDateTimeInterfaceDataProvider(): array
42 | {
43 | return [
44 | [true, new DateTimeImmutable('2022-02-22 16:00:42'), new DateTimeImmutable('2022-02-22 16:00:47')],
45 | [true, new DateTimeImmutable('2022-02-22 16:00:45'), new DateTimeImmutable('2022-02-22 16:00:45')],
46 | [true, new DateTimeImmutable('2022-02-22 16:00:45'), new DateTimeImmutable('2022-02-22 16:00:46')],
47 | [false, new DateTimeImmutable('2022-02-22 16:00:46'), new DateTimeImmutable('2022-02-22 16:00:47')],
48 | [false, new DateTimeImmutable('2022-02-22 16:00:46'), new DateTimeImmutable('2022-02-22 16:00:45')],
49 | ];
50 | }
51 |
52 | #[DataProvider('matchDateTimeInterfaceDataProvider')]
53 | public function testMatchDateTimeInterface(bool $expected, DateTimeImmutable $from, DateTimeImmutable $to): void
54 | {
55 | $processor = new BetweenHandler();
56 |
57 | $item = [
58 | 'id' => 1,
59 | 'value' => new DateTimeImmutable('2022-02-22 16:00:45'),
60 | ];
61 |
62 | $this->assertSame($expected, $processor->match($item, new Between('value', $from, $to), []));
63 | }
64 |
65 | public function testObjectWithGetters(): void
66 | {
67 | $car1 = new Car(1);
68 | $car2 = new Car(2);
69 | $car3 = new Car(3);
70 | $car4 = new Car(4);
71 | $car5 = new Car(5);
72 |
73 | $reader = new IterableDataReader([
74 | 1 => $car1,
75 | 2 => $car2,
76 | 3 => $car3,
77 | 4 => $car4,
78 | 5 => $car5,
79 | ]);
80 |
81 | $result = $reader->withFilter(new Between('getNumber()', 3, 5))->read();
82 |
83 | $this->assertSame([3 => $car3, 4 => $car4, 5 => $car5], $result);
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/EqualsHandlerTest.php:
--------------------------------------------------------------------------------
1 | 1,
34 | 'value' => 45,
35 | ];
36 |
37 | $this->assertSame($expected, $processor->match($item, new Equals('value', $value), []));
38 | }
39 |
40 | public static function matchDateTimeInterfaceDataProvider(): array
41 | {
42 | return [
43 | [true, new DateTimeImmutable('2022-02-22 16:00:45')],
44 | [false, new DateTimeImmutable('2022-02-22 16:00:44')],
45 | [false, new DateTimeImmutable('2022-02-22 16:00:46')],
46 | ];
47 | }
48 |
49 | #[DataProvider('matchDateTimeInterfaceDataProvider')]
50 | public function testMatchDateTimeInterface(bool $expected, DateTimeImmutable $value): void
51 | {
52 | $handler = new EqualsHandler();
53 |
54 | $item = [
55 | 'id' => 1,
56 | 'value' => new DateTimeImmutable('2022-02-22 16:00:45'),
57 | ];
58 |
59 | $this->assertSame($expected, $handler->match($item, new Equals('value', $value), []));
60 | }
61 |
62 | public function testObjectWithGetters(): void
63 | {
64 | $car1 = new Car(1);
65 | $car2 = new Car(2);
66 | $car3 = new Car(1);
67 | $car4 = new Car(3);
68 | $car5 = new Car(5);
69 |
70 | $reader = new IterableDataReader([
71 | 1 => $car1,
72 | 2 => $car2,
73 | 3 => $car3,
74 | 4 => $car4,
75 | 5 => $car5,
76 | ]);
77 |
78 | $result = $reader->withFilter(new Equals('getNumber()', 1))->read();
79 |
80 | $this->assertSame([1 => $car1, 3 => $car3], $result);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/EqualsNullHandlerTest.php:
--------------------------------------------------------------------------------
1 | null]],
20 | [false, ['value' => false]],
21 | [false, ['value' => true]],
22 | [false, ['value' => 0]],
23 | [false, ['value' => 0.0]],
24 | [false, ['value' => 42]],
25 | [false, ['value' => '']],
26 | [false, ['value' => 'null']],
27 | ];
28 | }
29 |
30 | #[DataProvider('matchDataProvider')]
31 | public function testMatch(bool $expected, array $item): void
32 | {
33 | $this->assertSame($expected, (new EqualsNullHandler())->match($item, new EqualsNull('value'), []));
34 | }
35 |
36 | public function testObjectWithGetters(): void
37 | {
38 | $car1 = new Car(1);
39 | $car2 = new Car(2);
40 | $car3 = new Car(null);
41 | $car4 = new Car(4);
42 | $car5 = new Car(null);
43 |
44 | $reader = new IterableDataReader([
45 | 1 => $car1,
46 | 2 => $car2,
47 | 3 => $car3,
48 | 4 => $car4,
49 | 5 => $car5,
50 | ]);
51 |
52 | $result = $reader->withFilter(new EqualsNull('getNumber()'))->read();
53 |
54 | $this->assertSame([3 => $car3, 5 => $car5], $result);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/GreaterThanHandlerTest.php:
--------------------------------------------------------------------------------
1 | 1,
32 | 'value' => 45,
33 | ];
34 |
35 | $this->assertSame($expected, $handler->match($item, new GreaterThan('value', $value), []));
36 | }
37 |
38 | public static function matchDateTimeInterfaceDataProvider(): array
39 | {
40 | return [
41 | [true, new DateTimeImmutable('2022-02-22 16:00:44')],
42 | [false, new DateTimeImmutable('2022-02-22 16:00:45')],
43 | [false, new DateTimeImmutable('2022-02-22 16:00:46')],
44 | ];
45 | }
46 |
47 | #[DataProvider('matchDateTimeInterfaceDataProvider')]
48 | public function testMatchDateTimeInterface(bool $expected, DateTimeImmutable $value): void
49 | {
50 | $handler = new GreaterThanHandler();
51 |
52 | $item = [
53 | 'id' => 1,
54 | 'value' => new DateTimeImmutable('2022-02-22 16:00:45'),
55 | ];
56 |
57 | $this->assertSame($expected, $handler->match($item, new GreaterThan('value', $value), []));
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/GreaterThanOrEqualHandlerTest.php:
--------------------------------------------------------------------------------
1 | 1,
32 | 'value' => 45,
33 | ];
34 |
35 | $this->assertSame($expected, $processor->match($item, new GreaterThanOrEqual('value', $value), []));
36 | }
37 |
38 | public static function matchDateTimeInterfaceDataProvider(): array
39 | {
40 | return [
41 | [true, new DateTimeImmutable('2022-02-22 16:00:44')],
42 | [true, new DateTimeImmutable('2022-02-22 16:00:45')],
43 | [false, new DateTimeImmutable('2022-02-22 16:00:46')],
44 | ];
45 | }
46 |
47 | #[DataProvider('matchDateTimeInterfaceDataProvider')]
48 | public function testMatchDateTimeInterface(bool $expected, DateTimeImmutable $value): void
49 | {
50 | $processor = new GreaterThanOrEqualHandler();
51 |
52 | $item = [
53 | 'id' => 1,
54 | 'value' => new DateTimeImmutable('2022-02-22 16:00:45'),
55 | ];
56 |
57 | $this->assertSame($expected, $processor->match($item, new GreaterThanOrEqual('value', $value), []));
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/InHandlerTest.php:
--------------------------------------------------------------------------------
1 | 1,
30 | 'value' => 45,
31 | ];
32 |
33 | $this->assertSame($expected, $handler->match($item, new In('value', $value), []));
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/LessThanHandlerTest.php:
--------------------------------------------------------------------------------
1 | 1,
32 | 'value' => 45,
33 | ];
34 |
35 | $this->assertSame($expected, $processor->match($item, new LessThan('value', $value), []));
36 | }
37 |
38 | public static function matchDateTimeInterfaceDataProvider(): array
39 | {
40 | return [
41 | [true, new DateTimeImmutable('2022-02-22 16:00:46')],
42 | [false, new DateTimeImmutable('2022-02-22 16:00:45')],
43 | [false, new DateTimeImmutable('2022-02-22 16:00:44')],
44 | ];
45 | }
46 |
47 | #[DataProvider('matchDateTimeInterfaceDataProvider')]
48 | public function testMatchDateTimeInterface(bool $expected, DateTimeImmutable $value): void
49 | {
50 | $handler = new LessThanHandler();
51 |
52 | $item = [
53 | 'id' => 1,
54 | 'value' => new DateTimeImmutable('2022-02-22 16:00:45'),
55 | ];
56 |
57 | $this->assertSame($expected, $handler->match($item, new LessThan('value', $value), []));
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/LessThanOrEqualHandlerTest.php:
--------------------------------------------------------------------------------
1 | 1,
32 | 'value' => 45,
33 | ];
34 |
35 | $this->assertSame($expected, $handler->match($item, new LessThanOrEqual('value', $value), []));
36 | }
37 |
38 | public static function matchDateTimeInterfaceDataProvider(): array
39 | {
40 | return [
41 | [true, new DateTimeImmutable('2022-02-22 16:00:46')],
42 | [true, new DateTimeImmutable('2022-02-22 16:00:45')],
43 | [false, new DateTimeImmutable('2022-02-22 16:00:44')],
44 | ];
45 | }
46 |
47 | #[DataProvider('matchDateTimeInterfaceDataProvider')]
48 | public function testMatchDateTimeInterface(bool $expected, DateTimeImmutable $value): void
49 | {
50 | $handler = new LessThanOrEqualHandler();
51 |
52 | $item = [
53 | 'id' => 1,
54 | 'value' => new DateTimeImmutable('2022-02-22 16:00:45'),
55 | ];
56 |
57 | $this->assertSame($expected, $handler->match($item, new LessThanOrEqual('value', $value), []));
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/LikeHandlerTest.php:
--------------------------------------------------------------------------------
1 | 1, 'value' => 'Great Cat Fighter'], 'value', 'Great Cat Fighter', null],
18 | [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Cat', null],
19 | [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'cat', null],
20 | [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'id', '1', null],
21 | [true, ['id' => 1, 'value' => '🙁🙂🙁'], 'value', '🙂', null],
22 | [true, ['id' => 1, 'value' => 'Привет мир'], 'value', ' ', null],
23 |
24 | [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Great Cat Fighter', false],
25 | [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Cat', false],
26 | [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'cat', false],
27 | [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'id', '1', false],
28 | [true, ['id' => 1, 'value' => '🙁🙂🙁'], 'value', '🙂', false],
29 | [true, ['id' => 1, 'value' => 'Привет мир'], 'value', ' ', false],
30 |
31 | [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Great Cat Fighter', true],
32 | [true, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'Cat', true],
33 | [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'value', 'cat', true],
34 | [false, ['id' => 1, 'value' => 'Great Cat Fighter'], 'id', '1', true],
35 | [true, ['id' => 1, 'value' => '🙁🙂🙁'], 'value', '🙂', true],
36 | [true, ['id' => 1, 'value' => 'Привет мир'], 'value', ' ', true],
37 |
38 | [true, ['id' => 1, 'value' => 'das Öl'], 'value', 'öl', false],
39 | ];
40 | }
41 |
42 | #[DataProvider('matchDataProvider')]
43 | public function testMatch(bool $expected, array $item, string $field, string $value, ?bool $caseSensitive): void
44 | {
45 | $processor = new LikeHandler();
46 | $this->assertSame($expected, $processor->match($item, new Like($field, $value, $caseSensitive), []));
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/FilterHandler/NotHandlerTest.php:
--------------------------------------------------------------------------------
1 | new EqualsHandler()]],
23 | [true, new Equals('value', 46), [Equals::class => new EqualsHandler()]],
24 | [false, new Equals('value', 45), [Equals::class => new EqualsHandler()]],
25 | [false, new Equals('value', '45'), [Equals::class => new EqualsHandler()]],
26 | ];
27 | }
28 |
29 | #[DataProvider('matchDataProvider')]
30 | public function testMatch(bool $expected, FilterInterface $filter, array $filterHandlers): void
31 | {
32 | $processor = new NotHandler();
33 |
34 | $item = [
35 | 'id' => 1,
36 | 'value' => 45,
37 | ];
38 |
39 | $this->assertSame($expected, $processor->match($item, new Not($filter), $filterHandlers));
40 | }
41 |
42 | public function testMatchFailIfFilterOperatorIsNotSupported(): void
43 | {
44 | $this->expectException(LogicException::class);
45 | $this->expectExceptionMessage('Filter "' . FilterWithoutHandler::class . '" is not supported.');
46 |
47 | (new NotHandler())->match(['id' => 1], new Not(new FilterWithoutHandler()), []);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/IterableDataReaderTest.php:
--------------------------------------------------------------------------------
1 | 1,
40 | 'name' => 'Codename Boris',
41 | ];
42 | private const ITEM_2 = [
43 | 'id' => 2,
44 | 'name' => 'Codename Doris',
45 | ];
46 | private const ITEM_3 = [
47 | 'id' => 3,
48 | 'name' => 'Agent K',
49 | ];
50 | private const ITEM_4 = [
51 | 'id' => 5,
52 | 'name' => 'Agent J',
53 | ];
54 | private const ITEM_5 = [
55 | 'id' => 6,
56 | 'name' => '007',
57 | ];
58 | private const DEFAULT_DATASET = [
59 | 0 => self::ITEM_1,
60 | 1 => self::ITEM_2,
61 | 2 => self::ITEM_3,
62 | 3 => self::ITEM_4,
63 | 4 => self::ITEM_5,
64 | ];
65 |
66 | public function testImmutability(): void
67 | {
68 | $reader = new IterableDataReader([]);
69 |
70 | $this->assertNotSame($reader, $reader->withAddedFilterHandlers());
71 | $this->assertNotSame($reader, $reader->withFilter(null));
72 | $this->assertNotSame($reader, $reader->withSort(null));
73 | $this->assertNotSame($reader, $reader->withOffset(1));
74 | $this->assertNotSame($reader, $reader->withLimit(1));
75 | }
76 |
77 | public function testExceptionOnPassingNonIterableFilters(): void
78 | {
79 | $nonIterableFilterHandler = new class () implements FilterHandlerInterface {
80 | public function getFilterClass(): string
81 | {
82 | return '?';
83 | }
84 | };
85 |
86 | $this->expectException(DataReaderException::class);
87 | $message = sprintf(
88 | '%s::withFilterHandlers() accepts instances of %s only.',
89 | IterableDataReader::class,
90 | IterableFilterHandlerInterface::class
91 | );
92 | $this->expectExceptionMessage($message);
93 |
94 | (new IterableDataReader([]))->withAddedFilterHandlers($nonIterableFilterHandler);
95 | }
96 |
97 | public function testWithLimitFailForNegativeValues(): void
98 | {
99 | $this->expectException(InvalidArgumentException::class);
100 | $this->expectExceptionMessage('The limit must not be less than 0.');
101 |
102 | (new IterableDataReader([]))->withLimit(-1);
103 | }
104 |
105 | public function testLimitIsApplied(): void
106 | {
107 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
108 | ->withLimit(5);
109 |
110 | $data = $reader->read();
111 |
112 | $this->assertCount(5, $data);
113 | $this->assertSame(array_slice(self::DEFAULT_DATASET, 0, 5), $data);
114 | }
115 |
116 | public function testOffsetIsApplied(): void
117 | {
118 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
119 | ->withOffset(2);
120 |
121 | $data = $reader->read();
122 |
123 | $this->assertCount(3, $data);
124 | $this->assertSame(
125 | [
126 | 2 => self::ITEM_3,
127 | 3 => self::ITEM_4,
128 | 4 => self::ITEM_5,
129 | ],
130 | $data
131 | );
132 | $this->assertSame(2, $reader->getOffset());
133 | }
134 |
135 | public function testAscSorting(): void
136 | {
137 | $sorting = Sort::only([
138 | 'id',
139 | 'name',
140 | ]);
141 |
142 | $sorting = $sorting->withOrder(['name' => 'asc']);
143 |
144 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
145 | ->withSort($sorting);
146 |
147 | $data = $reader->read();
148 |
149 | $this->assertSame($this->getDataSetAscSortedByName(), $data);
150 | }
151 |
152 | public function testDescSorting(): void
153 | {
154 | $sorting = Sort::only([
155 | 'id',
156 | 'name',
157 | ]);
158 |
159 | $sorting = $sorting->withOrder(['name' => 'desc']);
160 |
161 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
162 | ->withSort($sorting);
163 |
164 | $data = $reader->read();
165 |
166 | $this->assertSame($this->getDataSetDescSortedByName(), $data);
167 | }
168 |
169 | public function testCounting(): void
170 | {
171 | $reader = new IterableDataReader(self::DEFAULT_DATASET);
172 | $this->assertSame(5, $reader->count());
173 | $this->assertCount(5, $reader);
174 | }
175 |
176 | public function testCountWithLimit(): void
177 | {
178 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))->withLimit(2);
179 | $this->assertSame(5, $reader->count());
180 | $this->assertCount(5, $reader);
181 | }
182 |
183 | public function testCountWithOffset(): void
184 | {
185 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))->withOffset(3);
186 | $this->assertSame(5, $reader->count());
187 | $this->assertCount(5, $reader);
188 | }
189 |
190 | public function testReadOne(): void
191 | {
192 | $data = self::DEFAULT_DATASET;
193 | $reader = new IterableDataReader($data);
194 |
195 | $this->assertSame($data[0], $reader->readOne());
196 | }
197 |
198 | public function testReadOneWithSortingAndOffset(): void
199 | {
200 | $sorting = Sort::only(['id', 'name'])->withOrder(['name' => 'asc']);
201 |
202 | $data = self::DEFAULT_DATASET;
203 | $reader = (new IterableDataReader($data))
204 | ->withSort($sorting)
205 | ->withOffset(2);
206 |
207 | $this->assertSame($this->getDataSetAscSortedByName()[2], $reader->readOne());
208 | }
209 |
210 | public function testEqualsFiltering(): void
211 | {
212 | $filter = new Equals('id', 3);
213 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
214 | ->withFilter($filter);
215 |
216 | $this->assertSame([
217 | 2 => self::ITEM_3,
218 | ], $reader->read());
219 | }
220 |
221 | public function testGreaterThanFiltering(): void
222 | {
223 | $filter = new GreaterThan('id', 3);
224 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
225 | ->withFilter($filter);
226 |
227 | $this->assertSame([
228 | 3 => self::ITEM_4,
229 | 4 => self::ITEM_5,
230 | ], $reader->read());
231 | }
232 |
233 | public function testGreaterThanOrEqualFiltering(): void
234 | {
235 | $filter = new GreaterThanOrEqual('id', 3);
236 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
237 | ->withFilter($filter);
238 |
239 | $this->assertSame([
240 | 2 => self::ITEM_3,
241 | 3 => self::ITEM_4,
242 | 4 => self::ITEM_5,
243 | ], $reader->read());
244 | }
245 |
246 | public function testLessThanFiltering(): void
247 | {
248 | $filter = new LessThan('id', 3);
249 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
250 | ->withFilter($filter);
251 |
252 | $this->assertSame([
253 | 0 => self::ITEM_1,
254 | 1 => self::ITEM_2,
255 | ], $reader->read());
256 | }
257 |
258 | public function testLessThanOrEqualFiltering(): void
259 | {
260 | $filter = new LessThanOrEqual('id', 3);
261 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
262 | ->withFilter($filter);
263 |
264 | $this->assertSame([
265 | 0 => self::ITEM_1,
266 | 1 => self::ITEM_2,
267 | 2 => self::ITEM_3,
268 | ], $reader->read());
269 | }
270 |
271 | public function testInFiltering(): void
272 | {
273 | $filter = new In('id', [1, 2]);
274 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
275 | ->withFilter($filter);
276 |
277 | $this->assertSame([
278 | 0 => self::ITEM_1,
279 | 1 => self::ITEM_2,
280 | ], $reader->read());
281 | }
282 |
283 | public function testLikeFiltering(): void
284 | {
285 | $filter = new Like('name', 'Agent');
286 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
287 | ->withFilter($filter);
288 |
289 | $this->assertSame([
290 | 2 => self::ITEM_3,
291 | 3 => self::ITEM_4,
292 | ], $reader->read());
293 | }
294 |
295 | public function testNotFiltering(): void
296 | {
297 | $filter = new Not(new Equals('id', 1));
298 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
299 | ->withFilter($filter);
300 |
301 | $this->assertSame([
302 | 1 => self::ITEM_2,
303 | 2 => self::ITEM_3,
304 | 3 => self::ITEM_4,
305 | 4 => self::ITEM_5,
306 | ], $reader->read());
307 | }
308 |
309 | public function testAnyFiltering(): void
310 | {
311 | $filter = new Any(
312 | new Equals('id', 1),
313 | new Equals('id', 2)
314 | );
315 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
316 | ->withFilter($filter);
317 |
318 | $this->assertSame([
319 | 0 => self::ITEM_1,
320 | 1 => self::ITEM_2,
321 | ], $reader->read());
322 | }
323 |
324 | public function testAllFiltering(): void
325 | {
326 | $filter = new All(
327 | new GreaterThan('id', 3),
328 | new Like('name', 'Agent')
329 | );
330 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
331 | ->withFilter($filter);
332 |
333 | $this->assertSame([
334 | 3 => self::ITEM_4,
335 | ], $reader->read());
336 | }
337 |
338 | public function testLimitedSort(): void
339 | {
340 | $readerMin = (new IterableDataReader(self::DEFAULT_DATASET))
341 | ->withSort(
342 | Sort::only(['id'])->withOrder(['id' => 'asc'])
343 | )
344 | ->withLimit(1);
345 | $min = $readerMin->read()[0]['id'];
346 | $this->assertSame(1, $min, 'Wrong min value found');
347 |
348 | $readerMax = (new IterableDataReader(self::DEFAULT_DATASET))
349 | ->withSort(
350 | Sort::only(['id'])->withOrder(['id' => 'desc'])
351 | )
352 | ->withLimit(1);
353 | $max = $readerMax->readOne()['id'];
354 | $this->assertSame(6, $max, 'Wrong max value found');
355 | }
356 |
357 | public function testFilteredCount(): void
358 | {
359 | $reader = new IterableDataReader(self::DEFAULT_DATASET);
360 | $total = count($reader);
361 |
362 | $this->assertSame(5, $total, 'Wrong count of elements');
363 |
364 | $reader = $reader->withFilter(new Like('name', 'Agent'));
365 | $totalAgents = count($reader);
366 | $this->assertSame(2, $totalAgents, 'Wrong count of filtered elements');
367 | }
368 |
369 | public function testIteratorIteratorAsDataSet(): void
370 | {
371 | $reader = new IterableDataReader(new ArrayIterator(self::DEFAULT_DATASET));
372 | $sorting = Sort::only([
373 | 'id',
374 | 'name',
375 | ]);
376 | $sorting = $sorting->withOrder(['name' => 'asc']);
377 | $this->assertSame(
378 | $this->getDataSetAscSortedByName(),
379 | $reader
380 | ->withSort($sorting)
381 | ->read(),
382 | );
383 | }
384 |
385 | public function testGeneratorAsDataSet(): void
386 | {
387 | $reader = new IterableDataReader($this->getDataSetAsGenerator());
388 | $sorting = Sort::only([
389 | 'id',
390 | 'name',
391 | ]);
392 | $sorting = $sorting->withOrder(['name' => 'asc']);
393 | $this->assertSame(
394 | $this->getDataSetAscSortedByName(),
395 | $reader
396 | ->withSort($sorting)
397 | ->read(),
398 | );
399 | }
400 |
401 | public function testCustomFilter(): void
402 | {
403 | $filter = new All(new GreaterThan('id', 0), new Digital('name'));
404 | $reader = (new IterableDataReader(self::DEFAULT_DATASET))
405 | ->withAddedFilterHandlers(new DigitalHandler())
406 | ->withFilter($filter);
407 |
408 | $filtered = $reader->read();
409 |
410 | $this->assertSame([4 => self::ITEM_5], $filtered);
411 | $this->assertSame($filter, $reader->getFilter());
412 | }
413 |
414 | public function testCustomEqualsProcessor(): void
415 | {
416 | $sort = Sort::only(['id', 'name'])->withOrderString('id');
417 |
418 | $dataReader = (new IterableDataReader(self::DEFAULT_DATASET))
419 | ->withSort($sort)
420 | ->withAddedFilterHandlers(
421 | new class () implements IterableFilterHandlerInterface {
422 | public function getFilterClass(): string
423 | {
424 | return Equals::class;
425 | }
426 |
427 | public function match(
428 | array|object $item,
429 | FilterInterface $filter,
430 | array $iterableFilterHandlers
431 | ): bool {
432 | /** @var Equals $filter */
433 | return $item[$filter->getField()] === 2;
434 | }
435 | }
436 | );
437 |
438 | $dataReader = $dataReader->withFilter(new Equals('id', 100));
439 | $expected = [self::ITEM_2];
440 |
441 | $this->assertSame($expected, array_values($this->iterableToArray($dataReader->read())));
442 | }
443 |
444 | public function testNotSupportedFilter(): void
445 | {
446 | $dataReader = (new IterableDataReader(self::DEFAULT_DATASET))
447 | ->withFilter(new FilterWithoutHandler());
448 |
449 | $this->expectException(RuntimeException::class);
450 | $this->expectExceptionMessage('Filter "' . FilterWithoutHandler::class . '" is not supported.');
451 |
452 | $dataReader->read();
453 | }
454 |
455 | public function testArrayOfObjects(): void
456 | {
457 | $data = [
458 | 'one' => new class () {
459 | public int $a = 1;
460 | },
461 | 'two' => new class () {
462 | public int $a = 2;
463 | },
464 | 'three' => new class () {
465 | public int $a = 3;
466 | },
467 | ];
468 |
469 | $reader = new IterableDataReader($data);
470 |
471 | $rows = $reader->withFilter(new In('a', [2, 3]))->read();
472 |
473 | $this->assertSame(['two', 'three'], array_keys($rows));
474 | $this->assertSame(2, $rows['two']->a);
475 | $this->assertSame(3, $rows['three']->a);
476 | }
477 |
478 | public function testSortingWithSameValues(): void
479 | {
480 | $data = [
481 | 0 => ['value' => 1],
482 | 1 => ['value' => 2],
483 | 2 => ['value' => 3],
484 | 3 => ['value' => 2],
485 | ];
486 |
487 | $reader = (new IterableDataReader($data))
488 | ->withSort(
489 | Sort::any()->withOrder(['value' => 'asc'])
490 | );
491 |
492 | $this->assertSame(
493 | [
494 | 0 => ['value' => 1],
495 | 1 => ['value' => 2],
496 | 3 => ['value' => 2],
497 | 2 => ['value' => 3],
498 | ],
499 | $reader->read()
500 | );
501 | }
502 |
503 | public function testWithLimitZero(): void
504 | {
505 | $data = [
506 | 0 => ['value' => 1],
507 | 1 => ['value' => 2],
508 | 2 => ['value' => 3],
509 | 3 => ['value' => 2],
510 | ];
511 |
512 | $reader = (new IterableDataReader($data))
513 | ->withLimit(2)
514 | ->withLimit(0);
515 |
516 | $this->assertSame(
517 | [
518 | 0 => ['value' => 1],
519 | 1 => ['value' => 2],
520 | 2 => ['value' => 3],
521 | 3 => ['value' => 2],
522 | ],
523 | $reader->read()
524 | );
525 | }
526 |
527 | private function getDataSetAsGenerator(): Generator
528 | {
529 | yield from self::DEFAULT_DATASET;
530 | }
531 |
532 | private function getDataSetAscSortedByName(): array
533 | {
534 | return [
535 | 4 => self::ITEM_5,
536 | 3 => self::ITEM_4,
537 | 2 => self::ITEM_3,
538 | 0 => self::ITEM_1,
539 | 1 => self::ITEM_2,
540 | ];
541 | }
542 |
543 | private function getDataSetDescSortedByName(): array
544 | {
545 | return [
546 | 1 => self::ITEM_2,
547 | 0 => self::ITEM_1,
548 | 2 => self::ITEM_3,
549 | 3 => self::ITEM_4,
550 | 4 => self::ITEM_5,
551 | ];
552 | }
553 | }
554 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/ReaderWithFilter/ReaderTrait.php:
--------------------------------------------------------------------------------
1 | getReader()
22 | ->withFilter(
23 | new Any(
24 | new All(new GreaterThan('balance', 500), new LessThan('number', 5)),
25 | new Like('email', 'st'),
26 | )
27 | );
28 | $this->assertFixtures([3, 4], $reader->read());
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tests/Reader/Iterable/ReaderWithFilter/ReaderWithBetweenTest.php:
--------------------------------------------------------------------------------
1 | expectException(InvalidArgumentException::class);
17 | $this->expectExceptionMessage('Invalid config format.');
18 |
19 | Sort::only([
20 | 1 => [],
21 | ]);
22 | }
23 |
24 | public function testInvalidConfigWithoutConfig(): void
25 | {
26 | $this->expectException(InvalidArgumentException::class);
27 | $this->expectExceptionMessage('Invalid config format.');
28 |
29 | Sort::only([
30 | 'field' => 'whatever',
31 | ]);
32 | }
33 |
34 | public function testConfig(): void
35 | {
36 | $config = [
37 | 'a',
38 | 'b' => [
39 | 'default' => 'desc',
40 | ],
41 | 'name' => [
42 | 'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
43 | 'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
44 | 'default' => 'desc',
45 | ],
46 | ];
47 |
48 | $sortOnly = Sort::only($config);
49 | $sortAny = Sort::any($config);
50 |
51 | $expected = [
52 | 'a' => [
53 | 'asc' => [
54 | 'a' => SORT_ASC,
55 | ],
56 | 'desc' => [
57 | 'a' => SORT_DESC,
58 | ],
59 | 'default' => 'asc',
60 | ],
61 | 'b' => [
62 | 'asc' => [
63 | 'b' => SORT_ASC,
64 | ],
65 | 'desc' => [
66 | 'b' => SORT_DESC,
67 | ],
68 | 'default' => 'desc',
69 | ],
70 | 'name' => [
71 | 'asc' => ['first_name' => SORT_ASC, 'last_name' => SORT_ASC],
72 | 'desc' => ['first_name' => SORT_DESC, 'last_name' => SORT_DESC],
73 | 'default' => 'desc',
74 | ],
75 | ];
76 |
77 | $this->assertSame($expected, $this->getInaccessibleProperty($sortAny, 'config'));
78 | $this->assertSame($expected, $this->getInaccessibleProperty($sortOnly, 'config'));
79 | }
80 |
81 | public function testImmutability(): void
82 | {
83 | $sort = Sort::any();
84 | $this->assertNotSame($sort, $sort->withOrderString('a'));
85 | $this->assertNotSame($sort, $sort->withOrder([]));
86 | $this->assertNotSame($sort, $sort->withoutDefaultSorting());
87 | }
88 |
89 | public function testWithOrderString(): void
90 | {
91 | $sort = Sort::any()->withOrderString(' -a, b');
92 |
93 | $this->assertSame([
94 | 'a' => 'desc',
95 | 'b' => 'asc',
96 | ], $sort->getOrder());
97 | }
98 |
99 | public function testGetOrderAsString(): void
100 | {
101 | $sort = Sort::any()->withOrder([
102 | 'a' => 'desc',
103 | 'b' => 'asc',
104 | ]);
105 |
106 | $this->assertSame('-a,b', $sort->getOrderAsString());
107 | }
108 |
109 | public function testOnlyModeGetCriteriaWithEmptyConfig(): void
110 | {
111 | $sort = Sort::only([])->withOrder([
112 | 'a' => 'desc',
113 | 'b' => 'asc',
114 | ]);
115 |
116 | $this->assertSame([], $sort->getCriteria());
117 | }
118 |
119 | public function testAnyModeGetCriteriaWithEmptyConfig(): void
120 | {
121 | $sort = Sort::any()->withOrder([
122 | 'a' => 'desc',
123 | 'b' => 'asc',
124 | ]);
125 |
126 | $this->assertSame(
127 | [
128 | 'a' => SORT_DESC,
129 | 'b' => SORT_ASC,
130 | ],
131 | $sort->getCriteria(),
132 | );
133 | }
134 |
135 | public function testGetCriteria(): void
136 | {
137 | $sort = Sort::only([
138 | 'b' => [
139 | 'asc' => ['bee' => SORT_ASC],
140 | 'desc' => ['bee' => SORT_DESC],
141 | 'default' => 'asc',
142 | ],
143 | ])->withOrder([
144 | 'a' => 'desc',
145 | 'b' => 'asc',
146 | ]);
147 |
148 | $this->assertSame([
149 | 'bee' => SORT_ASC,
150 | ], $sort->getCriteria());
151 | }
152 |
153 | public function testAnyModeGetCriteriaWhenAnyFieldConflictsWithConfig(): void
154 | {
155 | $sort = Sort::any([
156 | 'a' => [
157 | 'asc' => ['foo' => 'asc'],
158 | 'desc' => ['foo' => 'desc'],
159 | 'default' => 'asc',
160 | ],
161 | 'b' => [
162 | 'asc' => ['bee' => 'asc'],
163 | 'desc' => ['bee' => 'desc'],
164 | 'default' => 'asc',
165 | ],
166 | ])->withOrderString('-bee,b,a,-foo');
167 |
168 | $this->assertSame(
169 | [
170 | 'bee' => SORT_ASC,
171 | 'foo' => SORT_DESC,
172 | ],
173 | $sort->getCriteria(),
174 | );
175 | }
176 |
177 | public function testGetCriteriaDefaults(): void
178 | {
179 | $sort = Sort::only([
180 | 'b' => [
181 | 'asc' => ['bee' => SORT_ASC],
182 | 'desc' => ['bee' => SORT_DESC],
183 | 'default' => 'desc',
184 | ],
185 | ])->withOrder([]);
186 |
187 | $this->assertSame([
188 | 'bee' => SORT_DESC,
189 | ], $sort->getCriteria());
190 | }
191 |
192 | public function testGetCriteriaDefaultsWithSimpleConfig(): void
193 | {
194 | $sort = Sort::only(['a', 'b'])->withOrder([]);
195 |
196 | $this->assertSame([], $sort->getOrder());
197 | }
198 |
199 | public function testGetCriteriaOrder(): void
200 | {
201 | $sort = Sort::only([
202 | 'b',
203 | 'c',
204 | ])->withOrder(['c' => 'desc']);
205 |
206 | $this->assertSame([
207 | 'c' => SORT_DESC,
208 | 'b' => SORT_ASC,
209 | ], $sort->getCriteria());
210 | }
211 |
212 | public function testGetCriteriaDefaultsWhenConfigIsNotComplete(): void
213 | {
214 | $sort = Sort::only([
215 | 'b' => [
216 | 'asc' => ['bee' => SORT_ASC],
217 | 'desc' => ['bee' => SORT_DESC],
218 | ],
219 | ])->withOrder([]);
220 |
221 | $this->assertSame([
222 | 'bee' => SORT_ASC,
223 | ], $sort->getCriteria());
224 | }
225 |
226 | public function testGetCriteriaWithShortFieldSyntax(): void
227 | {
228 | $sort = Sort::only([
229 | 'id',
230 | 'name',
231 | ])->withOrder(['name' => 'desc']);
232 |
233 | $this->assertSame([
234 | 'name' => SORT_DESC,
235 | 'id' => SORT_ASC,
236 | ], $sort->getCriteria());
237 | }
238 |
239 | public function testWithoutDefaultSortingWhenFormingCriteria(): void
240 | {
241 | $sort = Sort::only([
242 | 'a',
243 | 'b' => [
244 | 'asc' => ['bee' => SORT_ASC],
245 | 'desc' => ['bee' => SORT_DESC],
246 | 'default' => 'asc',
247 | ],
248 | ])
249 | ->withOrder(
250 | [
251 | 'b' => 'desc',
252 | ]
253 | )
254 | ->withoutDefaultSorting();
255 |
256 | $this->assertSame(
257 | [
258 | 'bee' => SORT_DESC,
259 | ],
260 | $sort->getCriteria()
261 | );
262 | }
263 |
264 | public function testIgnoreExtraFields(): void
265 | {
266 | $sort = Sort::only([
267 | 'a', 'b',
268 | ])->withOrder(['a' => 'desc', 'c' => 'asc', 'b' => 'desc']);
269 |
270 | $this->assertSame(
271 | [
272 | 'a' => SORT_DESC,
273 | 'b' => SORT_DESC,
274 | ],
275 | $sort->getCriteria()
276 | );
277 | }
278 |
279 | public static function dataPrepareConfig(): array
280 | {
281 | return [
282 | [
283 | [
284 | 'a' => SORT_ASC,
285 | 'b' => SORT_DESC,
286 | ],
287 | [
288 | 'a',
289 | 'b' => ['asc' => 'desc'],
290 | ],
291 | ],
292 | [
293 | [
294 | 'a' => SORT_ASC,
295 | ],
296 | [
297 | 'a' => [
298 | 'asc' => 'desc',
299 | 'desc' => 'asc',
300 | 'default' => 'desc',
301 | ],
302 | ],
303 | ],
304 | [
305 | [
306 | 'a' => SORT_ASC,
307 | ],
308 | [
309 | 'a' => [
310 | 'asc' => SORT_DESC,
311 | 'desc' => 'asc',
312 | 'default' => 'desc',
313 | ],
314 | ],
315 | ],
316 | [
317 | [
318 | 'x' => SORT_ASC,
319 | ],
320 | [
321 | 'a' => [
322 | 'default' => 'desc',
323 | 'desc' => ['x' => 'asc'],
324 | ],
325 | ],
326 | ],
327 | ];
328 | }
329 |
330 | #[DataProvider('dataPrepareConfig')]
331 | public function testPrepareConfig(array $expected, array $config): void
332 | {
333 | $sort = Sort::only($config);
334 | $this->assertSame($expected, $sort->getCriteria());
335 | }
336 |
337 | public function testHasFieldInConfig(): void
338 | {
339 | $sort = Sort::only(['a', 'b']);
340 |
341 | $this->assertTrue($sort->hasFieldInConfig('a'));
342 | $this->assertTrue($sort->hasFieldInConfig('b'));
343 | $this->assertFalse($sort->hasFieldInConfig('c'));
344 | }
345 | }
346 |
--------------------------------------------------------------------------------
/tests/Support/Car.php:
--------------------------------------------------------------------------------
1 | number;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tests/Support/CustomFilter/Digital.php:
--------------------------------------------------------------------------------
1 | field));
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tests/Support/CustomFilter/FilterWithoutHandler.php:
--------------------------------------------------------------------------------
1 | decorated = $this->decorated->withFilter($filter);
33 | return $new;
34 | }
35 |
36 | public function withAddedFilterHandlers(FilterHandlerInterface ...$filterHandlers): static
37 | {
38 | $new = clone $this;
39 | $new->decorated = $this->decorated->withAddedFilterHandlers(...$filterHandlers);
40 | return $new;
41 | }
42 |
43 | public function withLimit(?int $limit): static
44 | {
45 | $new = clone $this;
46 | $new->decorated = $this->decorated->withLimit($limit);
47 | return $new;
48 | }
49 |
50 | public function read(): iterable
51 | {
52 | return array_map($this->mutation, $this->decorated->read());
53 | }
54 |
55 | public function readOne(): array|object|null
56 | {
57 | return call_user_func($this->mutation, $this->decorated->readOne());
58 | }
59 |
60 | public function withSort(?Sort $sort): static
61 | {
62 | $new = clone $this;
63 | $new->decorated = $this->decorated->withSort($sort);
64 | return $new;
65 | }
66 |
67 | public function getSort(): ?Sort
68 | {
69 | return $this->decorated->getSort();
70 | }
71 |
72 | public function getFilter(): ?FilterInterface
73 | {
74 | return $this->decorated->getFilter();
75 | }
76 |
77 | public function getLimit(): ?int
78 | {
79 | return $this->decorated->getLimit();
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/tests/Support/StubOffsetData.php:
--------------------------------------------------------------------------------
1 | limit = $limit;
43 | return $new;
44 | }
45 |
46 | public function withOffset(int $offset): static
47 | {
48 | return $this;
49 | }
50 |
51 | public function getLimit(): ?int
52 | {
53 | return $this->limit;
54 | }
55 |
56 | public function getOffset(): int
57 | {
58 | return 0;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
1 | [true],
21 | 'bool-false' => [false],
22 | 'callback' => [fn () => null],
23 | 'float' => [1.0],
24 | 'int' => [1],
25 | 'null' => [null],
26 | 'string' => ['string'],
27 | 'object' => [new stdClass()],
28 | ];
29 | }
30 |
31 | public static function invalidFilterDataProvider(): array
32 | {
33 | return [
34 | 'callback' => [fn () => null],
35 | 'float' => [1.0],
36 | 'int' => [1],
37 | 'null' => [null],
38 | 'string' => ['string'],
39 | 'object' => [new stdClass()],
40 | ];
41 | }
42 |
43 | public static function invalidFilterOperatorDataProvider(): array
44 | {
45 | return [
46 | 'array' => [[[]]],
47 | 'callback' => [[fn () => null]],
48 | 'empty-string' => [['']],
49 | 'float' => [[1.0]],
50 | 'int' => [[1]],
51 | 'null' => [[null]],
52 | 'object' => [[new stdClass()]],
53 | ];
54 | }
55 |
56 | public static function invalidScalarValueDataProvider(): array
57 | {
58 | return [
59 | 'array' => [[]],
60 | 'callback' => [fn () => null],
61 | 'null' => [null],
62 | 'object' => [new stdClass()],
63 | ];
64 | }
65 |
66 | public static function scalarAndDataTimeInterfaceValueDataProvider(): array
67 | {
68 | return [
69 | 'bool-true' => [true],
70 | 'bool-false' => [false],
71 | 'float' => [1.1],
72 | 'int' => [1],
73 | 'string' => [''],
74 | DateTimeInterface::class => [new DateTimeImmutable()],
75 | ];
76 | }
77 |
78 | /**
79 | * Gets an inaccessible object property.
80 | */
81 | protected function getInaccessibleProperty(object $object, string $propertyName): mixed
82 | {
83 | $class = new ReflectionObject($object);
84 |
85 | while (!$class->hasProperty($propertyName)) {
86 | $class = $class->getParentClass();
87 | }
88 |
89 | return $class
90 | ->getProperty($propertyName)
91 | ->getValue($object);
92 | }
93 |
94 | /**
95 | * Invokes an inaccessible method.
96 | */
97 | protected function invokeMethod(object $object, string $method, array $args = []): mixed
98 | {
99 | return (new ReflectionObject($object))
100 | ->getMethod($method)
101 | ->invokeArgs($object, $args);
102 | }
103 |
104 | protected function iterableToArray(iterable $iterable): array
105 | {
106 | return $iterable instanceof Traversable ? iterator_to_array($iterable, true) : (array) $iterable;
107 | }
108 | }
109 |
--------------------------------------------------------------------------------