├── .codecov.yml ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── composer.json ├── config └── symfony │ ├── doctrine.php │ └── grid.php ├── phpstan.neon ├── phpunit.xml.dist ├── src ├── Collection.php ├── Collection │ ├── ArrayCollection.php │ ├── CallbackCollection.php │ ├── ChainCollection.php │ ├── Doctrine │ │ ├── Batch.php │ │ ├── Batch │ │ │ ├── BatchIterator.php │ │ │ ├── BatchProcessor.php │ │ │ ├── CountableBatchIterator.php │ │ │ └── CountableBatchProcessor.php │ │ ├── DoctrineBridgeCollection.php │ │ ├── DoctrineSpec.php │ │ ├── Grid │ │ │ └── ObjectGridDefinition.php │ │ ├── ORM │ │ │ ├── Bridge │ │ │ │ ├── ORMEntityRepository.php │ │ │ │ └── ORMServiceEntityRepository.php │ │ │ ├── EntityRepository.php │ │ │ ├── EntityRepositoryBridge.php │ │ │ ├── EntityRepositoryFactory.php │ │ │ ├── EntityResult.php │ │ │ ├── EntityResultQueryBuilder.php │ │ │ ├── EntityWithAggregates.php │ │ │ └── Specification │ │ │ │ ├── AntiJoin.php │ │ │ │ ├── Join.php │ │ │ │ └── QueryBuilderInterpreter.php │ │ ├── ObjectRepository.php │ │ ├── ObjectRepositoryFactory.php │ │ ├── Result.php │ │ └── Specification │ │ │ ├── Cache.php │ │ │ ├── CriteriaInterpreter.php │ │ │ ├── Delete.php │ │ │ ├── Instance.php │ │ │ └── Unwritable.php │ ├── Exception │ │ └── InvalidSpecification.php │ ├── FactoryCollection.php │ ├── Grid.php │ ├── Grid │ │ ├── Column.php │ │ ├── Columns.php │ │ ├── Definition │ │ │ └── ColumnDefinition.php │ │ ├── Filter.php │ │ ├── Filter │ │ │ ├── AutoFilter.php │ │ │ ├── Choice.php │ │ │ └── ChoiceFilter.php │ │ ├── Filters.php │ │ ├── GridBuilder.php │ │ ├── GridDefinition.php │ │ ├── Input.php │ │ ├── Input │ │ │ └── UriInput.php │ │ ├── PerPage.php │ │ └── PerPage │ │ │ ├── FixedPerPage.php │ │ │ ├── RangePerPage.php │ │ │ └── SetPerPage.php │ ├── IterableCollection.php │ ├── LazyCollection.php │ ├── Matchable.php │ ├── Page.php │ ├── Pagerfanta │ │ └── PagerfantaAdapter.php │ ├── Pages.php │ ├── Spec.php │ ├── Specification │ │ ├── Callback.php │ │ ├── Comparison.php │ │ ├── Field.php │ │ ├── Filter │ │ │ ├── Between.php │ │ │ ├── Contains.php │ │ │ ├── EndsWith.php │ │ │ ├── EqualTo.php │ │ │ ├── GreaterThan.php │ │ │ ├── GreaterThanOrEqualTo.php │ │ │ ├── In.php │ │ │ ├── IsNull.php │ │ │ ├── LessThan.php │ │ │ ├── LessThanOrEqualTo.php │ │ │ └── StartsWith.php │ │ ├── Logic │ │ │ ├── AndX.php │ │ │ ├── Composite.php │ │ │ ├── Not.php │ │ │ └── OrX.php │ │ ├── Nested.php │ │ ├── OrderBy.php │ │ └── Util.php │ └── Symfony │ │ ├── Attributes │ │ ├── AsGrid.php │ │ ├── ForDefinition.php │ │ └── ForObject.php │ │ ├── Doctrine │ │ └── ChainObjectRepositoryFactory.php │ │ ├── Grid │ │ └── GridFactory.php │ │ └── ZenstruckCollectionBundle.php └── functions.php └── templates └── Grid └── Pager ├── _full.html.twig └── _simple.html.twig /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 50% 11 | 12 | comment: false 13 | github_checks: 14 | annotations: false 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | /stubs export-ignore 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /phpunit.xml 3 | /vendor/ 4 | /build/ 5 | /var/ 6 | /.php-cs-fixer.cache 7 | /.phpunit.result.cache 8 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | =8.1" 16 | }, 17 | "require-dev": { 18 | "composer-runtime-api": "^2.0", 19 | "doctrine/collections": "^2.1", 20 | "doctrine/dbal": "^3.0|^4.0", 21 | "doctrine/doctrine-bundle": "^2.7", 22 | "doctrine/orm": "^2.15|^3.0", 23 | "pagerfanta/pagerfanta": "^1.0|^2.0|^3.0|^4.0", 24 | "phpstan/phpstan": "^2.1.17", 25 | "phpunit/phpunit": "^9.6.21", 26 | "symfony/expression-language": "^6.4|^7.0", 27 | "symfony/framework-bundle": "^6.4|^7.0", 28 | "symfony/phpunit-bridge": "^6.3|^7.0", 29 | "symfony/var-dumper": "^6.4|^7.0", 30 | "zenstruck/foundry": "^1.38.3", 31 | "zenstruck/uri": "^2.3" 32 | }, 33 | "suggest": { 34 | "doctrine/orm": "To use ORM implementation and batch utilities (>=2.15).", 35 | "doctrine/dbal": "To use DBAL implementation (>=3.0).", 36 | "doctrine/collections": "To use CollectionDecorator.", 37 | "pagerfanta/pagerfanta": "To use CollectionAdapter." 38 | }, 39 | "config": { 40 | "preferred-install": "dist", 41 | "sort-packages": true 42 | }, 43 | "autoload": { 44 | "psr-4": { "Zenstruck\\": ["src/"] }, 45 | "files": ["src/functions.php"] 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { "Zenstruck\\Collection\\Tests\\": ["tests/"] } 49 | }, 50 | "minimum-stability": "dev", 51 | "prefer-stable": true 52 | } 53 | -------------------------------------------------------------------------------- /config/symfony/doctrine.php: -------------------------------------------------------------------------------- 1 | services() 11 | ->set('.zenstruck_collection.doctrine.orm.object_repo_factory', EntityRepositoryFactory::class) 12 | ->args([service('doctrine')]) 13 | 14 | ->set('.zenstruck_collection.doctrine.chain_object_repo_factory', ChainObjectRepositoryFactory::class) 15 | ->args([service('.zenstruck_collection.doctrine.orm.object_repo_factory')]) 16 | ->tag('kernel.reset', ['method' => 'reset']) 17 | 18 | ->alias(ObjectRepositoryFactory::class, '.zenstruck_collection.doctrine.chain_object_repo_factory') 19 | ; 20 | }; 21 | -------------------------------------------------------------------------------- /config/symfony/grid.php: -------------------------------------------------------------------------------- 1 | services() 9 | ->set('.zenstruck_collection.grid_factory', GridFactory::class) 10 | ->args([ 11 | tagged_locator('zenstruck_collection.grid_definition', indexAttribute: 'key'), 12 | ]) 13 | 14 | ->alias(GridFactory::class, '.zenstruck_collection.grid_factory') 15 | ; 16 | }; 17 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | treatPhpDocTypesAsCertain: false 4 | paths: 5 | - src 6 | - stubs 7 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | tests 21 | 22 | 23 | 24 | 25 | 26 | src 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/Collection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck; 13 | 14 | use Zenstruck\Collection\ArrayCollection; 15 | use Zenstruck\Collection\Exception\InvalidSpecification; 16 | use Zenstruck\Collection\Page; 17 | use Zenstruck\Collection\Pages; 18 | 19 | /** 20 | * @author Kevin Bond 21 | * 22 | * @template V 23 | * @template K = array-key 24 | * @extends \IteratorAggregate 25 | * 26 | * @method static dump() 27 | * @method never dd() 28 | */ 29 | interface Collection extends \IteratorAggregate, \Countable 30 | { 31 | /** 32 | * @param mixed|callable(V,K):bool $specification 33 | * 34 | * @return self 35 | * 36 | * @throws InvalidSpecification if $specification is not valid 37 | */ 38 | public function filter(mixed $specification): self; 39 | 40 | /** 41 | * @template T 42 | * 43 | * @param callable(V,K):T $function 44 | * 45 | * @return self 46 | */ 47 | public function map(callable $function): self; 48 | 49 | /** 50 | * @template T 51 | * 52 | * @param callable(V,K):T $function 53 | * 54 | * @return self 55 | */ 56 | public function keyBy(callable $function): self; 57 | 58 | /** 59 | * @return self 60 | */ 61 | public function take(int $limit, int $offset = 0): self; 62 | 63 | /** 64 | * @template D 65 | * 66 | * @param D $default 67 | * 68 | * @return V|D 69 | */ 70 | public function first(mixed $default = null): mixed; 71 | 72 | /** 73 | * @template D 74 | * 75 | * @param mixed|callable(V,K):bool $specification 76 | * @param D $default 77 | * 78 | * @return V|D 79 | * 80 | * @throws InvalidSpecification if $specification is not a valid specification 81 | */ 82 | public function find(mixed $specification, mixed $default = null): mixed; 83 | 84 | /** 85 | * @template T 86 | * 87 | * @param callable(T,V,K):T $function 88 | * @param T $initial 89 | * 90 | * @return T 91 | */ 92 | public function reduce(callable $function, mixed $initial = null): mixed; 93 | 94 | public function isEmpty(): bool; 95 | 96 | /** 97 | * @return ArrayCollection 98 | */ 99 | public function eager(): ArrayCollection; 100 | 101 | /** 102 | * @param positive-int $page 103 | * @param positive-int $limit 104 | * 105 | * @return Page 106 | */ 107 | public function paginate(int $page = 1, int $limit = Page::DEFAULT_LIMIT): Page; 108 | 109 | /** 110 | * @param positive-int $limit 111 | * 112 | * @return Pages 113 | */ 114 | public function pages(int $limit = Page::DEFAULT_LIMIT): Pages; 115 | } 116 | -------------------------------------------------------------------------------- /src/Collection/ArrayCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | use Zenstruck\Collection\Exception\InvalidSpecification; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @immutable 21 | * 22 | * @template V 23 | * @template K of array-key = array-key 24 | * @implements Collection 25 | */ 26 | final class ArrayCollection implements Collection 27 | { 28 | /** @use IterableCollection */ 29 | use IterableCollection; 30 | 31 | /** @var array */ 32 | private array $source; 33 | 34 | /** 35 | * @param null|iterable|callable():iterable $source 36 | */ 37 | public function __construct(iterable|callable|null $source = null) 38 | { 39 | if (null === $source) { 40 | $source = []; 41 | } 42 | 43 | if (\is_callable($source) && !\is_iterable($source)) { 44 | $source = $source(); 45 | } 46 | 47 | $this->source = $source instanceof \Traversable ? \iterator_to_array($source) : $source; 48 | } 49 | 50 | /** 51 | * @param null|iterable|callable():iterable $source 52 | * 53 | * @return self 54 | */ 55 | public static function for(iterable|callable|null $source = null): self 56 | { 57 | return new self($source); 58 | } 59 | 60 | /** 61 | * @return self 62 | */ 63 | public static function wrap(mixed $value): self 64 | { 65 | if (null === $value) { 66 | $value = []; 67 | } 68 | 69 | return new self(\is_iterable($value) ? $value : [$value]); 70 | } 71 | 72 | /** 73 | * Create instance using {@see explode()}. 74 | * 75 | * Normalizes empty result into empty array: [''] => []. 76 | * 77 | * @param non-empty-string $separator 78 | * 79 | * @return self 80 | */ 81 | public static function explode(string $separator, string $string, ?int $limit = null): self 82 | { 83 | $exploded = null === $limit ? \explode($separator, $string) : \explode($separator, $string, $limit); 84 | 85 | return new self($exploded === [''] ? [] : $exploded); 86 | } 87 | 88 | /** 89 | * Create instance using {@see range()}. 90 | * 91 | * @template T of int|string|float 92 | * 93 | * @param T $start 94 | * @param T $end 95 | * 96 | * @return self 97 | */ 98 | public static function range(int|string|float $start, int|string|float $end, int|float $step = 1): self 99 | { 100 | return new self(\range($start, $end, $step)); 101 | } 102 | 103 | /** 104 | * Create instance using {@see array_fill()}. 105 | * 106 | * @template T 107 | * 108 | * @param T $value 109 | * 110 | * @return self 111 | */ 112 | public static function fill(int $start, int $count, mixed $value): self 113 | { 114 | return new self(\array_fill($start, $count, $value)); 115 | } 116 | 117 | public function first(mixed $default = null): mixed 118 | { 119 | return $this->source[\array_key_first($this->source)] ?? $default; 120 | } 121 | 122 | /** 123 | * @return self 124 | */ 125 | public function take(int $limit, int $offset = 0): self 126 | { 127 | return $this->slice($offset, $limit); 128 | } 129 | 130 | /** 131 | * @return array 132 | */ 133 | public function all(): array 134 | { 135 | return $this->source; 136 | } 137 | 138 | /** 139 | * @return self 140 | */ 141 | public function keys(): self 142 | { 143 | return new self(\array_keys($this->source)); 144 | } 145 | 146 | /** 147 | * @return self 148 | */ 149 | public function values(): self 150 | { 151 | return new self(\array_values($this->source)); 152 | } 153 | 154 | /** 155 | * @return self 156 | */ 157 | public function reverse(): self 158 | { 159 | return new self(\array_reverse($this->source, true)); 160 | } 161 | 162 | /** 163 | * @return self 164 | */ 165 | public function slice(int $offset, ?int $length = null): self 166 | { 167 | return new self(\array_slice($this->source, $offset, $length, true)); 168 | } 169 | 170 | /** 171 | * @param iterable ...$with 172 | * 173 | * @return self 174 | */ 175 | public function merge(iterable ...$with): self 176 | { 177 | return new self( 178 | \array_merge($this->source, ...\array_map(static fn(iterable $x) => self::for($x)->source, $with)), 179 | ); 180 | } 181 | 182 | /** 183 | * @param null|callable(V,K):bool $specification 184 | * 185 | * @return self 186 | */ 187 | public function filter(mixed $specification = null): self 188 | { 189 | if (null !== $specification && !\is_callable($specification)) { 190 | throw InvalidSpecification::build($specification, self::class, 'filter', 'Only null|callable(V,K):bool is supported.'); 191 | } 192 | 193 | return new self(\array_filter($this->source, $specification, \ARRAY_FILTER_USE_BOTH)); 194 | } 195 | 196 | /** 197 | * @template T of array-key|\Stringable 198 | * 199 | * @param callable(V,K):T $function 200 | * 201 | * @return self 202 | */ 203 | public function keyBy(callable $function): self 204 | { 205 | $results = []; 206 | 207 | foreach ($this->source as $key => $value) { 208 | $key = $function($value, $key); 209 | 210 | $results[$key instanceof \Stringable ? (string) $key : $key] = $value; 211 | } 212 | 213 | return new self($results); 214 | } 215 | 216 | /** 217 | * @template T 218 | * 219 | * @param callable(V,K):T $function 220 | * 221 | * @return self 222 | */ 223 | public function map(callable $function): self 224 | { 225 | $keys = \array_keys($this->source); 226 | 227 | return new self(\array_combine($keys, \array_map($function, $this->source, $keys))); 228 | } 229 | 230 | /** 231 | * @return self 232 | */ 233 | public function sort(int|callable $flags = \SORT_REGULAR): self 234 | { 235 | $items = $this->source; 236 | \is_callable($flags) ? \uasort($items, $flags) : \asort($items, $flags); 237 | 238 | return new self($items); 239 | } 240 | 241 | /** 242 | * @return self 243 | */ 244 | public function sortDesc(int|callable $flags = \SORT_REGULAR): self 245 | { 246 | return $this->sort($flags)->reverse(); 247 | } 248 | 249 | /** 250 | * @param callable(V,K):mixed $function 251 | * 252 | * @return self 253 | */ 254 | public function sortBy(callable $function, int $flags = \SORT_REGULAR): self 255 | { 256 | $results = []; 257 | 258 | // calculate comparator 259 | foreach ($this->source as $key => $value) { 260 | $results[$key] = $function($value, $key); 261 | } 262 | 263 | \asort($results, $flags); 264 | 265 | foreach (\array_keys($results) as $key) { 266 | $results[$key] = $this->source[$key]; 267 | } 268 | 269 | return new self($results); 270 | } 271 | 272 | /** 273 | * @param callable(V,K):mixed $function 274 | * 275 | * @return self 276 | */ 277 | public function sortByDesc(callable $function, int $flags = \SORT_REGULAR): self 278 | { 279 | return $this->sortBy($function, $flags)->reverse(); 280 | } 281 | 282 | /** 283 | * @return self 284 | */ 285 | public function sortKeys(int $flags = \SORT_REGULAR): self 286 | { 287 | $items = $this->source; 288 | 289 | \ksort($items, $flags); 290 | 291 | return new self($items); 292 | } 293 | 294 | /** 295 | * @return self 296 | */ 297 | public function sortKeysDesc(int $flags = \SORT_REGULAR): self 298 | { 299 | $items = $this->source; 300 | 301 | \krsort($items, $flags); 302 | 303 | return new self($items); 304 | } 305 | 306 | /** 307 | * @template T 308 | * 309 | * @param iterable $values 310 | * 311 | * @return self 312 | */ 313 | public function combine(iterable $values): self 314 | { 315 | return new self(\array_combine($this->source, self::for($values)->source)); // @phpstan-ignore return.type, argument.type 316 | } 317 | 318 | /** 319 | * @return self 320 | */ 321 | public function combineWithSelf(): self 322 | { 323 | return new self(\array_combine($this->source, $this->source)); 324 | } 325 | 326 | /** 327 | * @template T of array-key|\Stringable 328 | * 329 | * @param callable(V,K):T $function 330 | * 331 | * @return self> 332 | */ 333 | public function groupBy(callable $function): self 334 | { 335 | $results = []; 336 | 337 | foreach ($this->source as $key => $value) { 338 | $newKey = $function($value, $key); 339 | 340 | $results[$newKey instanceof \Stringable ? (string) $newKey : $newKey][] = $value; 341 | } 342 | 343 | return new self($results); 344 | } 345 | 346 | /** 347 | * @template D 348 | * 349 | * @param K $key 350 | * @param D $default 351 | * 352 | * @return V|D 353 | */ 354 | public function get(int|string $key, mixed $default = null): mixed 355 | { 356 | return $this->has($key) ? $this->source[$key] : $default; 357 | } 358 | 359 | /** 360 | * @param K $key 361 | * @param V $value 362 | * 363 | * @return self 364 | */ 365 | public function set(int|string $key, mixed $value): self 366 | { 367 | $clone = clone $this; 368 | $clone->source[$key] = $value; // @phpstan-ignore property.readOnlyByPhpDocAssignNotInConstructor 369 | 370 | return $clone; 371 | } 372 | 373 | /** 374 | * @param K ...$keys 375 | * 376 | * @return self 377 | */ 378 | public function unset(int|string ...$keys): self 379 | { 380 | $clone = clone $this; 381 | 382 | foreach ($keys as $key) { 383 | unset($clone->source[$key]); // @phpstan-ignore property.readOnlyByPhpDocAssignNotInConstructor 384 | } 385 | 386 | return $clone; 387 | } 388 | 389 | /** 390 | * @param K ...$keys 391 | * 392 | * @return self 393 | */ 394 | public function only(int|string ...$keys): self 395 | { 396 | return new self(\array_intersect_key($this->source, \array_flip($keys))); 397 | } 398 | 399 | /** 400 | * @param V ...$values 401 | * 402 | * @return self 403 | */ 404 | public function push(mixed ...$values): self 405 | { 406 | $clone = clone $this; 407 | 408 | foreach ($values as $value) { 409 | $clone->source[] = $value; // @phpstan-ignore property.readOnlyByPhpDocAssignNotInConstructor 410 | } 411 | 412 | return $clone; 413 | } 414 | 415 | /** 416 | * @param V $needle 417 | */ 418 | public function contains(mixed $needle): bool 419 | { 420 | return \in_array($needle, $this->source, true); 421 | } 422 | 423 | /** 424 | * @param K $key 425 | */ 426 | public function has(string|int $key): bool 427 | { 428 | return \array_key_exists($key, $this->source); 429 | } 430 | 431 | public function implode(string $separator = ''): string 432 | { 433 | return \implode($separator, $this->source); 434 | } 435 | 436 | public function eager(): self 437 | { 438 | return $this; 439 | } 440 | 441 | public function getIterator(): \Traversable 442 | { 443 | return new \ArrayIterator($this->source); 444 | } 445 | 446 | public function count(): int 447 | { 448 | return \count($this->source); 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /src/Collection/CallbackCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @template V 20 | * @template K = array-key 21 | * @implements Collection 22 | */ 23 | final class CallbackCollection implements Collection 24 | { 25 | /** @use IterableCollection */ 26 | use IterableCollection; 27 | 28 | /** @var LazyCollection */ 29 | private LazyCollection $iterator; 30 | private \Closure $count; 31 | 32 | public function __construct(callable $iterator, callable $count) 33 | { 34 | $this->iterator = new LazyCollection($iterator); 35 | $this->count = $count(...); 36 | } 37 | 38 | public function getIterator(): \Traversable 39 | { 40 | return $this->iterator; 41 | } 42 | 43 | public function count(): int 44 | { 45 | return ($this->count)(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Collection/ChainCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @template V 20 | * @template K = array-key 21 | * @implements Collection 22 | */ 23 | final class ChainCollection implements Collection 24 | { 25 | /** @use IterableCollection */ 26 | use IterableCollection; 27 | 28 | /** @var Collection,int> */ 29 | private Collection $collections; 30 | 31 | /** 32 | * @param iterable> $collections 33 | * @param bool $preserveKeys Whether to preserve the keys of the inner collections 34 | * when iterating. 35 | * !NOTE! data may be lost when converting to array 36 | * if inner collections have duplicated keys. 37 | */ 38 | public function __construct(iterable $collections, private bool $preserveKeys = false) 39 | { 40 | $this->collections = $collections instanceof Collection ? $collections : new LazyCollection($collections); 41 | } 42 | 43 | public function getIterator(): \Traversable 44 | { 45 | foreach ($this->collections as $collection) { 46 | if ($this->preserveKeys) { 47 | yield from $collection; 48 | 49 | continue; 50 | } 51 | 52 | foreach ($collection as $item) { 53 | yield $item; // @phpstan-ignore generator.keyType 54 | } 55 | } 56 | } 57 | 58 | public function count(): int 59 | { 60 | return $this->collections->reduce(fn(int $r, Collection $c) => $r + $c->count(), 0); // @phpstan-ignore return.type 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Batch.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine; 13 | 14 | use Doctrine\ORM\Query; 15 | use Doctrine\ORM\QueryBuilder; 16 | use Doctrine\ORM\Tools\Pagination\Paginator; 17 | use Doctrine\Persistence\ObjectManager; 18 | use Zenstruck\Collection\Doctrine\Batch\BatchIterator; 19 | use Zenstruck\Collection\Doctrine\Batch\BatchProcessor; 20 | use Zenstruck\Collection\Doctrine\Batch\CountableBatchIterator; 21 | use Zenstruck\Collection\Doctrine\Batch\CountableBatchProcessor; 22 | 23 | /** 24 | * @author Kevin Bond 25 | */ 26 | final class Batch 27 | { 28 | private function __construct() 29 | { 30 | } 31 | 32 | /** 33 | * @template V 34 | * 35 | * @param iterable $items 36 | * 37 | * @return \Traversable 38 | */ 39 | public static function iterate(iterable $items, ObjectManager $om, int $chunkSize = 100): \Traversable 40 | { 41 | if (\is_countable($items)) { 42 | return new CountableBatchIterator($items, $om, $chunkSize); 43 | } 44 | 45 | return new BatchIterator($items, $om, $chunkSize); 46 | } 47 | 48 | /** 49 | * @template V 50 | * 51 | * @param iterable $items 52 | * 53 | * @return \Traversable 54 | */ 55 | public static function process(iterable $items, ObjectManager $om, int $chunkSize = 100): \Traversable 56 | { 57 | if (\is_countable($items)) { 58 | return new CountableBatchProcessor($items, $om, $chunkSize); 59 | } 60 | 61 | return new BatchProcessor($items, $om, $chunkSize); 62 | } 63 | 64 | /** 65 | * @return \Traversable 66 | */ 67 | public static function iteratorFor(Query|QueryBuilder $query, int $chunkSize = 100): \Traversable 68 | { 69 | if ($query instanceof QueryBuilder) { 70 | $query = $query->getQuery(); 71 | } 72 | 73 | return new CountableBatchIterator(new Paginator($query), $query->getEntityManager(), $chunkSize); 74 | } 75 | 76 | /** 77 | * @return \Traversable 78 | */ 79 | public static function processorFor(Query|QueryBuilder $query, int $chunkSize = 100): \Traversable 80 | { 81 | if ($query instanceof QueryBuilder) { 82 | $query = $query->getQuery(); 83 | } 84 | 85 | return new CountableBatchProcessor(new Paginator($query), $query->getEntityManager(), $chunkSize); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Batch/BatchIterator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Batch; 13 | 14 | use Doctrine\Persistence\ObjectManager; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @internal 20 | * 21 | * @template V 22 | * @implements \IteratorAggregate 23 | */ 24 | class BatchIterator implements \IteratorAggregate 25 | { 26 | /** 27 | * @param iterable $items 28 | */ 29 | public function __construct(protected readonly iterable $items, private ObjectManager $om, private int $chunkSize = 100) 30 | { 31 | } 32 | 33 | final public function getIterator(): \Traversable 34 | { 35 | $iteration = 0; 36 | 37 | foreach ($this->items as $key => $value) { 38 | yield $key => $value; 39 | 40 | if (++$iteration % $this->chunkSize) { 41 | continue; 42 | } 43 | 44 | $this->om->clear(); 45 | } 46 | 47 | $this->om->clear(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Batch/BatchProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Batch; 13 | 14 | use Doctrine\ORM\EntityManagerInterface; 15 | use Doctrine\Persistence\ObjectManager; 16 | 17 | /** 18 | * @author Marco Pivetta 19 | * @author Kevin Bond 20 | * 21 | * @internal 22 | * 23 | * @template V 24 | * @implements \IteratorAggregate 25 | */ 26 | class BatchProcessor implements \IteratorAggregate 27 | { 28 | /** 29 | * @param iterable $items 30 | */ 31 | public function __construct(protected readonly iterable $items, private ObjectManager $om, private int $chunkSize = 100) 32 | { 33 | } 34 | 35 | final public function getIterator(): \Traversable 36 | { 37 | if ($this->om instanceof EntityManagerInterface) { 38 | $this->om->beginTransaction(); 39 | } 40 | 41 | $iteration = 0; 42 | 43 | try { 44 | foreach ($this->items as $key => $value) { 45 | yield $key => $value; 46 | 47 | $this->flushAndClearBatch(++$iteration); 48 | } 49 | } catch (\Throwable $e) { 50 | if ($this->om instanceof EntityManagerInterface) { 51 | $this->om->rollback(); 52 | } 53 | 54 | throw $e; 55 | } 56 | 57 | $this->flushAndClear(); 58 | 59 | if ($this->om instanceof EntityManagerInterface) { 60 | $this->om->commit(); 61 | } 62 | } 63 | 64 | private function flushAndClearBatch(int $iteration): void 65 | { 66 | if ($iteration % $this->chunkSize) { 67 | return; 68 | } 69 | 70 | $this->flushAndClear(); 71 | } 72 | 73 | private function flushAndClear(): void 74 | { 75 | $this->om->flush(); 76 | $this->om->clear(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Batch/CountableBatchIterator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Batch; 13 | 14 | use Doctrine\Persistence\ObjectManager; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @internal 20 | * 21 | * @template V 22 | * @extends BatchIterator 23 | */ 24 | final class CountableBatchIterator extends BatchIterator implements \Countable 25 | { 26 | /** 27 | * @param array|(iterable&\Countable) $items 28 | */ 29 | public function __construct(iterable $items, ObjectManager $om, int $chunkSize = 100) 30 | { 31 | parent::__construct($items, $om, $chunkSize); 32 | } 33 | 34 | public function count(): int 35 | { 36 | return \is_countable($this->items) ? \count($this->items) : throw new \LogicException('Not countable.'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Batch/CountableBatchProcessor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Batch; 13 | 14 | use Doctrine\Persistence\ObjectManager; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @internal 20 | * 21 | * @template V 22 | * @extends BatchProcessor 23 | */ 24 | final class CountableBatchProcessor extends BatchProcessor implements \Countable 25 | { 26 | /** 27 | * @param array|(iterable&\Countable) $items 28 | */ 29 | public function __construct(iterable $items, ObjectManager $om, int $chunkSize = 100) 30 | { 31 | parent::__construct($items, $om, $chunkSize); 32 | } 33 | 34 | public function count(): int 35 | { 36 | return \is_countable($this->items) ? \count($this->items) : throw new \LogicException('Not countable.'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/DoctrineBridgeCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine; 13 | 14 | use Doctrine\Common\Collections\AbstractLazyCollection; 15 | use Doctrine\Common\Collections\ArrayCollection as DoctrineArrayCollection; 16 | use Doctrine\Common\Collections\Collection as DoctrineCollection; 17 | use Doctrine\Common\Collections\Criteria; 18 | use Doctrine\Common\Collections\Selectable; 19 | use Zenstruck\Collection; 20 | use Zenstruck\Collection\ArrayCollection; 21 | use Zenstruck\Collection\Doctrine\Specification\CriteriaInterpreter; 22 | use Zenstruck\Collection\IterableCollection; 23 | use Zenstruck\Collection\Matchable; 24 | 25 | /** 26 | * @author Kevin Bond 27 | * 28 | * @template V 29 | * @template K of array-key = array-key 30 | * @implements Collection 31 | * @implements DoctrineCollection 32 | * @implements Matchable 33 | */ 34 | final class DoctrineBridgeCollection implements Collection, DoctrineCollection, Matchable 35 | { 36 | /** @use IterableCollection */ 37 | use IterableCollection { 38 | map as private innerMap; 39 | reduce as private innerReduce; 40 | find as private innerFind; 41 | } 42 | 43 | /** @var DoctrineCollection */ 44 | private DoctrineCollection $inner; 45 | 46 | /** 47 | * @param iterable|DoctrineCollection|null $source 48 | */ 49 | public function __construct(iterable|DoctrineCollection|null $source = []) 50 | { 51 | if (null === $source) { 52 | $source = []; 53 | } 54 | 55 | if (!$source instanceof DoctrineCollection) { 56 | $source = new DoctrineArrayCollection(\is_array($source) ? $source : \iterator_to_array($source)); 57 | } 58 | 59 | $this->inner = $source; 60 | } 61 | 62 | public function first(mixed $default = null): mixed 63 | { 64 | if ($this->inner instanceof AbstractLazyCollection && !$this->inner->isInitialized()) { 65 | return $this->slice(0, 1)[0] ?? $default; 66 | } 67 | 68 | return $this->inner->first() ?? $default; // @phpstan-ignore return.type 69 | } 70 | 71 | public function findFirst(\Closure $p): mixed 72 | { 73 | if (\method_exists($this->inner, 'findFirst')) { 74 | return $this->inner->findFirst($p); 75 | } 76 | 77 | throw new \LogicException(\sprintf('Method "%s::findFirst()" not available. Try upgrading to doctrine/collections 2.0+.', $this->inner::class)); 78 | } 79 | 80 | /** 81 | * @param Criteria|callable(V,K):bool $specification 82 | */ 83 | public function find(mixed $specification, mixed $default = null): mixed 84 | { 85 | if ($specification instanceof Criteria) { 86 | return $this->filter($specification->setMaxResults(1))->first($default); 87 | } 88 | 89 | if (!\is_callable($specification)) { 90 | return $this->find(CriteriaInterpreter::interpret($specification, self::class, 'find'), $default); 91 | } 92 | 93 | return $this->innerFind($specification, $default); 94 | } 95 | 96 | /** 97 | * @param Criteria|callable(V,K):bool $specification 98 | * 99 | * @return self 100 | */ 101 | public function filter(mixed $specification): self 102 | { 103 | if ($this->inner instanceof Selectable && $specification instanceof Criteria) { 104 | return new self($this->inner->matching($specification)); 105 | } 106 | 107 | if ($this->inner instanceof Criteria) { 108 | throw new \LogicException(\sprintf('"%s" is not an instance of "%s". Cannot use Criteria as a specification.', $this->inner::class, Selectable::class)); 109 | } 110 | 111 | if (!\is_callable($specification)) { 112 | return $this->filter(CriteriaInterpreter::interpret($specification, self::class, 'filter')); 113 | } 114 | 115 | return new self($this->inner->filter($specification(...))); 116 | } 117 | 118 | public function reduce(\Closure|callable $function, mixed $initial = null): mixed 119 | { 120 | return $this->innerReduce($function, $initial); 121 | } 122 | 123 | /** 124 | * @return self 125 | */ 126 | public function map(\Closure|callable $function): self 127 | { 128 | return new self($this->innerMap($function)); // @phpstan-ignore return.type 129 | } 130 | 131 | /** 132 | * @return self 133 | */ 134 | public function take(int $limit, int $offset = 0): self 135 | { 136 | return new self($this->slice($offset, $limit)); 137 | } 138 | 139 | public function isEmpty(): bool 140 | { 141 | return $this->inner->isEmpty(); 142 | } 143 | 144 | public function eager(): ArrayCollection 145 | { 146 | return new ArrayCollection($this->inner->toArray()); 147 | } 148 | 149 | public function add($element): void 150 | { 151 | $this->inner->add($element); 152 | } 153 | 154 | public function clear(): void 155 | { 156 | $this->inner->clear(); 157 | } 158 | 159 | public function remove($key): mixed 160 | { 161 | return $this->inner->remove($key); 162 | } 163 | 164 | public function removeElement($element): bool 165 | { 166 | return $this->inner->removeElement($element); 167 | } 168 | 169 | public function set($key, $value): void 170 | { 171 | $this->inner->set($key, $value); 172 | } 173 | 174 | public function partition(\Closure $p): array 175 | { 176 | return $this->inner->partition($p); 177 | } 178 | 179 | public function offsetExists(mixed $offset): bool 180 | { 181 | return $this->inner->offsetExists($offset); 182 | } 183 | 184 | public function offsetGet(mixed $offset): mixed 185 | { 186 | return $this->inner->offsetGet($offset); 187 | } 188 | 189 | public function offsetSet(mixed $offset, mixed $value): void 190 | { 191 | $this->inner->offsetSet($offset, $value); 192 | } 193 | 194 | public function offsetUnset(mixed $offset): void 195 | { 196 | $this->inner->offsetUnset($offset); 197 | } 198 | 199 | public function count(): int 200 | { 201 | return $this->inner->count(); 202 | } 203 | 204 | public function contains($element): bool 205 | { 206 | return $this->inner->contains($element); 207 | } 208 | 209 | public function containsKey($key): bool 210 | { 211 | return $this->inner->containsKey($key); 212 | } 213 | 214 | public function get($key): mixed 215 | { 216 | return $this->inner->get($key); 217 | } 218 | 219 | public function getKeys(): array 220 | { 221 | return $this->inner->getKeys(); 222 | } 223 | 224 | public function getValues(): array 225 | { 226 | return $this->inner->getValues(); 227 | } 228 | 229 | public function toArray(): array 230 | { 231 | return $this->inner->toArray(); 232 | } 233 | 234 | public function last(): mixed 235 | { 236 | return $this->inner->last(); 237 | } 238 | 239 | public function key(): mixed 240 | { 241 | return $this->inner->key(); 242 | } 243 | 244 | public function current(): mixed 245 | { 246 | return $this->inner->current(); 247 | } 248 | 249 | public function next(): mixed 250 | { 251 | return $this->inner->next(); 252 | } 253 | 254 | public function slice($offset, $length = null): array 255 | { 256 | return $this->inner->slice($offset, $length); 257 | } 258 | 259 | public function exists(\Closure $p): bool 260 | { 261 | return $this->inner->exists($p); 262 | } 263 | 264 | public function forAll(\Closure $p): bool 265 | { 266 | return $this->inner->forAll($p); 267 | } 268 | 269 | public function indexOf($element): mixed 270 | { 271 | return $this->inner->indexOf($element); 272 | } 273 | 274 | public function getIterator(): \Traversable 275 | { 276 | if ($this->inner instanceof AbstractLazyCollection && !$this->inner->isInitialized()) { 277 | foreach ($this->pages() as $page) { 278 | yield from $page; 279 | } 280 | 281 | return; 282 | } 283 | 284 | yield from $this->inner->getIterator(); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/DoctrineSpec.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine; 13 | 14 | use Zenstruck\Collection\Doctrine\ORM\Specification\AntiJoin; 15 | use Zenstruck\Collection\Doctrine\ORM\Specification\Join; 16 | use Zenstruck\Collection\Doctrine\Specification\Cache; 17 | use Zenstruck\Collection\Doctrine\Specification\Delete; 18 | use Zenstruck\Collection\Doctrine\Specification\Instance; 19 | use Zenstruck\Collection\Doctrine\Specification\Unwritable; 20 | use Zenstruck\Collection\Spec; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | final class DoctrineSpec extends Spec 26 | { 27 | public static function readonly(): Unwritable 28 | { 29 | return new Unwritable(); 30 | } 31 | 32 | public static function delete(): Delete 33 | { 34 | return new Delete(); 35 | } 36 | 37 | public static function cache(?int $lifetime = null, ?string $key = null): Cache 38 | { 39 | return new Cache($lifetime, $key); 40 | } 41 | 42 | /** 43 | * @param class-string $class 44 | */ 45 | public static function instanceOf(string $class): Instance 46 | { 47 | return new Instance($class); 48 | } 49 | 50 | public static function innerJoin(string $field, ?string $alias = null): Join 51 | { 52 | return Join::inner($field, $alias); 53 | } 54 | 55 | public static function leftJoin(string $field, ?string $alias = null): Join 56 | { 57 | return Join::left($field, $alias); 58 | } 59 | 60 | public static function antiJoin(string $field): AntiJoin 61 | { 62 | return new AntiJoin($field); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Grid/ObjectGridDefinition.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Grid; 13 | 14 | use Zenstruck\Collection\Doctrine\ObjectRepositoryFactory; 15 | use Zenstruck\Collection\Grid\GridBuilder; 16 | use Zenstruck\Collection\Grid\GridDefinition; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @implements GridDefinition 22 | */ 23 | final class ObjectGridDefinition implements GridDefinition 24 | { 25 | /** 26 | * @param class-string $class 27 | * @param GridDefinition $inner 28 | */ 29 | public function __construct( 30 | private string $class, 31 | private ObjectRepositoryFactory $repositoryFactory, 32 | private GridDefinition $inner, 33 | ) { 34 | } 35 | 36 | public function configure(GridBuilder $builder): void 37 | { 38 | $this->inner->configure($builder); 39 | 40 | if ($builder->source) { 41 | return; 42 | } 43 | 44 | $builder->source = $this->repositoryFactory->create($this->class); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/Bridge/ORMEntityRepository.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM\Bridge; 13 | 14 | use Doctrine\ORM\EntityRepository; 15 | use Zenstruck\Collection\Doctrine\ObjectRepository; 16 | use Zenstruck\Collection\Doctrine\ORM\EntityRepositoryBridge; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @template V of object 22 | * @extends EntityRepository 23 | * @implements ObjectRepository 24 | */ 25 | class ORMEntityRepository extends EntityRepository implements ObjectRepository 26 | { 27 | /** @use EntityRepositoryBridge */ 28 | use EntityRepositoryBridge; 29 | } 30 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/Bridge/ORMServiceEntityRepository.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM\Bridge; 13 | 14 | use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; 15 | use Zenstruck\Collection\Doctrine\ObjectRepository; 16 | use Zenstruck\Collection\Doctrine\ORM\EntityRepositoryBridge; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @template V of object 22 | * @extends ServiceEntityRepository 23 | * @implements ObjectRepository 24 | */ 25 | class ORMServiceEntityRepository extends ServiceEntityRepository implements ObjectRepository 26 | { 27 | /** @use EntityRepositoryBridge */ 28 | use EntityRepositoryBridge; 29 | } 30 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/EntityRepository.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM; 13 | 14 | use Doctrine\Common\Collections\Criteria; 15 | use Doctrine\ORM\EntityManagerInterface; 16 | use Doctrine\ORM\NoResultException; 17 | use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver; 18 | use Doctrine\ORM\QueryBuilder; 19 | use Zenstruck\Collection\Doctrine\ObjectRepository; 20 | use Zenstruck\Collection\Doctrine\ORM\Specification\QueryBuilderInterpreter; 21 | use Zenstruck\Collection\Exception\InvalidSpecification; 22 | 23 | /** 24 | * @author Kevin Bond 25 | * 26 | * @template V of object 27 | * @implements ObjectRepository 28 | */ 29 | class EntityRepository implements ObjectRepository 30 | { 31 | /** 32 | * @param class-string $class 33 | */ 34 | public function __construct(private EntityManagerInterface $em, private string $class) 35 | { 36 | } 37 | 38 | /** 39 | * @param mixed|Criteria|array|(object&callable(QueryBuilder,string):void)|object $specification 40 | */ 41 | public function find(mixed $specification): ?object 42 | { 43 | try { 44 | if ($specification instanceof Criteria) { 45 | return $this->qb()->addCriteria($specification)->getQuery()->getSingleResult(); 46 | } 47 | 48 | if (\is_array($specification) && !\array_is_list($specification)) { 49 | return $this->em()->getUnitOfWork()->getEntityPersister($this->class)->load($specification, limit: 1); // @phpstan-ignore return.type 50 | } 51 | 52 | if (\is_callable($specification) && \is_object($specification)) { 53 | $specification($qb = $this->qb(), 'e'); 54 | 55 | return $qb->getQuery()->getSingleResult(); 56 | } 57 | 58 | if (\is_object($specification)) { 59 | try { 60 | return QueryBuilderInterpreter::interpret($specification, static::class, __FUNCTION__, $this->qb(), 'e') // @phpstan-ignore return.type 61 | ->result() 62 | ->first() 63 | ; 64 | } catch (InvalidSpecification $e) { 65 | if (!$this->em->getMetadataFactory()->hasMetadataFor(DefaultProxyClassNameResolver::getClass($specification))) { 66 | throw $e; 67 | } 68 | } 69 | } 70 | 71 | return $this->em()->find($this->class, $specification); 72 | } catch (NoResultException) { 73 | return null; 74 | } 75 | } 76 | 77 | /** 78 | * @param Criteria|null|array|(object&callable(QueryBuilder,string):void)|object $specification 79 | * 80 | * @return EntityResult 81 | */ 82 | public function query(mixed $specification): EntityResult 83 | { 84 | return $this->resultFor($specification, __FUNCTION__); 85 | } 86 | 87 | /** 88 | * @param Criteria|null|array|(object&callable(QueryBuilder,string):void)|object $specification 89 | * 90 | * @return EntityResult 91 | */ 92 | public function filter(mixed $specification): EntityResult 93 | { 94 | return $this->resultFor($specification, __FUNCTION__); 95 | } 96 | 97 | public function count(): int 98 | { 99 | return $this->qb()->result()->count(); 100 | } 101 | 102 | public function getIterator(): \Traversable 103 | { 104 | return $this->qb()->result()->batchIterate(); 105 | } 106 | 107 | /** 108 | * @return EntityResultQueryBuilder 109 | */ 110 | final protected function qb(string $alias = 'e', ?string $indexBy = null): EntityResultQueryBuilder 111 | { 112 | return EntityResultQueryBuilder::forEntity($this->em, $this->class, $alias, $indexBy); 113 | } 114 | 115 | final protected function em(): EntityManagerInterface 116 | { 117 | return $this->em; 118 | } 119 | 120 | /** 121 | * @return EntityResult 122 | */ 123 | private function resultFor(mixed $specification, string $method): EntityResult 124 | { 125 | $specification ??= []; 126 | $qb = $this->qb(); 127 | 128 | if ($specification instanceof Criteria) { 129 | return $qb->addCriteria($specification)->result(); 130 | } 131 | 132 | if (\is_callable($specification) && \is_object($specification)) { 133 | $specification($qb, 'e'); 134 | 135 | return $qb->result(); 136 | } 137 | 138 | if (\is_object($specification)) { 139 | return QueryBuilderInterpreter::interpret($specification, static::class, $method, $qb, 'e') // @phpstan-ignore return.type 140 | ->result() 141 | ; 142 | } 143 | 144 | if (!\is_array($specification)) { 145 | throw InvalidSpecification::build($specification, static::class, $method, 'Only array|Criteria|callable(QueryBuilder) supported.'); 146 | } 147 | 148 | foreach ($specification as $field => $value) { 149 | $qb->andWhere("e.{$field} = :{$field}")->setParameter($field, $value); 150 | } 151 | 152 | return $qb->result(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/EntityRepositoryBridge.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM; 13 | 14 | use Doctrine\Common\Collections\Criteria; 15 | use Doctrine\DBAL\LockMode; 16 | use Doctrine\ORM\QueryBuilder; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @template V of object 22 | */ 23 | trait EntityRepositoryBridge 24 | { 25 | /** @var EntityRepository */ 26 | private EntityRepository $collectionRepo; 27 | 28 | /** 29 | * @param mixed|Criteria|array|(object&callable(QueryBuilder,string):void)|object $specification 30 | * @param LockMode|int|null $lockMode 31 | * @param int|null $lockVersion 32 | */ 33 | public function find($specification, $lockMode = null, $lockVersion = null): ?object 34 | { 35 | if ($lockMode || $lockVersion) { 36 | // @phpstan-ignore-next-line 37 | return $this->getEntityManager()->find($this->getEntityName(), $specification, $lockMode, $lockVersion); 38 | } 39 | 40 | return $this->collectionRepo()->find($specification); 41 | } 42 | 43 | /** 44 | * @param Criteria|null|array|(object&callable(QueryBuilder,string):void)|object $specification 45 | * 46 | * @return EntityResult 47 | */ 48 | public function query(mixed $specification): EntityResult 49 | { 50 | return $this->collectionRepo()->query($specification); 51 | } 52 | 53 | /** 54 | * @param Criteria|null|array|(object&callable(QueryBuilder,string):void)|object $specification 55 | * 56 | * @return EntityResult 57 | */ 58 | public function filter(mixed $specification): EntityResult 59 | { 60 | return $this->query($specification); 61 | } 62 | 63 | public function getIterator(): \Traversable 64 | { 65 | return $this->collectionRepo()->getIterator(); 66 | } 67 | 68 | public function count(array $criteria = []): int 69 | { 70 | return parent::count($criteria); 71 | } 72 | 73 | /** 74 | * @param string $alias 75 | * @param string|null $indexBy 76 | * 77 | * @return EntityResultQueryBuilder 78 | */ 79 | public function createQueryBuilder($alias, $indexBy = null): EntityResultQueryBuilder 80 | { 81 | return EntityResultQueryBuilder::forEntity( 82 | parent::createQueryBuilder($alias, $indexBy)->getEntityManager(), 83 | $this->getClassName(), 84 | $alias, 85 | $indexBy 86 | ); 87 | } 88 | 89 | /** 90 | * @return EntityResultQueryBuilder 91 | */ 92 | protected function qb(string $alias = 'e', ?string $indexBy = null): EntityResultQueryBuilder 93 | { 94 | return $this->createQueryBuilder($alias, $indexBy); 95 | } 96 | 97 | /** 98 | * @return EntityRepository 99 | */ 100 | private function collectionRepo(): EntityRepository 101 | { 102 | return $this->collectionRepo ??= new EntityRepository($this->getEntityManager(), $this->getEntityName()); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/EntityRepositoryFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM; 13 | 14 | use Doctrine\ORM\EntityManagerInterface; 15 | use Doctrine\Persistence\ManagerRegistry; 16 | use Zenstruck\Collection\Doctrine\ObjectRepository; 17 | use Zenstruck\Collection\Doctrine\ObjectRepositoryFactory; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | final class EntityRepositoryFactory implements ObjectRepositoryFactory 23 | { 24 | public function __construct(private ManagerRegistry $registry) 25 | { 26 | } 27 | 28 | public function create(string $class): ObjectRepository 29 | { 30 | $em = $this->registry->getManagerForClass($class); 31 | 32 | if (!$em instanceof EntityManagerInterface) { 33 | throw new \LogicException($em ? \sprintf('"%s" only supports "%s"," %s" given.', self::class, EntityManagerInterface::class, $em::class) : \sprintf('No entity manager found for class "%s".', $class)); 34 | } 35 | 36 | return new EntityRepository($em, $class); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/EntityResult.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM; 13 | 14 | use Doctrine\Common\Collections\Criteria; 15 | use Doctrine\ORM\EntityManagerInterface; 16 | use Doctrine\ORM\NoResultException; 17 | use Doctrine\ORM\Query; 18 | use Doctrine\ORM\Query\QueryException; 19 | use Doctrine\ORM\QueryBuilder; 20 | use Doctrine\ORM\Tools\Pagination\Paginator; 21 | use Zenstruck\Collection; 22 | use Zenstruck\Collection\ArrayCollection; 23 | use Zenstruck\Collection\Doctrine\Batch; 24 | use Zenstruck\Collection\Doctrine\Result; 25 | use Zenstruck\Collection\Doctrine\Specification\CriteriaInterpreter; 26 | use Zenstruck\Collection\FactoryCollection; 27 | use Zenstruck\Collection\IterableCollection; 28 | use Zenstruck\Collection\LazyCollection; 29 | 30 | use function Zenstruck\collect; 31 | 32 | /** 33 | * @author Kevin Bond 34 | * 35 | * @template V 36 | * @implements Result 37 | */ 38 | final class EntityResult implements Result 39 | { 40 | /** @use IterableCollection */ 41 | use IterableCollection { 42 | find as private innerFind; 43 | filter as private innerFilter; 44 | } 45 | 46 | private const ENTITY_WITH_AGGREGATES = [EntityWithAggregates::class, 'create']; 47 | 48 | /** @var callable(mixed):mixed */ 49 | private $resultModifier; 50 | 51 | /** @var Query::HYDRATE_*|null */ 52 | private ?int $hydrationMode = null; // @phpstan-ignore property.unusedType, property.unusedType, property.unusedType, property.unusedType 53 | private bool $fetchJoins = true; 54 | 55 | private bool $readonly = false; 56 | 57 | /** @var non-negative-int */ 58 | private int $count; 59 | private ?bool $useOutputWalkers = null; 60 | 61 | public function __construct(private QueryBuilder $qb) 62 | { 63 | } 64 | 65 | public function __clone(): void 66 | { 67 | $this->qb = clone $this->qb; 68 | } 69 | 70 | public function batchIterate(int $chunkSize = 100): \Traversable 71 | { 72 | return Batch::iterate($this, $this->em(), $chunkSize); 73 | } 74 | 75 | public function batchProcess(int $chunkSize = 100): \Traversable 76 | { 77 | return Batch::process($this, $this->em(), $chunkSize); 78 | } 79 | 80 | /** 81 | * @return self 82 | */ 83 | public function readonly(): self 84 | { 85 | $clone = clone $this; 86 | $clone->readonly = true; 87 | 88 | return $clone; 89 | } 90 | 91 | public function first(mixed $default = null): mixed 92 | { 93 | $query = $this->query(); 94 | $sql = $query->getSQL(); 95 | 96 | if (\is_array($sql)) { 97 | $sql = \implode(' ', $sql); 98 | } 99 | 100 | if (\str_starts_with(\mb_strtolower($sql), 'delete')) { 101 | return $this->normalizeResult($query->execute()); 102 | } 103 | 104 | try { 105 | return $this->normalizeResult($query->setMaxResults(1)->getSingleResult()) ?? $default; 106 | } catch (NoResultException $e) { 107 | return $default; 108 | } 109 | } 110 | 111 | public function filter(mixed $specification): Collection 112 | { 113 | if ($specification instanceof Criteria) { 114 | $clone = clone $this; 115 | $clone->qb = $clone->qb->addCriteria($specification); 116 | 117 | return $clone; 118 | } 119 | 120 | if (!\is_callable($specification)) { 121 | return $this->filter(CriteriaInterpreter::interpret($specification, self::class, 'filter')); 122 | } 123 | 124 | return $this->innerFilter($specification); 125 | } 126 | 127 | public function find(mixed $specification, mixed $default = null): mixed 128 | { 129 | if ($specification instanceof Criteria) { 130 | return $this->filter($specification)->first($default); 131 | } 132 | 133 | if (!\is_callable($specification)) { 134 | return $this->find(CriteriaInterpreter::interpret($specification, self::class, 'find'), $default); 135 | } 136 | 137 | return $this->innerFind($specification, $default); 138 | } 139 | 140 | /** 141 | * @return self 142 | */ 143 | public function asScalar(?string $field = null): self 144 | { 145 | $clone = clone $this; 146 | $clone->hydrationMode = Query::HYDRATE_SCALAR_COLUMN; 147 | 148 | if ($field) { 149 | $clone->qb = clone $this->qb; 150 | $clone->qb->select(\sprintf('%s.%s', $clone->qb->getRootAliases()[0], $field)); 151 | } 152 | 153 | return $clone; 154 | } 155 | 156 | /** 157 | * @return self 158 | */ 159 | public function asString(?string $field = null): self 160 | { 161 | return $this->asScalar($field)->as(static fn(bool|string|int|float $row) => (string) $row); 162 | } 163 | 164 | /** 165 | * @return self 166 | */ 167 | public function asInt(?string $field = null): self 168 | { 169 | return $this->asScalar($field)->as(static fn(bool|string|int|float $row) => (int) $row); 170 | } 171 | 172 | /** 173 | * @return self 174 | */ 175 | public function asFloat(?string $field = null): self 176 | { 177 | return $this->asScalar($field)->as(static fn(bool|string|int|float $row) => (float) $row); 178 | } 179 | 180 | /** 181 | * @return self> 182 | */ 183 | public function asArray(string ...$fields): self 184 | { 185 | $clone = clone $this; 186 | $clone->hydrationMode = Query::HYDRATE_ARRAY; 187 | 188 | if ($fields) { 189 | $root = $this->qb->getRootAliases()[0]; 190 | $clone->qb = clone $this->qb; 191 | $clone->qb->select(\array_map(fn($f) => \sprintf('%s.%s', $root, $f), $fields)); 192 | } 193 | 194 | return $clone; 195 | } 196 | 197 | /** 198 | * @template R 199 | * 200 | * @param callable(mixed):R $modifier 201 | * 202 | * @return self 203 | */ 204 | public function as(callable $modifier): self 205 | { 206 | $clone = clone $this; 207 | $clone->resultModifier = $modifier; 208 | 209 | return $clone; 210 | } 211 | 212 | /** 213 | * Call this before iterating/paginating if your query result 214 | * contains "aggregate fields" (extra columns not associated 215 | * with your entity). This wraps each entity in an 216 | * {@see EntityWithAggregates} object. 217 | * 218 | * When iterating over large sets, there is a slight performance 219 | * impact. Doctrine does not allow iterating over aggregate 220 | * results directly chunk the results into groups of 20. Each 221 | * chunk requires additional queries. 222 | * 223 | * @return self> 224 | */ 225 | public function withAggregates(): self 226 | { 227 | return $this->as(self::ENTITY_WITH_AGGREGATES); // @phpstan-ignore return.type 228 | } 229 | 230 | /** 231 | * @return self 232 | */ 233 | public function disableFetchJoins(): self 234 | { 235 | $clone = clone $this; 236 | $clone->fetchJoins = false; 237 | 238 | return $clone; 239 | } 240 | 241 | /** 242 | * @return self 243 | */ 244 | public function enableOutputWalkers(): self 245 | { 246 | $clone = clone $this; 247 | $clone->useOutputWalkers = true; 248 | 249 | return $clone; 250 | } 251 | 252 | /** 253 | * @return self 254 | */ 255 | public function disableOutputWalkers(): self 256 | { 257 | $clone = clone $this; 258 | $clone->useOutputWalkers = false; 259 | 260 | return $clone; 261 | } 262 | 263 | public function take(int $limit, int $offset = 0): Collection 264 | { 265 | return new FactoryCollection( 266 | new LazyCollection( 267 | fn() => \iterator_to_array($this->paginator($this->query()->setFirstResult($offset)->setMaxResults($limit))), 268 | ), 269 | fn(mixed $result): mixed => $this->normalizeResult($result), 270 | ); 271 | } 272 | 273 | public function getIterator(): \Traversable 274 | { 275 | if (self::ENTITY_WITH_AGGREGATES === $this->resultModifier || Query::HYDRATE_SCALAR_COLUMN === $this->hydrationMode) { 276 | foreach ($this->pages(20) as $page) { 277 | yield from $page; 278 | } 279 | 280 | return; 281 | } 282 | 283 | try { 284 | $iterator = $this->query()->toIterable(hydrationMode: $this->hydrationMode ?? Query::HYDRATE_OBJECT); 285 | 286 | if ($this->resultModifier) { 287 | $iterator = collect(fn() => yield from $iterator)->map($this->resultModifier); 288 | } 289 | 290 | yield from $iterator; 291 | } catch (QueryException $e) { 292 | if ($e->getMessage() === QueryException::iterateWithMixedResultNotAllowed()->getMessage()) { 293 | throw new \LogicException(\sprintf('Results contain aggregate fields, call %s::withAggregates().', self::class), 0, $e); 294 | } 295 | 296 | throw $e; 297 | } catch (\TypeError $e) { 298 | throw new \LogicException('Result is not a collection.', previous: $e); 299 | } 300 | } 301 | 302 | public function eager(): ArrayCollection 303 | { 304 | if (!\is_array($result = $this->query()->execute())) { 305 | throw new \LogicException('Result is not a collection.'); 306 | } 307 | 308 | return new ArrayCollection(\array_map([$this, 'normalizeResult'], $result)); 309 | } 310 | 311 | public function count(): int 312 | { 313 | return $this->count ??= $this->paginator()->count(); 314 | } 315 | 316 | private function em(): EntityManagerInterface 317 | { 318 | return $this->qb->getEntityManager(); 319 | } 320 | 321 | private function query(): Query 322 | { 323 | $query = $this->qb->getQuery(); 324 | 325 | if ($this->hydrationMode) { 326 | $query->setHydrationMode($this->hydrationMode); 327 | } 328 | 329 | return $query; 330 | } 331 | 332 | private function normalizeResult(mixed $result): mixed 333 | { 334 | if (null === $result) { 335 | return $result; 336 | } 337 | 338 | if ($this->resultModifier) { 339 | $result = ($this->resultModifier)($result); 340 | } 341 | 342 | if ($this->readonly && \is_object($result)) { 343 | $this->em()->detach($result instanceof EntityWithAggregates ? $result->entity() : $result); 344 | } 345 | 346 | return $result; 347 | } 348 | 349 | /** 350 | * @return Paginator 351 | */ 352 | private function paginator(?Query $query = null): Paginator 353 | { 354 | $paginator = new Paginator($query ?? $this->query(), $this->fetchJoins); 355 | $useOutputWalkers = $this->useOutputWalkers; 356 | 357 | if ($this->resultModifier || $this->hydrationMode) { 358 | $useOutputWalkers = false; 359 | } 360 | 361 | $paginator->setUseOutputWalkers($useOutputWalkers); 362 | 363 | return $paginator; 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/EntityResultQueryBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM; 13 | 14 | use Doctrine\ORM\EntityManagerInterface; 15 | use Doctrine\ORM\Query; 16 | use Doctrine\ORM\QueryBuilder; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @template V 22 | */ 23 | final class EntityResultQueryBuilder extends QueryBuilder 24 | { 25 | /** @var list */ 26 | private array $queryModifiers = []; 27 | private bool $readonly = false; 28 | 29 | /** 30 | * @param class-string $class 31 | * 32 | * @return self 33 | */ 34 | public static function forEntity(EntityManagerInterface $em, string $class, string $alias, ?string $indexBy = null): self 35 | { 36 | return (new self($em)) 37 | ->select($alias) 38 | ->from($class, $alias, $indexBy) 39 | ; 40 | } 41 | 42 | /** 43 | * @return EntityResult 44 | */ 45 | public function result(): EntityResult 46 | { 47 | $result = new EntityResult($this); 48 | 49 | return $this->readonly ? $result->readonly() : $result; 50 | } 51 | 52 | public function getQuery(): Query 53 | { 54 | $query = parent::getQuery(); 55 | 56 | foreach ($this->queryModifiers as $modifier) { 57 | $modifier($query); 58 | } 59 | 60 | return $query; 61 | } 62 | 63 | /** 64 | * Add a query modifier. 65 | * 66 | * @param callable(Query):void $modifier 67 | * 68 | * @return $this 69 | */ 70 | public function modifyQuery(callable $modifier): self 71 | { 72 | $this->queryModifiers[] = $modifier; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * Mark the query and {@see EntityResult} as readonly. 79 | * 80 | * @return $this 81 | */ 82 | public function readonly(): self 83 | { 84 | $this->readonly = true; 85 | 86 | return $this->modifyQuery(function(Query $query) { 87 | $query->setHint(Query::HINT_READ_ONLY, true); 88 | }); 89 | } 90 | 91 | /** 92 | * @return $this 93 | */ 94 | public function cacheResult(?int $lifetime = null, ?string $key = null): self 95 | { 96 | return $this->modifyQuery(function(Query $query) use ($lifetime, $key) { 97 | $query->enableResultCache($lifetime, $key); 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/EntityWithAggregates.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * 17 | * @template V of object 18 | * 19 | * @mixin V 20 | */ 21 | final class EntityWithAggregates 22 | { 23 | /** 24 | * @param V $entity 25 | * @param array $aggregates 26 | */ 27 | private function __construct(private object $entity, private array $aggregates) 28 | { 29 | } 30 | 31 | /** 32 | * @param mixed[] $arguments 33 | */ 34 | public function __call(string $name, array $arguments): mixed 35 | { 36 | if (\method_exists($this->entity, $name)) { 37 | return $this->entity->{$name}(...$arguments); 38 | } 39 | 40 | return $this->aggregates[$name] ?? throw new \BadMethodCallException(\sprintf('"%s" is not a existing %s method or aggregate.', $name, $this->entity::class)); 41 | } 42 | 43 | public function __get(string $name): mixed 44 | { 45 | return $this->entity->{$name} ?? $this->aggregates[$name] ?? throw new \LogicException(\sprintf('"%s" is not a existing %s property or aggregate.', $name, $this->entity::class)); 46 | } 47 | 48 | public function __isset(string $name): bool 49 | { 50 | return isset($this->entity->{$name}) || isset($this->aggregates[$name]); 51 | } 52 | 53 | /** 54 | * @internal 55 | * 56 | * @param array{0:V} $data 57 | * 58 | * @return self 59 | */ 60 | public static function create(mixed $data): static 61 | { 62 | if (!\is_array($data) || !isset($data[0]) || !\is_object($data[0])) { 63 | throw new \LogicException(\sprintf('Results does not contain aggregate fields, do not call %s::withAggregates().', EntityResult::class)); 64 | } 65 | 66 | $entity = $data[0]; 67 | 68 | unset($data[0]); 69 | 70 | return new self($entity, $data); 71 | } 72 | 73 | /** 74 | * @return V 75 | */ 76 | public function entity(): object 77 | { 78 | return $this->entity; 79 | } 80 | 81 | /** 82 | * @return array 83 | */ 84 | public function aggregates(): array 85 | { 86 | return $this->aggregates; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/Specification/AntiJoin.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM\Specification; 13 | 14 | use Zenstruck\Collection\Specification\Field; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class AntiJoin extends Field 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/Specification/Join.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM\Specification; 13 | 14 | use Zenstruck\Collection\Specification\Field; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class Join extends Field 20 | { 21 | private const TYPE_INNER = 'inner'; 22 | private const TYPE_LEFT = 'left'; 23 | 24 | private string $alias; 25 | private bool $eager = false; 26 | private mixed $child = null; 27 | 28 | /** 29 | * @param self::TYPE_INNER|self::TYPE_LEFT $type 30 | */ 31 | private function __construct(private string $type, string $field, ?string $alias = null) 32 | { 33 | parent::__construct($field); 34 | 35 | $this->alias = $alias ?? $field; 36 | } 37 | 38 | public function __toString(): string 39 | { 40 | return \sprintf('%sJoin(%s)', \ucfirst($this->type()), $this->field); 41 | } 42 | 43 | public static function inner(string $field, ?string $alias = null): self 44 | { 45 | return new self(self::TYPE_INNER, $field, $alias); 46 | } 47 | 48 | public static function left(string $field, ?string $alias = null): self 49 | { 50 | return new self(self::TYPE_LEFT, $field, $alias); 51 | } 52 | 53 | public static function anti(string $field): AntiJoin 54 | { 55 | return new AntiJoin($field); 56 | } 57 | 58 | public function eager(): self 59 | { 60 | $this->eager = true; 61 | 62 | return $this; 63 | } 64 | 65 | public function scope(mixed $specification): self 66 | { 67 | $this->child = $specification; 68 | 69 | return $this; 70 | } 71 | 72 | public function alias(): string 73 | { 74 | return $this->alias; 75 | } 76 | 77 | /** 78 | * @return self::TYPE_INNER|self::TYPE_LEFT 79 | */ 80 | public function type(): string 81 | { 82 | return $this->type; 83 | } 84 | 85 | public function isEager(): bool 86 | { 87 | return $this->eager; 88 | } 89 | 90 | public function child(): mixed 91 | { 92 | return $this->child; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ORM/Specification/QueryBuilderInterpreter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\ORM\Specification; 13 | 14 | use Doctrine\ORM\Query\Expr\Comparison as DoctrineComparison; 15 | use Doctrine\ORM\Query\Expr\Composite as DoctrineComposite; 16 | use Doctrine\ORM\Query\Expr\Func; 17 | use Zenstruck\Collection\Doctrine\ORM\EntityResultQueryBuilder; 18 | use Zenstruck\Collection\Doctrine\Specification\Cache; 19 | use Zenstruck\Collection\Doctrine\Specification\Delete; 20 | use Zenstruck\Collection\Doctrine\Specification\Instance; 21 | use Zenstruck\Collection\Doctrine\Specification\Unwritable; 22 | use Zenstruck\Collection\Exception\InvalidSpecification; 23 | use Zenstruck\Collection\Specification\Callback; 24 | use Zenstruck\Collection\Specification\Comparison; 25 | use Zenstruck\Collection\Specification\Filter\Between; 26 | use Zenstruck\Collection\Specification\Filter\Contains; 27 | use Zenstruck\Collection\Specification\Filter\EndsWith; 28 | use Zenstruck\Collection\Specification\Filter\EqualTo; 29 | use Zenstruck\Collection\Specification\Filter\GreaterThan; 30 | use Zenstruck\Collection\Specification\Filter\GreaterThanOrEqualTo; 31 | use Zenstruck\Collection\Specification\Filter\In; 32 | use Zenstruck\Collection\Specification\Filter\IsNull; 33 | use Zenstruck\Collection\Specification\Filter\LessThan; 34 | use Zenstruck\Collection\Specification\Filter\LessThanOrEqualTo; 35 | use Zenstruck\Collection\Specification\Filter\StartsWith; 36 | use Zenstruck\Collection\Specification\Logic\AndX; 37 | use Zenstruck\Collection\Specification\Logic\Composite; 38 | use Zenstruck\Collection\Specification\Logic\Not; 39 | use Zenstruck\Collection\Specification\Logic\OrX; 40 | use Zenstruck\Collection\Specification\Nested; 41 | use Zenstruck\Collection\Specification\OrderBy; 42 | 43 | /** 44 | * @author Kevin Bond 45 | * 46 | * @internal 47 | */ 48 | final class QueryBuilderInterpreter 49 | { 50 | /** 51 | * @param EntityResultQueryBuilder $qb 52 | * @param class-string $callingClass 53 | */ 54 | private function __construct( 55 | private EntityResultQueryBuilder $qb, 56 | private string $alias, 57 | private string $callingClass, 58 | private string $callingMethod, 59 | ) { 60 | } 61 | 62 | /** 63 | * @param class-string $callingClass 64 | * @param EntityResultQueryBuilder $qb 65 | * 66 | * @return EntityResultQueryBuilder 67 | */ 68 | public static function interpret( 69 | object $specification, 70 | string $callingClass, 71 | string $callingMethod, 72 | EntityResultQueryBuilder $qb, 73 | string $alias, 74 | ): EntityResultQueryBuilder { 75 | $self = new self($qb, $alias, $callingClass, $callingMethod); 76 | 77 | if (self::isExpression($expression = $self->transform($specification))) { 78 | $qb->andWhere($expression); 79 | } 80 | 81 | return $qb; 82 | } 83 | 84 | private function transform(object $specification): mixed 85 | { 86 | if ($specification instanceof Nested) { 87 | return $this->transform($specification->specification()); 88 | } 89 | 90 | return match ($specification::class) { 91 | AndX::class => $this->composite($specification, 'andX'), 92 | OrX::class => $this->composite($specification, 'orX'), 93 | Not::class => $this->composite($specification, 'not'), 94 | 95 | EqualTo::class => $this->qb->expr()->eq($this->prefix($specification->field), $this->param($specification->value)), 96 | LessThan::class => $this->qb->expr()->lt($this->prefix($specification->field), $this->param($specification->value)), 97 | LessThanOrEqualTo::class => $this->qb->expr()->lte($this->prefix($specification->field), $this->param($specification->value)), 98 | GreaterThan::class => $this->qb->expr()->gt($this->prefix($specification->field), $this->param($specification->value)), 99 | GreaterThanOrEqualTo::class => $this->qb->expr()->gte($this->prefix($specification->field), $this->param($specification->value)), 100 | In::class => $this->qb->expr()->in($this->prefix($specification->field), $this->param($specification->value)), 101 | IsNull::class => $this->qb->expr()->isNull($this->prefix($specification->field)), 102 | Contains::class => $this->qb->expr()->like($this->prefix($specification->field), $this->param('%'.self::normalizeLike($specification).'%')), 103 | StartsWith::class => $this->qb->expr()->like($this->prefix($specification->field), $this->param(self::normalizeLike($specification).'%')), 104 | EndsWith::class => $this->qb->expr()->like($this->prefix($specification->field), $this->param('%'.self::normalizeLike($specification))), 105 | Between::class => $this->interpretBetween($specification), 106 | 107 | Callback::class => ($specification->value)($this->qb, $this->alias), 108 | 109 | OrderBy::class => $this->qb->addOrderBy($this->prefix($specification->field), $specification->direction), 110 | 111 | Instance::class => $this->qb->expr()->isInstanceOf($this->alias, $this->param($specification->of())), 112 | Delete::class => $this->qb->delete(), 113 | Unwritable::class => $this->qb->readonly(), 114 | Cache::class => $this->qb->cacheResult($specification->lifetime(), $specification->key()), 115 | AntiJoin::class => $this->qb->leftJoin($this->prefix($specification->field), $specification->field)->andWhere($this->qb->expr()->isNull($specification->field)), 116 | Join::class => $this->interpretJoin($specification), 117 | 118 | default => throw InvalidSpecification::build($specification, $this->callingClass, $this->callingMethod), 119 | }; 120 | } 121 | 122 | private function interpretBetween(Between $between): mixed 123 | { 124 | if (Between::INCLUSIVE === $between->type) { 125 | return $this->qb->expr()->between( 126 | $this->prefix($between->field), 127 | $this->param($between->begin), 128 | $this->param($between->end), 129 | ); 130 | } 131 | 132 | return $this->transform($between->asAnd()); 133 | } 134 | 135 | private function interpretJoin(Join $join): mixed 136 | { 137 | $this->addJoinToQueryBuilder($join); 138 | 139 | if ($join->isEager()) { 140 | $this->qb->addSelect($join->alias()); 141 | } 142 | 143 | if (null === $join->child()) { 144 | return null; 145 | } 146 | 147 | $interpreter = clone $this; 148 | $interpreter->alias = $join->alias(); 149 | 150 | return $interpreter->transform($join->child()); 151 | } 152 | 153 | private function addJoinToQueryBuilder(Join $join): void 154 | { 155 | $field = $this->prefix($join->field); 156 | 157 | foreach ($this->qb->getDQLParts()['join'] as $entry) { 158 | foreach ($entry as $item) { 159 | if ($field === $item->getJoin()) { 160 | // join already added 161 | return; 162 | } 163 | } 164 | } 165 | 166 | $this->qb->{$join->type().'Join'}($field, $join->alias()); 167 | } 168 | 169 | private function composite(Composite $specification, string $method): DoctrineComposite|Func|null 170 | { 171 | if (!$expressions = $this->filter($specification)) { 172 | return null; 173 | } 174 | 175 | return $this->qb->expr()->{$method}(...$expressions); 176 | } 177 | 178 | /** 179 | * @return list 180 | */ 181 | private function filter(Composite $specification): array 182 | { 183 | return \array_values( 184 | \array_filter( 185 | \array_map( 186 | fn(object $child) => $this->transform($child), 187 | $specification->children, 188 | ), 189 | static fn(mixed $child) => self::isExpression($child), 190 | ), 191 | ); 192 | } 193 | 194 | private static function normalizeLike(Comparison $comparison): string 195 | { 196 | return \str_replace(['%', '*'], ['%%', '%'], \trim($comparison->value, '*')); // todo make wildcard char configurable? 197 | } 198 | 199 | private function param(mixed $value): string 200 | { 201 | $param = \sprintf('param_%d', \count($this->qb->getParameters()) + 1); 202 | 203 | $this->qb->setParameter($param, $value); 204 | 205 | return ":{$param}"; 206 | } 207 | 208 | private static function isExpression(mixed $what): bool 209 | { 210 | return \is_string($what) || $what instanceof Func || $what instanceof DoctrineComparison || $what instanceof DoctrineComposite; 211 | } 212 | 213 | private function prefix(string $field): string 214 | { 215 | return "{$this->alias}.{$field}"; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ObjectRepository.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine; 13 | 14 | use Doctrine\Common\Collections\Criteria; 15 | use Zenstruck\Collection\Exception\InvalidSpecification; 16 | use Zenstruck\Collection\Matchable; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @template V of object 22 | * @extends Matchable 23 | * @extends \IteratorAggregate 24 | */ 25 | interface ObjectRepository extends Matchable, \Countable, \IteratorAggregate 26 | { 27 | public const ALL = null; 28 | 29 | /** 30 | * @param mixed|array|Criteria $specification 31 | * 32 | * @return ?V 33 | */ 34 | public function find(mixed $specification): ?object; 35 | 36 | /** 37 | * @param mixed|self::ALL|array|Criteria|object $specification 38 | * 39 | * @return Result 40 | * 41 | * @throws InvalidSpecification if the specification is not supported 42 | */ 43 | public function query(mixed $specification): Result; 44 | 45 | /** 46 | * @param mixed|self::ALL|array|Criteria|object $specification 47 | * 48 | * @return Result 49 | */ 50 | public function filter(mixed $specification): Result; 51 | } 52 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/ObjectRepositoryFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | interface ObjectRepositoryFactory 18 | { 19 | /** 20 | * @template T of object 21 | * 22 | * @param class-string $class 23 | * 24 | * @return ObjectRepository 25 | */ 26 | public function create(string $class): ObjectRepository; 27 | } 28 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Result.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine; 13 | 14 | use Doctrine\Common\Collections\Criteria; 15 | use Zenstruck\Collection; 16 | use Zenstruck\Collection\Matchable; 17 | 18 | /** 19 | * Represents a Doctrine result set. 20 | * 21 | * @author Kevin Bond 22 | * 23 | * @immutable 24 | * @template V 25 | * @extends Collection 26 | * @extends Matchable 27 | */ 28 | interface Result extends Collection, Matchable 29 | { 30 | /** 31 | * "Batch iterate" the result set, clearing the 32 | * Object Manager after each "chunk". 33 | * 34 | * @return \Traversable 35 | */ 36 | public function batchIterate(int $chunkSize = 100): \Traversable; 37 | 38 | /** 39 | * "Batch process" the result set, flushing and clearing 40 | * the Object Manager after each "chunk". 41 | * 42 | * @return \Traversable 43 | */ 44 | public function batchProcess(int $chunkSize = 100): \Traversable; 45 | 46 | /** 47 | * @param mixed|Criteria|callable(V,int):bool $specification 48 | */ 49 | public function find(mixed $specification, mixed $default = null): mixed; 50 | 51 | /** 52 | * @param mixed|Criteria|callable(V,int):bool $specification 53 | */ 54 | public function filter(mixed $specification): Collection; 55 | 56 | /** 57 | * If results are managed objects, detach them from the 58 | * Object Manager immediately after hydrating. 59 | * 60 | * @return self 61 | */ 62 | public function readonly(): self; 63 | 64 | /** 65 | * @return self 66 | */ 67 | public function asScalar(?string $field = null): self; 68 | 69 | /** 70 | * @return self 71 | */ 72 | public function asString(?string $field = null): self; 73 | 74 | /** 75 | * @return self 76 | */ 77 | public function asInt(?string $field = null): self; 78 | 79 | /** 80 | * @return self 81 | */ 82 | public function asFloat(?string $field = null): self; 83 | 84 | /** 85 | * @return self> 86 | */ 87 | public function asArray(string ...$fields): self; 88 | 89 | /** 90 | * @template R 91 | * 92 | * @param callable(mixed):R $modifier 93 | * 94 | * @return self 95 | */ 96 | public function as(callable $modifier): self; 97 | } 98 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Specification/Cache.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class Cache implements \Stringable 18 | { 19 | public function __construct(private ?int $lifetime = null, private ?string $key = null) 20 | { 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return \sprintf('Cache(lifetime: %s, key: %s)', $this->lifetime ?? '', $this->key ?? ''); 26 | } 27 | 28 | public function lifetime(): ?int 29 | { 30 | return $this->lifetime; 31 | } 32 | 33 | public function key(): ?string 34 | { 35 | return $this->key; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Specification/CriteriaInterpreter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Specification; 13 | 14 | use Doctrine\Common\Collections\Criteria; 15 | use Doctrine\Common\Collections\Expr\Expression; 16 | use Zenstruck\Collection\Exception\InvalidSpecification; 17 | use Zenstruck\Collection\Specification\Callback; 18 | use Zenstruck\Collection\Specification\Filter\Between; 19 | use Zenstruck\Collection\Specification\Filter\Contains; 20 | use Zenstruck\Collection\Specification\Filter\EndsWith; 21 | use Zenstruck\Collection\Specification\Filter\EqualTo; 22 | use Zenstruck\Collection\Specification\Filter\GreaterThan; 23 | use Zenstruck\Collection\Specification\Filter\GreaterThanOrEqualTo; 24 | use Zenstruck\Collection\Specification\Filter\In; 25 | use Zenstruck\Collection\Specification\Filter\IsNull; 26 | use Zenstruck\Collection\Specification\Filter\LessThan; 27 | use Zenstruck\Collection\Specification\Filter\LessThanOrEqualTo; 28 | use Zenstruck\Collection\Specification\Filter\StartsWith; 29 | use Zenstruck\Collection\Specification\Logic\AndX; 30 | use Zenstruck\Collection\Specification\Logic\Composite; 31 | use Zenstruck\Collection\Specification\Logic\Not; 32 | use Zenstruck\Collection\Specification\Logic\OrX; 33 | use Zenstruck\Collection\Specification\Nested; 34 | use Zenstruck\Collection\Specification\OrderBy; 35 | 36 | /** 37 | * @author Kevin Bond 38 | * 39 | * @internal 40 | */ 41 | final class CriteriaInterpreter 42 | { 43 | /** @var array */ 44 | private array $orderBy = []; 45 | 46 | /** 47 | * @param class-string $class 48 | */ 49 | private function __construct(private Criteria $criteria, private string $class, private string $method) 50 | { 51 | } 52 | 53 | /** 54 | * @param class-string $class 55 | */ 56 | public static function interpret(object $specification, string $class, string $method, ?Criteria $criteria = null): Criteria 57 | { 58 | $self = new self($criteria ?? new Criteria(), $class, $method); 59 | 60 | if ($expression = $self->transform($specification)) { 61 | $self->criteria->andWhere($expression); 62 | } 63 | 64 | if ($self->orderBy) { 65 | $self->criteria->orderBy($self->orderBy); 66 | } 67 | 68 | return $self->criteria; 69 | } 70 | 71 | private function transform(object $specification): ?Expression 72 | { 73 | if ($specification instanceof Nested) { 74 | return $this->transform($specification->specification()); 75 | } 76 | 77 | if ($specification instanceof OrderBy) { 78 | $this->orderBy[$specification->field] = $specification->direction; 79 | 80 | return null; 81 | } 82 | 83 | return match ($specification::class) { 84 | AndX::class => $this->composite($specification, 'andX'), 85 | OrX::class => $this->composite($specification, 'orX'), 86 | Not::class => $this->composite($specification, 'not'), 87 | 88 | Contains::class => Criteria::expr()->contains($specification->field, $specification->value), 89 | EndsWith::class => Criteria::expr()->endsWith($specification->field, $specification->value), 90 | EqualTo::class => Criteria::expr()->eq($specification->field, $specification->value), 91 | GreaterThan::class => Criteria::expr()->gt($specification->field, $specification->value), 92 | GreaterThanOrEqualTo::class => Criteria::expr()->gte($specification->field, $specification->value), 93 | In::class => Criteria::expr()->in($specification->field, $specification->value), 94 | IsNull::class => Criteria::expr()->isNull($specification->field), 95 | LessThan::class => Criteria::expr()->lt($specification->field, $specification->value), 96 | LessThanOrEqualTo::class => Criteria::expr()->lte($specification->field, $specification->value), 97 | StartsWith::class => Criteria::expr()->startsWith($specification->field, $specification->value), 98 | Between::class => $this->transform($specification->asAnd()), 99 | 100 | Callback::class => ($specification->value)($this->criteria), 101 | 102 | default => throw InvalidSpecification::build($specification, $this->class, $this->method), 103 | }; 104 | } 105 | 106 | private function composite(Composite $specification, string $method): ?Expression 107 | { 108 | if (!$expressions = $this->filter($specification)) { 109 | return null; 110 | } 111 | 112 | return Criteria::expr()->{$method}(...$expressions); 113 | } 114 | 115 | /** 116 | * @return Expression[] 117 | */ 118 | private function filter(Composite $specification): array 119 | { 120 | return \array_values( 121 | \array_filter( 122 | \array_map( 123 | fn(object $child) => $this->transform($child), 124 | $specification->children, 125 | ), 126 | static fn(mixed $child) => $child instanceof Expression, 127 | ), 128 | ); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Specification/Delete.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class Delete implements \Stringable 18 | { 19 | public function __toString(): string 20 | { 21 | return 'Delete'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Specification/Instance.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class Instance implements \Stringable 18 | { 19 | /** 20 | * @param class-string $of 21 | */ 22 | public function __construct(private string $of) 23 | { 24 | } 25 | 26 | public function __toString(): string 27 | { 28 | return \sprintf('Instance(%s)', $this->of); 29 | } 30 | 31 | public function of(): string 32 | { 33 | return $this->of; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Collection/Doctrine/Specification/Unwritable.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Doctrine\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class Unwritable implements \Stringable 18 | { 19 | public function __toString(): string 20 | { 21 | return 'Readonly'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Collection/Exception/InvalidSpecification.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Exception; 13 | 14 | use Zenstruck\Collection\Specification\Util; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class InvalidSpecification extends \InvalidArgumentException 20 | { 21 | /** 22 | * @param class-string $class 23 | */ 24 | public static function build(mixed $what, string $class, string $method, string $message = ''): self 25 | { 26 | if (\is_scalar($what)) { 27 | $what = \sprintf('%s (%s)', $what, \get_debug_type($what)); 28 | } 29 | 30 | if (\is_object($what) && !\is_callable($what)) { 31 | $what = Util::stringify($what); 32 | } 33 | 34 | if (!\is_scalar($what)) { 35 | $what = \get_debug_type($what); 36 | } 37 | 38 | if ('' !== $message) { 39 | $message = ' '.$message; 40 | } 41 | 42 | return new self(\sprintf('"%s::%s()" does not support specification "%s".%s', $class, $method, $what, $message)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Collection/FactoryCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @template V 20 | * @template K = array-key 21 | * @implements Collection 22 | */ 23 | final class FactoryCollection implements Collection 24 | { 25 | /** @use IterableCollection */ 26 | use IterableCollection; 27 | 28 | /** @var Collection */ 29 | private Collection $inner; 30 | private \Closure $factory; 31 | 32 | /** 33 | * @template T 34 | * 35 | * @param Collection $collection 36 | * @param callable(T):V $factory 37 | */ 38 | public function __construct(Collection $collection, callable $factory) 39 | { 40 | $this->inner = $collection; 41 | $this->factory = $factory(...); 42 | } 43 | 44 | public function getIterator(): \Traversable 45 | { 46 | foreach ($this->inner as $key => $value) { 47 | yield $key => ($this->factory)($value); 48 | } 49 | } 50 | 51 | public function count(): int 52 | { 53 | return $this->inner->count(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Collection/Grid.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection\Grid\Column; 15 | use Zenstruck\Collection\Grid\Columns; 16 | use Zenstruck\Collection\Grid\Filter; 17 | use Zenstruck\Collection\Grid\Filters; 18 | use Zenstruck\Collection\Grid\Input; 19 | use Zenstruck\Collection\Grid\PerPage; 20 | use Zenstruck\Collection\Grid\PerPage\FixedPerPage; 21 | use Zenstruck\Collection\Specification\Logic\AndX; 22 | use Zenstruck\Collection\Specification\Logic\OrX; 23 | 24 | /** 25 | * @author Kevin Bond 26 | * 27 | * @template T of array|object 28 | * 29 | * @implements \IteratorAggregate 30 | */ 31 | final class Grid implements \IteratorAggregate 32 | { 33 | public readonly PerPage $perPage; 34 | 35 | /** @var Page */ 36 | private Page $page; 37 | 38 | /** 39 | * @internal 40 | * 41 | * @param Matchable $source 42 | */ 43 | public function __construct( 44 | public readonly Input $input, 45 | public readonly Matchable $source, 46 | public readonly Columns $columns, 47 | public readonly Filters $filters, 48 | ?PerPage $perPage = null, 49 | private ?object $defaultSpecification = null, 50 | ) { 51 | $this->perPage = $perPage ?? new FixedPerPage(); 52 | } 53 | 54 | public function getIterator(): \Traversable 55 | { 56 | return $this->page(); 57 | } 58 | 59 | /** 60 | * @return Page 61 | */ 62 | public function page(): Page 63 | { 64 | if (isset($this->page)) { 65 | return $this->page; 66 | } 67 | 68 | $specification = new AndX(...\array_filter([ 69 | $this->defaultSpecification, 70 | $this->columns->sort(), 71 | $this->searchSpecification(), 72 | new AndX(...\array_filter($this->filterSpecification())), 73 | ])); 74 | 75 | return $this->page = $this->source->filter($specification) 76 | ->paginate($this->input->page(), $this->perPage->value($this->input->perPage()))->strict() 77 | ; 78 | } 79 | 80 | private function searchSpecification(): OrX 81 | { 82 | if (!$query = $this->input->query()) { 83 | return new OrX(); 84 | } 85 | 86 | return new OrX(...$this->columns 87 | ->searchable() 88 | ->all() 89 | ->map(fn(Column $column) => $column->searchSpecification($query)) 90 | ->filter() 91 | ->values() 92 | ->all() 93 | ); 94 | } 95 | 96 | /** 97 | * @return array 98 | */ 99 | private function filterSpecification(): array 100 | { 101 | return $this->filters->all() 102 | ->map(fn(Filter $filter, string $name) => $filter->apply($this->input->filter($name))) 103 | ->values() 104 | ->all() 105 | ; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Collection/Grid/Column.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid; 13 | 14 | use Zenstruck\Collection\Grid\Definition\ColumnDefinition; 15 | use Zenstruck\Collection\Specification\Filter\Contains; 16 | use Zenstruck\Collection\Specification\OrderBy; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class Column 22 | { 23 | /** 24 | * @internal 25 | */ 26 | public function __construct( 27 | private ColumnDefinition $definition, 28 | private Input $input, 29 | private ?OrderBy $defaultSort, 30 | ) { 31 | } 32 | 33 | public function name(): string 34 | { 35 | return $this->definition->name; 36 | } 37 | 38 | public function isSearchable(): bool 39 | { 40 | return (bool) $this->definition->searchable; 41 | } 42 | 43 | public function isSortable(): bool 44 | { 45 | return $this->definition->sortable; 46 | } 47 | 48 | public function searchSpecification(string $query): ?object 49 | { 50 | if (false === $this->definition->searchable) { 51 | return null; 52 | } 53 | 54 | if (true === $this->definition->searchable) { 55 | return new Contains($this->name(), $query); 56 | } 57 | 58 | return ($this->definition->searchable)($query); 59 | } 60 | 61 | public function sort(): ?OrderBy 62 | { 63 | if (!($sort = $this->input->sort() ?? $this->defaultSort) || $this->name() !== $sort->field) { 64 | return null; 65 | } 66 | 67 | return $sort; 68 | } 69 | 70 | public function applyAscSort(): Input 71 | { 72 | return $this->input->applySort(OrderBy::asc($this->name())); 73 | } 74 | 75 | public function applyDescSort(): Input 76 | { 77 | return $this->input->applySort(OrderBy::desc($this->name())); 78 | } 79 | 80 | public function applyOppositeSort(): Input 81 | { 82 | if (!$sort = $this->sort()) { 83 | return $this->applyAscSort(); 84 | } 85 | 86 | return $this->input->applySort($sort->opposite()); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Collection/Grid/Columns.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid; 13 | 14 | use Zenstruck\Collection\ArrayCollection; 15 | use Zenstruck\Collection\Specification\OrderBy; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @implements \IteratorAggregate 21 | */ 22 | final class Columns implements \IteratorAggregate, \Countable 23 | { 24 | private self $searchable; 25 | private self $sortable; 26 | 27 | /** 28 | * @internal 29 | * 30 | * @param ArrayCollection $columns 31 | */ 32 | public function __construct(private ArrayCollection $columns, private Input $input, private ?OrderBy $defaultSort) 33 | { 34 | } 35 | 36 | public function get(string $name): ?Column 37 | { 38 | return $this->columns->get($name); 39 | } 40 | 41 | public function has(string $name): bool 42 | { 43 | return $this->columns->has($name); 44 | } 45 | 46 | public function searchable(): self 47 | { 48 | return $this->searchable ??= new self($this->columns->filter(fn(Column $c) => $c->isSearchable()), $this->input, $this->defaultSort); 49 | } 50 | 51 | public function sortable(): self 52 | { 53 | return $this->sortable ??= new self($this->columns->filter(fn(Column $c) => $c->isSortable()), $this->input, $this->defaultSort); 54 | } 55 | 56 | public function sort(): ?OrderBy 57 | { 58 | if (!$sort = $this->input->sort()) { 59 | return $this->defaultSort; 60 | } 61 | 62 | return $this->sortable()->has($sort->field) ? $sort : $this->defaultSort; 63 | } 64 | 65 | /** 66 | * @return ArrayCollection 67 | */ 68 | public function all(): ArrayCollection 69 | { 70 | return $this->columns; 71 | } 72 | 73 | public function getIterator(): \Traversable 74 | { 75 | return $this->columns; 76 | } 77 | 78 | public function count(): int 79 | { 80 | return $this->columns->count(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Collection/Grid/Definition/ColumnDefinition.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid\Definition; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class ColumnDefinition 18 | { 19 | /** 20 | * @param bool|\Closure(string):(object|null) $searchable 21 | */ 22 | public function __construct( 23 | public string $name, 24 | public bool|\Closure $searchable = false, 25 | public bool $sortable = false, 26 | ) { 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Collection/Grid/Filter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | interface Filter 18 | { 19 | public function apply(mixed $value): ?object; 20 | } 21 | -------------------------------------------------------------------------------- /src/Collection/Grid/Filter/AutoFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid\Filter; 13 | 14 | use Zenstruck\Collection\Grid\Filter; 15 | use Zenstruck\Collection\Specification\Filter\Between; 16 | use Zenstruck\Collection\Specification\Filter\Contains; 17 | use Zenstruck\Collection\Specification\Filter\EndsWith; 18 | use Zenstruck\Collection\Specification\Filter\EqualTo; 19 | use Zenstruck\Collection\Specification\Filter\GreaterThan; 20 | use Zenstruck\Collection\Specification\Filter\GreaterThanOrEqualTo; 21 | use Zenstruck\Collection\Specification\Filter\In; 22 | use Zenstruck\Collection\Specification\Filter\IsNull; 23 | use Zenstruck\Collection\Specification\Filter\LessThan; 24 | use Zenstruck\Collection\Specification\Filter\LessThanOrEqualTo; 25 | use Zenstruck\Collection\Specification\Filter\StartsWith; 26 | use Zenstruck\Collection\Specification\Logic\Not; 27 | 28 | /** 29 | * @author Kevin Bond 30 | */ 31 | final class AutoFilter implements Filter 32 | { 33 | public function __construct(private string $field) 34 | { 35 | } 36 | 37 | public function apply(mixed $value): ?object 38 | { 39 | if (\is_array($value) && \array_is_list($value)) { 40 | return new In($this->field, $value); 41 | } 42 | 43 | if (!\is_string($value) || !$value) { 44 | return null; 45 | } 46 | 47 | if (2 === \count($parts = \explode('...', $value))) { 48 | [$begin, $end] = $parts; 49 | $type = \str_starts_with($begin, '(') ? '(' : '['; 50 | $type .= \str_ends_with($end, ')') ? ')' : ']'; 51 | 52 | return new Between($this->field, \trim($begin, '[('), \trim($end, ')]'), $type); 53 | } 54 | 55 | return match (true) { 56 | '~' === $value => new IsNull($this->field), 57 | \str_starts_with($value, '*') && \str_ends_with($value, '*') => new Contains($this->field, \mb_substr($value, 1, -1)), 58 | \str_starts_with($value, '*') => new StartsWith($this->field, \mb_substr($value, 1)), 59 | \str_ends_with($value, '*') => new EndsWith($this->field, \mb_substr($value, 0, -1)), 60 | \str_starts_with($value, '!') => new Not($this->apply(\mb_substr($value, 1))), 61 | \str_starts_with($value, '<=') => new LessThanOrEqualTo($this->field, \mb_substr($value, 2)), 62 | \str_starts_with($value, '>=') => new GreaterThanOrEqualTo($this->field, \mb_substr($value, 2)), 63 | \str_starts_with($value, '>') => new GreaterThan($this->field, \mb_substr($value, 1)), 64 | \str_starts_with($value, '<') => new LessThan($this->field, \mb_substr($value, 1)), 65 | \count($values = \explode(',', $value)) > 1 => new In($this->field, $values), 66 | default => new EqualTo($this->field, $value), 67 | }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Collection/Grid/Filter/Choice.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid\Filter; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class Choice implements \Stringable 18 | { 19 | public function __construct( 20 | public readonly ?string $value, 21 | public readonly ?object $specification = null, 22 | public readonly ?string $label = null, 23 | ) { 24 | } 25 | 26 | public function __toString(): string 27 | { 28 | return $this->label ?? $this->value ?? ''; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Collection/Grid/Filter/ChoiceFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid\Filter; 13 | 14 | use Zenstruck\Collection\ArrayCollection; 15 | use Zenstruck\Collection\Grid\Filter; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @implements \IteratorAggregate 21 | */ 22 | final class ChoiceFilter implements Filter, \IteratorAggregate, \Countable 23 | { 24 | /** @var ArrayCollection */ 25 | private ArrayCollection $choices; 26 | 27 | public function __construct(Choice ...$choices) 28 | { 29 | $this->choices = ArrayCollection::for($choices)->keyBy(fn(Choice $choice) => (string) $choice->value); 30 | } 31 | 32 | public function apply(mixed $value): ?object 33 | { 34 | if (!\is_string($value)) { 35 | return null; 36 | } 37 | 38 | return $this->choices->get($value)?->specification; 39 | } 40 | 41 | public function getIterator(): \Traversable 42 | { 43 | return $this->choices; 44 | } 45 | 46 | public function count(): int 47 | { 48 | return $this->choices->count(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Collection/Grid/Filters.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid; 13 | 14 | use Zenstruck\Collection\ArrayCollection; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @implements \IteratorAggregate 20 | */ 21 | final class Filters implements \IteratorAggregate, \Countable 22 | { 23 | /** @var ArrayCollection */ 24 | private ArrayCollection $filters; 25 | 26 | /** 27 | * @param array $filters 28 | */ 29 | public function __construct(array $filters) 30 | { 31 | $this->filters = new ArrayCollection($filters); 32 | } 33 | 34 | public function get(string $name): ?Filter 35 | { 36 | return $this->filters->get($name); 37 | } 38 | 39 | public function has(string $name): bool 40 | { 41 | return $this->filters->has($name); 42 | } 43 | 44 | /** 45 | * @return ArrayCollection 46 | */ 47 | public function all(): ArrayCollection 48 | { 49 | return $this->filters; 50 | } 51 | 52 | public function getIterator(): \Traversable 53 | { 54 | return $this->filters; 55 | } 56 | 57 | public function count(): int 58 | { 59 | return $this->filters->count(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Collection/Grid/GridBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid; 13 | 14 | use Zenstruck\Collection\Grid; 15 | use Zenstruck\Collection\Grid\Definition\ColumnDefinition; 16 | use Zenstruck\Collection\Grid\Filter\AutoFilter; 17 | use Zenstruck\Collection\Matchable; 18 | use Zenstruck\Collection\Specification\OrderBy; 19 | 20 | use function Zenstruck\collect; 21 | 22 | /** 23 | * @author Kevin Bond 24 | * 25 | * @template T of array|object 26 | */ 27 | final class GridBuilder 28 | { 29 | /** @var Matchable|null */ 30 | public ?Matchable $source = null; 31 | public ?OrderBy $defaultSort = null; 32 | public ?PerPage $perPage = null; 33 | public ?object $defaultSpecification = null; 34 | 35 | /** @var array */ 36 | private array $columns = []; 37 | 38 | /** @var array */ 39 | private array $filters = []; 40 | 41 | /** 42 | * @return Grid 43 | */ 44 | public function build(Input $input): Grid 45 | { 46 | $columns = collect($this->columns) 47 | ->map(fn(ColumnDefinition $column) => new Column( 48 | definition: $column, 49 | input: $input, 50 | defaultSort: $this->defaultSort, 51 | )) 52 | ; 53 | 54 | return new Grid( 55 | input: $input, 56 | source: $this->source ?? throw new \LogicException('No source defined.'), 57 | columns: new Columns($columns, $input, $this->defaultSort), 58 | filters: new Filters($this->filters), 59 | perPage: $this->perPage, 60 | defaultSpecification: $this->defaultSpecification, 61 | ); 62 | } 63 | 64 | /** 65 | * @param bool|(object&callable(string):(object|null)) $searchable 66 | * @param OrderBy::*|null $defaultSort 67 | * 68 | * @return $this 69 | */ 70 | public function addColumn( 71 | string $name, 72 | callable|bool $searchable = false, 73 | bool $sortable = false, 74 | bool $autofilter = false, 75 | ?string $defaultSort = null, 76 | ): self { 77 | $this->columns[$name] = new ColumnDefinition( 78 | name: $name, 79 | searchable: \is_bool($searchable) ? $searchable : $searchable(...), 80 | sortable: $sortable, 81 | ); 82 | 83 | if ($defaultSort) { 84 | $this->defaultSort = new OrderBy($name, $defaultSort); 85 | } 86 | 87 | if ($autofilter) { 88 | $this->addFilter($name, new AutoFilter($name)); 89 | } 90 | 91 | return $this; 92 | } 93 | 94 | public function getColumn(string $name): ColumnDefinition 95 | { 96 | return $this->columns[$name] ?? throw new \InvalidArgumentException(\sprintf('Column "%s" does not exist.', $name)); 97 | } 98 | 99 | /** 100 | * @return $this 101 | */ 102 | public function removeColumn(string $name): self 103 | { 104 | unset($this->columns[$name]); 105 | 106 | return $this; 107 | } 108 | 109 | /** 110 | * @return $this 111 | */ 112 | public function addFilter(string $name, Filter $filter): self 113 | { 114 | $this->filters[$name] = $filter; 115 | 116 | return $this; 117 | } 118 | 119 | public function getFilter(string $name): Filter 120 | { 121 | return $this->filters[$name] ?? throw new \InvalidArgumentException(\sprintf('Filter "%s" does not exist.', $name)); 122 | } 123 | 124 | /** 125 | * @return $this 126 | */ 127 | public function removeFilter(string $name): self 128 | { 129 | unset($this->filters[$name]); 130 | 131 | return $this; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Collection/Grid/GridDefinition.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * 17 | * @template T of array|object 18 | */ 19 | interface GridDefinition 20 | { 21 | /** 22 | * @param GridBuilder $builder 23 | */ 24 | public function configure(GridBuilder $builder): void; 25 | } 26 | -------------------------------------------------------------------------------- /src/Collection/Grid/Input.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid; 13 | 14 | use Zenstruck\Collection\ArrayCollection; 15 | use Zenstruck\Collection\Specification\OrderBy; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @immutable 21 | */ 22 | interface Input 23 | { 24 | /** 25 | * @return positive-int 26 | */ 27 | public function page(): int; 28 | 29 | /** 30 | * @param positive-int $value 31 | */ 32 | public function applyPage(int $value): static; 33 | 34 | public function query(): ?string; 35 | 36 | public function applyQuery(?string $value): static; 37 | 38 | /** 39 | * @return positive-int|null 40 | */ 41 | public function perPage(): ?int; 42 | 43 | /** 44 | * @param positive-int $value 45 | */ 46 | public function applyPerPage(int $value): static; 47 | 48 | public function sort(): ?OrderBy; 49 | 50 | public function applySort(OrderBy $orderBy): static; 51 | 52 | public function filter(string $name): mixed; 53 | 54 | public function applyFilter(string $name, mixed $value): static; 55 | 56 | public function reset(): static; 57 | 58 | /** 59 | * @return ArrayCollection 60 | */ 61 | public function values(): ArrayCollection; 62 | } 63 | -------------------------------------------------------------------------------- /src/Collection/Grid/Input/UriInput.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid\Input; 13 | 14 | use Symfony\Component\HttpFoundation\Request; 15 | use Zenstruck\Collection\ArrayCollection; 16 | use Zenstruck\Collection\Grid\Input; 17 | use Zenstruck\Collection\Specification\OrderBy; 18 | use Zenstruck\Uri; 19 | use Zenstruck\Uri\ParsedUri; 20 | 21 | use function Zenstruck\collect; 22 | 23 | /** 24 | * @author Kevin Bond 25 | */ 26 | final class UriInput implements Input, \Stringable 27 | { 28 | private const PAGE = 'page'; 29 | private const PER_PAGE = 'perPage'; 30 | private const SORT = 'sort'; 31 | private const QUERY = 'q'; 32 | private const FILTERS = 'filters'; 33 | 34 | private ParsedUri $uri; 35 | 36 | /** @var mixed[] */ 37 | private array $query; 38 | 39 | public function __construct(string|Request|Uri $uri, private ?string $key = null) 40 | { 41 | if (!\class_exists(ParsedUri::class)) { 42 | throw new \LogicException('The "zenstruck/uri" package is required to use UriInput. Run "composer require zenstruck/uri".'); 43 | } 44 | 45 | $this->uri = ParsedUri::wrap($uri); 46 | $query = $key ? $this->uri->query()->get($key, []) : $this->uri->query()->all(); 47 | $this->query = \is_array($query) ? $query : []; 48 | } 49 | 50 | public function __toString(): string 51 | { 52 | if (!$this->key) { 53 | return $this->uri->withQuery($this->query)->toString(); 54 | } 55 | 56 | return $this->uri->withQueryParam($this->key, $this->query)->toString(); 57 | } 58 | 59 | public function page(): int 60 | { 61 | $page = $this->query[self::PAGE] ?? 1; 62 | 63 | if (\is_numeric($page) && $page > 0) { 64 | return (int) $page; // @phpstan-ignore return.type 65 | } 66 | 67 | return 1; 68 | } 69 | 70 | public function applyPage(int $value): static 71 | { 72 | $clone = clone $this; 73 | $clone->query[self::PAGE] = $value; 74 | 75 | return $clone; 76 | } 77 | 78 | public function query(): ?string 79 | { 80 | $query = $this->query[self::QUERY] ?? null; 81 | 82 | return \is_scalar($query) ? (string) $query : null; 83 | } 84 | 85 | public function applyQuery(?string $value): static 86 | { 87 | $clone = clone $this; 88 | $clone->query[self::QUERY] = $value; 89 | 90 | return $clone; 91 | } 92 | 93 | public function perPage(): ?int 94 | { 95 | $page = $this->query[self::PER_PAGE] ?? null; 96 | 97 | if (\is_numeric($page) && $page > 0) { 98 | return (int) $page; // @phpstan-ignore return.type 99 | } 100 | 101 | return null; 102 | } 103 | 104 | public function applyPerPage(int $value): static 105 | { 106 | $clone = clone $this; 107 | $clone->query[self::PER_PAGE] = $value; 108 | 109 | return $clone; 110 | } 111 | 112 | public function sort(): ?OrderBy 113 | { 114 | $sort = $this->query[self::SORT] ?? null; 115 | 116 | return match (true) { 117 | \is_string($sort) && \str_starts_with($sort, '-') => OrderBy::desc(\mb_substr($sort, 1)), 118 | \is_string($sort) => OrderBy::asc($sort), 119 | default => null, 120 | }; 121 | } 122 | 123 | public function applySort(OrderBy $orderBy): static 124 | { 125 | $clone = clone $this; 126 | $clone->query[self::SORT] = \sprintf('%s%s', $orderBy->isDesc() ? '-' : '', $orderBy->field); 127 | 128 | return $clone; 129 | } 130 | 131 | public function filter(string $name): mixed 132 | { 133 | return $this->filters()[$name] ?? null; 134 | } 135 | 136 | public function applyFilter(string $name, mixed $value): static 137 | { 138 | $filters = $this->filters(); 139 | $filters[$name] = $value; 140 | 141 | $clone = clone $this; 142 | $clone->query[self::FILTERS] = $filters; 143 | 144 | return $clone; 145 | } 146 | 147 | public function reset(): static 148 | { 149 | $clone = clone $this; 150 | unset( 151 | $clone->query[self::PAGE], 152 | $clone->query[self::PER_PAGE], 153 | $clone->query[self::SORT], 154 | $clone->query[self::QUERY], 155 | $clone->query[self::FILTERS] 156 | ); 157 | 158 | return $clone; 159 | } 160 | 161 | public function values(): ArrayCollection 162 | { 163 | return collect([ 164 | self::PAGE => $this->query[self::PAGE] ?? null, 165 | self::PER_PAGE => $this->query[self::PER_PAGE] ?? null, 166 | self::SORT => $this->query[self::SORT] ?? null, 167 | self::QUERY => $this->query[self::QUERY] ?? null, 168 | self::FILTERS => $this->filters(), 169 | ])->filter(); 170 | } 171 | 172 | /** 173 | * @return mixed[] 174 | */ 175 | private function filters(): array 176 | { 177 | if (\is_array($filters = $this->query[self::FILTERS] ?? [])) { 178 | return $filters; 179 | } 180 | 181 | return []; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/Collection/Grid/PerPage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | interface PerPage 18 | { 19 | /** 20 | * @param positive-int|null $input 21 | * 22 | * @return positive-int 23 | */ 24 | public function value(?int $input): int; 25 | } 26 | -------------------------------------------------------------------------------- /src/Collection/Grid/PerPage/FixedPerPage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid\PerPage; 13 | 14 | use Zenstruck\Collection\Grid\PerPage; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class FixedPerPage implements PerPage 20 | { 21 | /** 22 | * @param positive-int $value 23 | */ 24 | public function __construct(private int $value = 20) 25 | { 26 | } 27 | 28 | public function value(?int $input): int 29 | { 30 | return $this->value; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Collection/Grid/PerPage/RangePerPage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid\PerPage; 13 | 14 | use Zenstruck\Collection\Grid\PerPage; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class RangePerPage implements PerPage 20 | { 21 | /** 22 | * @param positive-int $min 23 | * @param positive-int $max 24 | * @param positive-int $default 25 | */ 26 | public function __construct( 27 | public readonly int $min = 1, 28 | public readonly int $max = 100, 29 | public readonly int $default = 20, 30 | ) { 31 | } 32 | 33 | public function value(?int $input): int 34 | { 35 | return \max($this->min, \min($this->max, $input ?? $this->default)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Collection/Grid/PerPage/SetPerPage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Grid\PerPage; 13 | 14 | use Zenstruck\Collection\Grid\PerPage; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class SetPerPage implements PerPage 20 | { 21 | /** 22 | * @param list $values 23 | * @param positive-int $default 24 | */ 25 | public function __construct( 26 | public readonly array $values = [20, 50, 100], 27 | public readonly int $default = 20, 28 | ) { 29 | } 30 | 31 | public function value(?int $input): int 32 | { 33 | return \in_array($input, $this->values, true) ? $input : $this->default; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Collection/IterableCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | use Zenstruck\Collection\Exception\InvalidSpecification; 16 | 17 | /** 18 | * Convert any {@see \Traversable} class into a {@see Collection} 19 | * with some extra, "lazy" methods. 20 | * 21 | * @author Kevin Bond 22 | * 23 | * @template V 24 | * @template K = array-key 25 | */ 26 | trait IterableCollection 27 | { 28 | /** 29 | * @return LazyCollection 30 | */ 31 | public function take(int $limit, int $offset = 0): Collection 32 | { 33 | $source = $this->iterableSource(); 34 | 35 | if ($source instanceof \ArrayIterator || $source instanceof \ArrayObject) { 36 | return new LazyCollection(\array_slice($source->getArrayCopy(), $offset, $limit, true)); 37 | } 38 | 39 | if ($limit < 0) { 40 | throw new \InvalidArgumentException('$limit cannot be negative'); 41 | } 42 | 43 | if ($offset < 0) { 44 | throw new \InvalidArgumentException('$offset cannot be negative'); 45 | } 46 | 47 | if (0 === $limit) { 48 | return new LazyCollection(); 49 | } 50 | 51 | return new LazyCollection(function() use ($limit, $offset) { 52 | $i = 0; 53 | 54 | foreach ($this as $key => $value) { 55 | if ($i++ < $offset) { 56 | continue; 57 | } 58 | 59 | yield $key => $value; 60 | 61 | if ($i >= $offset + $limit) { 62 | break; 63 | } 64 | } 65 | }); 66 | } 67 | 68 | /** 69 | * @return LazyCollection 70 | */ 71 | public function filter(mixed $specification): Collection 72 | { 73 | if (!\is_callable($specification)) { 74 | throw InvalidSpecification::build($specification, static::class, 'filter', 'Only callable(V,K):bool is supported.'); 75 | } 76 | 77 | return new LazyCollection(function() use ($specification) { 78 | foreach ($this as $key => $value) { 79 | if ($specification($value, $key)) { 80 | yield $key => $value; 81 | } 82 | } 83 | }); 84 | } 85 | 86 | /** 87 | * @return LazyCollection 88 | */ 89 | public function keyBy(callable $function): Collection 90 | { 91 | return new LazyCollection(function() use ($function) { 92 | foreach ($this as $key => $value) { 93 | yield $function($value, $key) => $value; 94 | } 95 | }); 96 | } 97 | 98 | /** 99 | * @template T 100 | * 101 | * @param callable(V,K):T $function 102 | * 103 | * @return LazyCollection 104 | */ 105 | public function map(callable $function): Collection 106 | { 107 | return new LazyCollection(function() use ($function) { 108 | foreach ($this as $key => $value) { 109 | yield $key => $function($value, $key); 110 | } 111 | }); 112 | } 113 | 114 | /** 115 | * @return Page 116 | */ 117 | public function paginate(int $page = 1, int $limit = Page::DEFAULT_LIMIT): Page 118 | { 119 | return $this->pages($limit)->get($page); 120 | } 121 | 122 | /** 123 | * @return Pages 124 | */ 125 | public function pages(int $limit = Page::DEFAULT_LIMIT): Pages 126 | { 127 | return new Pages($this, $limit); 128 | } 129 | 130 | public function first(mixed $default = null): mixed 131 | { 132 | foreach ($this as $value) { 133 | return $value; 134 | } 135 | 136 | return $default; 137 | } 138 | 139 | public function find(mixed $specification, mixed $default = null): mixed 140 | { 141 | if (!\is_callable($specification)) { 142 | throw InvalidSpecification::build($specification, static::class, 'find', 'Only callable(V,K):bool is supported.'); 143 | } 144 | 145 | foreach ($this as $key => $value) { 146 | if ($specification($value, $key)) { 147 | return $value; 148 | } 149 | } 150 | 151 | return $default; 152 | } 153 | 154 | public function reduce(callable $function, mixed $initial = null): mixed 155 | { 156 | $result = $initial; 157 | 158 | foreach ($this as $key => $value) { 159 | $result = $function($result, $value, $key); 160 | } 161 | 162 | return $result; 163 | } 164 | 165 | public function isEmpty(): bool 166 | { 167 | return 0 === $this->count(); 168 | } 169 | 170 | public function count(): int 171 | { 172 | $source = $this->iterableSource(); 173 | 174 | return \is_countable($source) ? \count($source) : \iterator_count($source); 175 | } 176 | 177 | public function getIterator(): \Traversable 178 | { 179 | foreach ($this->iterableSource() as $key => $value) { 180 | yield $key => $value; 181 | } 182 | } 183 | 184 | public function dump(): static 185 | { 186 | \function_exists('dump') ? dump(\iterator_to_array($this)) : \var_dump(\iterator_to_array($this)); 187 | 188 | return $this; 189 | } 190 | 191 | public function dd(): void 192 | { 193 | $this->dump(); 194 | 195 | exit; 196 | } 197 | 198 | public function eager(): ArrayCollection 199 | { 200 | return new ArrayCollection($this->iterableSource()); 201 | } 202 | 203 | /** 204 | * @return iterable 205 | */ 206 | private function iterableSource(): iterable 207 | { 208 | return $this; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Collection/LazyCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @template V 20 | * @template K = array-key 21 | * @implements Collection 22 | */ 23 | final class LazyCollection implements Collection 24 | { 25 | /** @use IterableCollection */ 26 | use IterableCollection; 27 | 28 | /** @var \Traversable|\Closure():iterable */ 29 | private \Closure|\Traversable $source; 30 | 31 | /** 32 | * @param iterable|callable():iterable $source 33 | */ 34 | public function __construct(iterable|callable $source = []) 35 | { 36 | if ($source instanceof \Generator) { 37 | throw new \InvalidArgumentException('$source must not be a generator directly as generators cannot be rewound. Try wrapping in a closure.'); 38 | } 39 | 40 | if (\is_callable($source) && (!\is_iterable($source) || \is_array($source))) { 41 | $source = $source(...); // @phpstan-ignore callable.nonCallable 42 | } 43 | 44 | $this->source = \is_array($source) ? new \ArrayIterator($source) : $source; // @phpstan-ignore assign.propertyType 45 | } 46 | 47 | /** 48 | * @return iterable 49 | */ 50 | private function iterableSource(): iterable 51 | { 52 | if ($this->source instanceof \Traversable) { 53 | return $this->source; 54 | } 55 | 56 | // source is callback 57 | $source = ($this->source)(); 58 | 59 | if ($source instanceof \Generator) { 60 | // generators cannot be rewound so don't set as $source (ensure callback is executed next time) 61 | return $source; 62 | } 63 | 64 | if (!\is_iterable($source)) { 65 | throw new \InvalidArgumentException('$source callback must return iterable.'); 66 | } 67 | 68 | return $this->source = \is_array($source) ? new \ArrayIterator($source) : $source; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Collection/Matchable.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | use Zenstruck\Collection\Exception\InvalidSpecification; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @template V 21 | * @template K = array-key 22 | */ 23 | interface Matchable 24 | { 25 | /** 26 | * @return V|null 27 | * 28 | * @throws InvalidSpecification if $specification is not valid 29 | */ 30 | public function find(object $specification): mixed; 31 | 32 | /** 33 | * @return Collection 34 | */ 35 | public function filter(mixed $specification): Collection; 36 | } 37 | -------------------------------------------------------------------------------- /src/Collection/Page.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @template V 20 | * @template K = array-key 21 | * @implements \IteratorAggregate 22 | */ 23 | final class Page implements \IteratorAggregate, \Countable 24 | { 25 | public const DEFAULT_LIMIT = 20; 26 | 27 | /** @var positive-int */ 28 | private int $page; 29 | 30 | /** @var positive-int */ 31 | private int $limit; 32 | private bool $strict = false; 33 | 34 | /** @var Collection */ 35 | private Collection $cachedPage; 36 | 37 | /** 38 | * @param Collection $collection 39 | * @param positive-int $page 40 | * @param positive-int $limit 41 | */ 42 | public function __construct(private Collection $collection, int $page = 1, int $limit = self::DEFAULT_LIMIT) 43 | { 44 | $this->page = \max($page, 1); 45 | $this->limit = $limit < 1 ? self::DEFAULT_LIMIT : $limit; 46 | } 47 | 48 | /** 49 | * Enable/Disable "strict mode". 50 | * 51 | * When enabled, when calling {@see currentPage}, if provided page number 52 | * greater than the calculated last page number, the last page number will 53 | * be returned. 54 | * 55 | * When enabled, extra work (ie count query) may be required to ensure the 56 | * current page number is valid. 57 | * 58 | * @return $this 59 | */ 60 | public function strict(bool $flag = true): self 61 | { 62 | $this->strict = $flag; 63 | 64 | return $this; 65 | } 66 | 67 | public function currentPage(): int 68 | { 69 | if (!$this->strict) { 70 | return $this->page; 71 | } 72 | 73 | $lastPage = $this->lastPage(); 74 | 75 | if ($this->page > $lastPage) { 76 | return $lastPage; 77 | } 78 | 79 | return $this->page; 80 | } 81 | 82 | /** 83 | * @return positive-int 84 | */ 85 | public function limit(): int 86 | { 87 | return $this->limit; 88 | } 89 | 90 | /** 91 | * @return int the count for the current page 92 | */ 93 | public function count(): int 94 | { 95 | return $this->getPage()->count(); 96 | } 97 | 98 | public function totalCount(): int 99 | { 100 | return $this->collection->count(); 101 | } 102 | 103 | public function getIterator(): \Traversable 104 | { 105 | return $this->getPage()->getIterator(); 106 | } 107 | 108 | public function nextPage(): ?int 109 | { 110 | $currentPage = $this->currentPage(); 111 | 112 | if ($currentPage === $this->lastPage()) { 113 | return null; 114 | } 115 | 116 | return ++$currentPage; 117 | } 118 | 119 | public function previousPage(): ?int 120 | { 121 | $page = $this->currentPage(); 122 | 123 | if (1 === $page) { 124 | return null; 125 | } 126 | 127 | return --$page; 128 | } 129 | 130 | public function firstPage(): int 131 | { 132 | return 1; 133 | } 134 | 135 | /** 136 | * @return positive-int 137 | */ 138 | public function lastPage(): int 139 | { 140 | $totalCount = $this->totalCount(); 141 | 142 | if (0 === $totalCount) { 143 | return 1; 144 | } 145 | 146 | return (int) \ceil($totalCount / $this->limit()); // @phpstan-ignore return.type 147 | } 148 | 149 | /** 150 | * @return positive-int 151 | */ 152 | public function pageCount(): int 153 | { 154 | return $this->lastPage(); 155 | } 156 | 157 | public function haveToPaginate(): bool 158 | { 159 | return $this->pageCount() > 1; 160 | } 161 | 162 | /** 163 | * @return Collection 164 | */ 165 | private function getPage(): Collection 166 | { 167 | if (isset($this->cachedPage)) { 168 | return $this->cachedPage; 169 | } 170 | 171 | $offset = $this->currentPage() * $this->limit() - $this->limit(); 172 | 173 | return $this->cachedPage = $this->collection->take($this->limit(), $offset); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Collection/Pagerfanta/PagerfantaAdapter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Pagerfanta; 13 | 14 | use Pagerfanta\Adapter\AdapterInterface; 15 | use Zenstruck\Collection; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @template V 21 | * @implements AdapterInterface 22 | */ 23 | final class PagerfantaAdapter implements AdapterInterface 24 | { 25 | /** @var Collection */ 26 | private Collection $collection; 27 | 28 | /** 29 | * @param Collection $collection 30 | */ 31 | public function __construct(Collection $collection) 32 | { 33 | $this->collection = $collection; 34 | } 35 | 36 | public function getNbResults(): int 37 | { 38 | return $this->collection->count(); 39 | } 40 | 41 | public function getSlice($offset, $length): iterable 42 | { 43 | return $this->collection->take($length, $offset); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Collection/Pages.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @template V 20 | * @template K = array-key 21 | * @implements \IteratorAggregate> 22 | */ 23 | final class Pages implements \IteratorAggregate, \Countable 24 | { 25 | /** @var Page */ 26 | private Page $page1; 27 | 28 | /** 29 | * @param Collection $collection 30 | * @param positive-int $limit 31 | */ 32 | public function __construct(private Collection $collection, private int $limit = Page::DEFAULT_LIMIT) 33 | { 34 | } 35 | 36 | /** 37 | * @param positive-int $page 38 | * 39 | * @return Page 40 | */ 41 | public function get(int $page): Page 42 | { 43 | return 1 === $page ? $this->page1() : new Page($this->collection, $page, $this->limit); 44 | } 45 | 46 | public function getIterator(): \Traversable 47 | { 48 | if (0 === $this->count()) { 49 | return; 50 | } 51 | 52 | for ($page = 1; $page <= $this->count(); ++$page) { 53 | yield $this->get($page); 54 | } 55 | } 56 | 57 | public function count(): int 58 | { 59 | if (0 === $this->page1()->count()) { 60 | return 0; 61 | } 62 | 63 | return $this->page1()->pageCount(); 64 | } 65 | 66 | /** 67 | * @return Page 68 | */ 69 | private function page1(): Page 70 | { 71 | return $this->page1 ??= new Page($this->collection, 1, $this->limit); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Collection/Spec.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection; 13 | 14 | use Zenstruck\Collection\Specification\Callback; 15 | use Zenstruck\Collection\Specification\Filter\Contains; 16 | use Zenstruck\Collection\Specification\Filter\EndsWith; 17 | use Zenstruck\Collection\Specification\Filter\EqualTo; 18 | use Zenstruck\Collection\Specification\Filter\GreaterThan; 19 | use Zenstruck\Collection\Specification\Filter\GreaterThanOrEqualTo; 20 | use Zenstruck\Collection\Specification\Filter\In; 21 | use Zenstruck\Collection\Specification\Filter\IsNull; 22 | use Zenstruck\Collection\Specification\Filter\LessThan; 23 | use Zenstruck\Collection\Specification\Filter\LessThanOrEqualTo; 24 | use Zenstruck\Collection\Specification\Filter\StartsWith; 25 | use Zenstruck\Collection\Specification\Logic\AndX; 26 | use Zenstruck\Collection\Specification\Logic\Not; 27 | use Zenstruck\Collection\Specification\Logic\OrX; 28 | use Zenstruck\Collection\Specification\OrderBy; 29 | 30 | /** 31 | * @author Kevin Bond 32 | */ 33 | class Spec 34 | { 35 | private function __construct() 36 | { 37 | } 38 | 39 | final public static function andX(mixed ...$children): AndX 40 | { 41 | return new AndX(...$children); 42 | } 43 | 44 | final public static function orX(mixed ...$children): OrX 45 | { 46 | return new OrX(...$children); 47 | } 48 | 49 | final public static function not(mixed $restriction): Not 50 | { 51 | return new Not($restriction); 52 | } 53 | 54 | final public static function eq(string $field, mixed $value): EqualTo 55 | { 56 | return new EqualTo($field, $value); 57 | } 58 | 59 | final public static function contains(string $field, string $value): Contains 60 | { 61 | return new Contains($field, $value); 62 | } 63 | 64 | final public static function startsWith(string $field, string $value): StartsWith 65 | { 66 | return new StartsWith($field, $value); 67 | } 68 | 69 | final public static function endsWith(string $field, ?string $value): EndsWith 70 | { 71 | return new EndsWith($field, $value); 72 | } 73 | 74 | final public static function isNull(string $field): IsNull 75 | { 76 | return new IsNull($field); 77 | } 78 | 79 | /** 80 | * @param mixed[] $values 81 | */ 82 | final public static function in(string $field, array $values): In 83 | { 84 | return new In($field, $values); 85 | } 86 | 87 | final public static function lt(string $field, mixed $value): LessThan 88 | { 89 | return new LessThan($field, $value); 90 | } 91 | 92 | final public static function lte(string $field, mixed $value): LessThanOrEqualTo 93 | { 94 | return new LessThanOrEqualTo($field, $value); 95 | } 96 | 97 | final public static function gt(string $field, mixed $value): GreaterThan 98 | { 99 | return new GreaterThan($field, $value); 100 | } 101 | 102 | final public static function gte(string $field, mixed $value): GreaterThanOrEqualTo 103 | { 104 | return new GreaterThanOrEqualTo($field, $value); 105 | } 106 | 107 | final public static function callback(callable $value): Callback 108 | { 109 | return new Callback($value); 110 | } 111 | 112 | final public static function sortAsc(string $field): OrderBy 113 | { 114 | return OrderBy::asc($field); 115 | } 116 | 117 | final public static function sortDesc(string $field): OrderBy 118 | { 119 | return OrderBy::desc($field); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Collection/Specification/Callback.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class Callback 18 | { 19 | public readonly \Closure $value; 20 | 21 | public function __construct(callable $value) 22 | { 23 | $this->value = $value(...); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Collection/Specification/Comparison.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | abstract class Comparison extends Field 18 | { 19 | public function __construct(string $field, public readonly mixed $value) 20 | { 21 | parent::__construct($field); 22 | } 23 | 24 | final public function __toString(): string 25 | { 26 | $value = $this->value; 27 | 28 | if (\is_string($value)) { 29 | $value = "'{$value}'"; 30 | } 31 | 32 | return \sprintf('Compare(%s %s %s)', 33 | $this->field, 34 | (new \ReflectionClass($this))->getShortName(), 35 | \is_scalar($value) ? $value : \get_debug_type($value), 36 | ); 37 | } 38 | 39 | final public function value(): mixed 40 | { 41 | return $this->value; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Collection/Specification/Field.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | abstract class Field implements \Stringable 18 | { 19 | public function __construct(public readonly string $field) 20 | { 21 | } 22 | 23 | public function __toString(): string 24 | { 25 | return \sprintf('%s(%s)', (new \ReflectionClass($this))->getShortName(), $this->field); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/Between.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Field; 15 | use Zenstruck\Collection\Specification\Logic\AndX; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class Between extends Field 21 | { 22 | public const INCLUSIVE = '[]'; 23 | public const INCLUSIVE_BEGIN = '[)'; 24 | public const INCLUSIVE_END = '(]'; 25 | public const EXCLUSIVE = '()'; 26 | public const EXCLUSIVE_BEGIN = '(]'; 27 | public const EXCLUSIVE_END = '[)'; 28 | 29 | /** 30 | * @param self::* $type 31 | */ 32 | public function __construct( 33 | string $field, 34 | public readonly mixed $begin, 35 | public readonly mixed $end, 36 | public readonly string $type = self::INCLUSIVE, 37 | ) { 38 | parent::__construct($field); 39 | } 40 | 41 | public function __toString(): string 42 | { 43 | return \sprintf( 44 | 'Between%s%s AND %s%s', 45 | $this->type[0], 46 | \is_scalar($this->begin) ? $this->begin : \get_debug_type($this->begin), 47 | \is_scalar($this->end) ? $this->end : \get_debug_type($this->end), 48 | $this->type[1], 49 | ); 50 | } 51 | 52 | public static function inclusive(string $field, mixed $begin, mixed $end): self 53 | { 54 | return new self($field, $begin, $end, self::INCLUSIVE); 55 | } 56 | 57 | public static function exclusive(string $field, mixed $begin, mixed $end): self 58 | { 59 | return new self($field, $begin, $end, self::EXCLUSIVE); 60 | } 61 | 62 | public function asAnd(): AndX 63 | { 64 | return new AndX( 65 | '[' === $this->type[0] ? new GreaterThanOrEqualTo($this->field, $this->begin) : new GreaterThan($this->field, $this->begin), 66 | ']' === $this->type[1] ? new LessThanOrEqualTo($this->field, $this->end) : new LessThan($this->field, $this->end), 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/Contains.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class Contains extends Comparison 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/EndsWith.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class EndsWith extends Comparison 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/EqualTo.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class EqualTo extends Comparison 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/GreaterThan.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class GreaterThan extends Comparison 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/GreaterThanOrEqualTo.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class GreaterThanOrEqualTo extends Comparison 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/In.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @property mixed[] $value 20 | */ 21 | final class In extends Comparison 22 | { 23 | /** 24 | * @param mixed[] $value 25 | */ 26 | public function __construct(string $field, array $value) 27 | { 28 | parent::__construct($field, $value); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/IsNull.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Field; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class IsNull extends Field 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/LessThan.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class LessThan extends Comparison 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/LessThanOrEqualTo.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class LessThanOrEqualTo extends Comparison 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Filter/StartsWith.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Filter; 13 | 14 | use Zenstruck\Collection\Specification\Comparison; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class StartsWith extends Comparison 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Collection/Specification/Logic/AndX.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Logic; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class AndX extends Composite 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /src/Collection/Specification/Logic/Composite.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Logic; 13 | 14 | use Zenstruck\Collection\Specification\Util; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | abstract class Composite implements \Stringable 20 | { 21 | /** @var mixed[] */ 22 | public readonly array $children; 23 | 24 | public function __construct(mixed ...$children) 25 | { 26 | $this->children = $children; 27 | } 28 | 29 | final public function __toString(): string 30 | { 31 | $children = \array_filter(\array_map([Util::class, 'stringify'], $this->children)); 32 | 33 | return \sprintf('%s(%s)', (new \ReflectionClass($this))->getShortName(), \implode(', ', $children)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Collection/Specification/Logic/Not.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Logic; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class Not extends Composite 18 | { 19 | public function __construct(mixed $restriction) 20 | { 21 | parent::__construct($restriction); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Collection/Specification/Logic/OrX.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification\Logic; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class OrX extends Composite 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /src/Collection/Specification/Nested.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | interface Nested 18 | { 19 | public function specification(): mixed; 20 | } 21 | -------------------------------------------------------------------------------- /src/Collection/Specification/OrderBy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class OrderBy extends Field 18 | { 19 | public const ASC = 'ASC'; 20 | public const DESC = 'DESC'; 21 | 22 | /** @var self::* */ 23 | public readonly string $direction; 24 | 25 | /** 26 | * @param self::* $direction 27 | */ 28 | public function __construct(string $field, string $direction) 29 | { 30 | parent::__construct($field); 31 | 32 | $this->direction = match (\mb_strtoupper($direction)) { 33 | self::DESC => self::DESC, 34 | default => self::ASC, 35 | }; 36 | } 37 | 38 | public static function asc(string $field): self 39 | { 40 | return new self($field, self::ASC); 41 | } 42 | 43 | public static function desc(string $field): self 44 | { 45 | return new self($field, self::DESC); 46 | } 47 | 48 | public function opposite(): self 49 | { 50 | return new self($this->field, self::ASC === $this->direction ? self::DESC : self::ASC); 51 | } 52 | 53 | public function isAsc(): bool 54 | { 55 | return self::ASC === $this->direction; 56 | } 57 | 58 | public function isDesc(): bool 59 | { 60 | return self::DESC === $this->direction; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Collection/Specification/Util.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Specification; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * 17 | * @internal 18 | */ 19 | final class Util 20 | { 21 | public static function stringify(mixed $specification): string 22 | { 23 | if ($specification instanceof \Stringable) { 24 | return $specification; 25 | } 26 | 27 | if ($specification instanceof Nested) { 28 | return \sprintf('%s(%s)', $specification::class, self::stringify($specification->specification())); 29 | } 30 | 31 | return \get_debug_type($specification); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Collection/Symfony/Attributes/AsGrid.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Symfony\Attributes; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | #[\Attribute(\Attribute::TARGET_CLASS)] 18 | final class AsGrid 19 | { 20 | public function __construct(public readonly string $name) 21 | { 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Collection/Symfony/Attributes/ForDefinition.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Symfony\Attributes; 13 | 14 | use Symfony\Component\DependencyInjection\Attribute\Autowire; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | #[\Attribute(\Attribute::TARGET_PARAMETER)] 20 | final class ForDefinition extends Autowire 21 | { 22 | public function __construct(string $name, ?string $key = null) 23 | { 24 | parent::__construct( 25 | expression: \sprintf( 26 | 'service(".zenstruck_collection.grid_factory").createFor("%s", service("request_stack").getCurrentRequest(), "%s")', 27 | \addslashes($name), 28 | $key, 29 | ) 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Collection/Symfony/Attributes/ForObject.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Symfony\Attributes; 13 | 14 | use Symfony\Component\DependencyInjection\Attribute\Autowire; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | #[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_CLASS)] 20 | final class ForObject extends Autowire 21 | { 22 | /** 23 | * @param class-string $class 24 | */ 25 | public function __construct(public readonly string $class) 26 | { 27 | parent::__construct( 28 | expression: \sprintf('service(".zenstruck_collection.doctrine.chain_object_repo_factory").create("%s")', \addslashes($this->class)), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Collection/Symfony/Doctrine/ChainObjectRepositoryFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Symfony\Doctrine; 13 | 14 | use Symfony\Contracts\Service\ResetInterface; 15 | use Zenstruck\Collection\Doctrine\ObjectRepository; 16 | use Zenstruck\Collection\Doctrine\ObjectRepositoryFactory; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @internal 22 | */ 23 | final class ChainObjectRepositoryFactory implements ObjectRepositoryFactory, ResetInterface 24 | { 25 | /** @var array> */ 26 | private array $cache = []; 27 | 28 | public function __construct(private ObjectRepositoryFactory $inner) 29 | { 30 | } 31 | 32 | public function create(string $class): ObjectRepository 33 | { 34 | return $this->cache[$class] ??= $this->inner->create($class); // @phpstan-ignore return.type 35 | } 36 | 37 | public function reset(): void 38 | { 39 | $this->cache = []; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Collection/Symfony/Grid/GridFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Symfony\Grid; 13 | 14 | use Psr\Container\ContainerInterface; 15 | use Symfony\Component\HttpFoundation\Request; 16 | use Zenstruck\Collection\Grid; 17 | use Zenstruck\Collection\Grid\GridBuilder; 18 | use Zenstruck\Collection\Grid\GridDefinition; 19 | use Zenstruck\Collection\Grid\Input\UriInput; 20 | 21 | /** 22 | * @author Kevin Bond 23 | */ 24 | final class GridFactory 25 | { 26 | public function __construct(private ContainerInterface $definitions) 27 | { 28 | } 29 | 30 | /** 31 | * @return Grid|object> 32 | */ 33 | public function createFor(string $definition, string|Request $input, ?string $key = null): Grid 34 | { 35 | $definitionObject = $this->definitions->get($definition); 36 | 37 | if (!$definitionObject instanceof GridDefinition) { 38 | throw new \LogicException(\sprintf('Definition "%s" must be an instance of "%s".', $definition, GridDefinition::class)); 39 | } 40 | 41 | $definitionObject->configure($builder = new GridBuilder()); 42 | 43 | return $builder->build(new UriInput($input, $key)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Collection/Symfony/ZenstruckCollectionBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Collection\Symfony; 13 | 14 | use Symfony\Component\Config\FileLocator; 15 | use Symfony\Component\DependencyInjection\ChildDefinition; 16 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 17 | use Symfony\Component\DependencyInjection\ContainerBuilder; 18 | use Symfony\Component\DependencyInjection\Exception\LogicException; 19 | use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; 20 | use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; 21 | use Symfony\Component\DependencyInjection\Reference; 22 | use Symfony\Component\HttpKernel\Bundle\AbstractBundle; 23 | use Zenstruck\Collection\Doctrine\Grid\ObjectGridDefinition; 24 | use Zenstruck\Collection\Doctrine\ORM\EntityRepository; 25 | use Zenstruck\Collection\Grid\GridDefinition; 26 | use Zenstruck\Collection\Symfony\Attributes\AsGrid; 27 | use Zenstruck\Collection\Symfony\Attributes\ForObject; 28 | 29 | use function Zenstruck\collect; 30 | 31 | /** 32 | * @author Kevin Bond 33 | * 34 | * @codeCoverageIgnore 35 | */ 36 | final class ZenstruckCollectionBundle extends AbstractBundle implements CompilerPassInterface 37 | { 38 | public function build(ContainerBuilder $container): void 39 | { 40 | $container->addCompilerPass($this); 41 | } 42 | 43 | public function getPath(): string 44 | { 45 | return __DIR__.'/../../../'; 46 | } 47 | 48 | /** 49 | * @param mixed[] $config 50 | */ 51 | public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void 52 | { 53 | $loader = new PhpFileLoader($builder, new FileLocator(__DIR__.'/../../../config/symfony')); 54 | 55 | $loader->load('grid.php'); 56 | 57 | $builder->registerAttributeForAutoconfiguration(AsGrid::class, function(ChildDefinition $definition, AsGrid $attribute) { 58 | $definition->addTag('zenstruck_collection.grid_definition', ['key' => $attribute->name]); 59 | }); 60 | 61 | if (isset($builder->getParameter('kernel.bundles')['DoctrineBundle'])) { // @phpstan-ignore offsetAccess.nonOffsetAccessible 62 | $loader->load('doctrine.php'); 63 | 64 | $builder->registerAttributeForAutoconfiguration(ForObject::class, function(ChildDefinition $definition, ForObject $attribute, \ReflectionClass $class) { // @phpstan-ignore argument.type 65 | if ($class->implementsInterface(GridDefinition::class)) { 66 | $definition->addTag('zenstruck_collection.grid_definition', ['key' => $attribute->class, 'as_object' => true]); 67 | 68 | return; 69 | } 70 | 71 | if (!$class->isSubclassOf(EntityRepository::class)) { 72 | throw new LogicException(\sprintf('Can only use "%s" on classes that implement "%s" or extend "%s".', ForObject::class, GridDefinition::class, EntityRepository::class)); 73 | } 74 | 75 | if (EntityRepository::class !== $class->getConstructor()?->getDeclaringClass()->name) { 76 | throw new LogicException(\sprintf('Cannot use "%s" on "%s" as it overrides the constructor.', ForObject::class, $class->name)); 77 | } 78 | 79 | $definition->setArgument('$class', $attribute->class); 80 | }); 81 | } 82 | } 83 | 84 | public function process(ContainerBuilder $container): void 85 | { 86 | if (!isset($container->getParameter('kernel.bundles')['DoctrineBundle'])) { // @phpstan-ignore offsetAccess.nonOffsetAccessible 87 | return; 88 | } 89 | 90 | foreach ($container->findTaggedServiceIds('zenstruck_collection.grid_definition') as $id => $tags) { 91 | foreach ($tags as $tag) { 92 | if (!($tag['as_object'] ?? false)) { 93 | continue; 94 | } 95 | 96 | $container->register($id.'.object', ObjectGridDefinition::class) 97 | ->setDecoratedService($id) 98 | ->setArguments([ 99 | $tag['key'], 100 | new Reference('.zenstruck_collection.doctrine.chain_object_repo_factory'), 101 | new Reference($id.'.object.inner'), 102 | ]) 103 | ; 104 | 105 | if ($gridTag = collect($tags)->find(fn(array $t) => false === ($t['as_object'] ?? false))) { 106 | // service was also tagged using AsGrid - use it as the "alias" 107 | $container->getDefinition($id) 108 | ->clearTag('zenstruck_collection.grid_definition') 109 | ->addTag('zenstruck_collection.grid_definition', ['key' => $gridTag['key']]) 110 | ; 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck; 13 | 14 | use Doctrine\Common\Collections\Collection as DoctrineCollection; 15 | use Zenstruck\Collection\ArrayCollection; 16 | use Zenstruck\Collection\Doctrine\DoctrineBridgeCollection; 17 | use Zenstruck\Collection\LazyCollection; 18 | 19 | /** 20 | * @template V 21 | * @template K 22 | * 23 | * @param null|iterable|callable():iterable $source 24 | * 25 | * @return Collection 26 | * @phpstan-return ($source is null ? Collection : ($source is array ? ArrayCollection : ($source is DoctrineCollection ? DoctrineBridgeCollection : Collection))) 27 | */ 28 | function collect(iterable|callable|null $source = null): Collection 29 | { 30 | if ($source instanceof Collection) { 31 | return $source; 32 | } 33 | 34 | if ($source instanceof DoctrineCollection) { 35 | return new DoctrineBridgeCollection($source); 36 | } 37 | 38 | if (\is_array($source)) { 39 | return new ArrayCollection($source); 40 | } 41 | 42 | return new LazyCollection($source ?? []); 43 | } 44 | -------------------------------------------------------------------------------- /templates/Grid/Pager/_full.html.twig: -------------------------------------------------------------------------------- 1 | {% apply spaceless %} 2 | {% set pager = grid.page %} 3 | {% set input = grid.input %} 4 | {% set window = window|default(4) %} 5 |
    6 | {% if pager.currentPage == 1 %} 7 |
  • <
  • 8 | {% else %} 9 |
  • <
  • 10 | {% endif %} 11 | 12 | {% for i in 1..pager.pageCount %} 13 | {% if loop.index == 1 and pager.currentPage != loop.index %} 14 |
  • 15 | {{ loop.index }} 16 |
  • 17 | {% elseif loop.index == pager.pageCount and pager.currentPage != loop.index %} 18 |
  • 19 | {{ loop.index }} 20 |
  • 21 | {% elseif 0 == (pager.currentPage - window) - loop.index %} {# dots before #} 22 |
  • ...
  • 23 | {% elseif 0 == (pager.currentPage + window) - loop.index %} {# dots after #} 24 |
  • ...
  • 25 | {% elseif 0 < (pager.currentPage - window) - loop.index %} {# hide all before #} 26 | {% elseif 0 > (pager.currentPage + window) - loop.index %} {# hide all after #} 27 | {% elseif pager.currentPage == loop.index %} 28 |
  • 29 | {{ loop.index }} 30 |
  • 31 | {% else %} 32 |
  • 33 | {{ loop.index }} 34 |
  • 35 | {% endif %} 36 | {% endfor %} 37 | 38 | {% if pager.currentPage < pager.pageCount %} 39 |
  • >
  • 40 | {% else %} 41 |
  • >
  • 42 | {% endif %} 43 |
44 | {% endapply %} 45 | -------------------------------------------------------------------------------- /templates/Grid/Pager/_simple.html.twig: -------------------------------------------------------------------------------- 1 | 7 | --------------------------------------------------------------------------------