├── .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