├── 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 | Yii 4 | 5 |

Yii Data

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/data/v)](https://packagist.org/packages/yiisoft/data) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/data/downloads)](https://packagist.org/packages/yiisoft/data) 11 | [![Build status](https://github.com/yiisoft/data/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/data/actions/workflows/build.yml) 12 | [![Code Coverage](https://codecov.io/gh/yiisoft/data/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/data) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fdata%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/data/master) 14 | [![static analysis](https://github.com/yiisoft/data/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/data/actions?query=workflow%3A%22static+analysis%22) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/data/coverage.svg)](https://shepherd.dev/github/yiisoft/data) 16 | [![psalm-level](https://shepherd.dev/github/yiisoft/data/level.svg)](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 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 380 | 381 | ## Follow updates 382 | 383 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 384 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 385 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 386 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 387 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](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 | --------------------------------------------------------------------------------