1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/* |
4
|
|
|
* (c) Olivier Laviale <[email protected]> |
5
|
|
|
* |
6
|
|
|
* For the full copyright and license information, please view the LICENSE |
7
|
|
|
* file that was distributed with this source code. |
8
|
|
|
*/ |
9
|
|
|
|
10
|
|
|
namespace olvlvl\EventDispatcher\Symfony; |
11
|
|
|
|
12
|
|
|
use olvlvl\EventDispatcher\ListenerProviderWithContainer; |
13
|
|
|
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; |
14
|
|
|
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; |
15
|
|
|
use Symfony\Component\DependencyInjection\ContainerBuilder; |
16
|
|
|
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; |
17
|
|
|
use Symfony\Component\DependencyInjection\Exception\LogicException; |
18
|
|
|
use Symfony\Component\DependencyInjection\TypedReference; |
19
|
|
|
|
20
|
|
|
use function array_fill_keys; |
21
|
|
|
use function array_filter; |
22
|
|
|
use function array_flip; |
23
|
|
|
use function array_intersect; |
24
|
|
|
use function array_intersect_key; |
25
|
|
|
use function array_keys; |
26
|
|
|
use function array_merge; |
27
|
|
|
use function array_slice; |
28
|
|
|
use function class_exists; |
29
|
|
|
use function count; |
30
|
|
|
use function implode; |
31
|
|
|
use function interface_exists; |
32
|
|
|
use function is_int; |
33
|
|
|
use function max; |
34
|
|
|
use function min; |
35
|
|
|
use function sprintf; |
36
|
|
|
use function usort; |
37
|
|
|
|
38
|
|
|
/** |
39
|
|
|
* A compilation pass for event listeners. |
40
|
|
|
*/ |
41
|
|
|
final class ListenerProviderPass implements CompilerPassInterface |
42
|
|
|
{ |
43
|
|
|
public const DEFAULT_PROVIDER_TAG = 'listener_provider'; |
44
|
|
|
public const DEFAULT_LISTENER_TAG = 'event_listener'; |
45
|
|
|
public const DEFAULT_PRIORITY = 0; |
46
|
|
|
public const ATTRIBUTE_LISTENER_TAG = 'listener_tag'; |
47
|
|
|
public const ATTRIBUTE_EVENT = 'event'; |
48
|
|
|
public const ATTRIBUTE_PRIORITY = 'priority'; |
49
|
|
|
public const ATTRIBUTE_BEFORE = 'before'; |
50
|
|
|
public const ATTRIBUTE_AFTER = 'after'; |
51
|
|
|
public const PRIORITY_FIRST = 'first'; |
52
|
|
|
public const PRIORITY_LAST = 'last'; |
53
|
|
|
|
54
|
|
|
private const PLACEMENT_ATTRIBUTES = [ |
55
|
|
|
self::ATTRIBUTE_PRIORITY, |
56
|
|
|
self::ATTRIBUTE_BEFORE, |
57
|
|
|
self::ATTRIBUTE_AFTER, |
58
|
|
|
]; |
59
|
|
|
|
60
|
|
|
/** |
61
|
|
|
* @var string |
62
|
|
|
*/ |
63
|
|
|
private $providerTag; |
64
|
|
|
|
65
|
|
|
/** |
66
|
|
|
* @param string $providerTag Tag identifying listener providers. |
67
|
|
|
*/ |
68
|
|
|
public function __construct(string $providerTag = self::DEFAULT_PROVIDER_TAG) |
69
|
|
|
{ |
70
|
|
|
$this->providerTag = $providerTag; |
71
|
|
|
} |
72
|
|
|
|
73
|
|
|
/** |
74
|
|
|
* @inheritDoc |
75
|
|
|
*/ |
76
|
|
|
public function process(ContainerBuilder $container): void |
77
|
|
|
{ |
78
|
|
|
foreach ($this->providerIterator($container) as $id => $listenerTag) { |
79
|
|
|
[ $mapping, $refMap ] = $this->collectListeners($container, $listenerTag); |
80
|
|
|
|
81
|
|
|
$container |
82
|
|
|
->getDefinition($id) |
83
|
|
|
->setSynthetic(false) |
84
|
|
|
->setClass(ListenerProviderWithContainer::class) |
85
|
|
|
->setArguments( |
86
|
|
|
[ |
87
|
|
|
$mapping, |
88
|
|
|
ServiceLocatorTagPass::register($container, $refMap), |
89
|
|
|
] |
90
|
|
|
); |
91
|
|
|
} |
92
|
|
|
} |
93
|
|
|
|
94
|
|
|
/** |
95
|
|
|
* @return iterable<string, string> |
96
|
|
|
*/ |
97
|
|
|
private function providerIterator(ContainerBuilder $container): iterable |
98
|
|
|
{ |
99
|
|
|
foreach ($container->findTaggedServiceIds($this->providerTag, true) as $id => $tags) { |
100
|
|
|
$listener_tag = $tags[0][self::ATTRIBUTE_LISTENER_TAG] ?? self::DEFAULT_LISTENER_TAG; |
101
|
|
|
|
102
|
|
|
yield $id => $listener_tag; |
103
|
|
|
} |
104
|
|
|
} |
105
|
|
|
|
106
|
|
|
/** |
107
|
|
|
* @return array{0: array<class-string, string[]>, 1: array<string, TypedReference>} |
108
|
|
|
*/ |
109
|
|
|
private function collectListeners(ContainerBuilder $container, string $listenerTag): array |
110
|
|
|
{ |
111
|
|
|
$listeners = $container->findTaggedServiceIds($listenerTag, true); |
112
|
|
|
$mapping = []; |
113
|
|
|
$refMap = []; |
114
|
|
|
$prioritiesByEvent = []; |
115
|
|
|
$beforeByEvent = []; |
116
|
|
|
$afterByEvent = []; |
117
|
|
|
|
118
|
|
|
foreach ($listeners as $id => $tags) { |
119
|
|
|
foreach ($tags as $attributes) { |
120
|
|
|
$class = $container->getDefinition($id)->getClass(); |
121
|
|
|
|
122
|
|
|
if (!$class) { |
123
|
|
|
throw new InvalidArgumentException("Missing class for listener '$id'."); |
124
|
|
|
} |
125
|
|
|
|
126
|
|
|
$refMap[$id] = new TypedReference($id, $class); |
127
|
|
|
|
128
|
|
|
$event = $this->extractEvent($attributes, $id); |
129
|
|
|
$mapping[$event][] = $id; |
130
|
|
|
|
131
|
|
|
$this->assertPlacement($attributes, $id); |
132
|
|
|
|
133
|
|
|
if (isset($attributes[self::ATTRIBUTE_BEFORE])) { |
134
|
|
|
$relative = $attributes[self::ATTRIBUTE_BEFORE]; |
135
|
|
|
$this->assertRelative($listeners, $relative, $id); |
136
|
|
|
$beforeByEvent[$event][$id] = $relative; |
137
|
|
|
} elseif (isset($attributes[self::ATTRIBUTE_AFTER])) { |
138
|
|
|
$relative = $attributes[self::ATTRIBUTE_AFTER]; |
139
|
|
|
$this->assertRelative($listeners, $relative, $id); |
140
|
|
|
$afterByEvent[$event][$id] = $relative; |
141
|
|
|
} else { |
142
|
|
|
$prioritiesByEvent[$event][$id] = $this->extractPriority($attributes, $id); |
143
|
|
|
} |
144
|
|
|
} |
145
|
|
|
} |
146
|
|
|
|
147
|
|
|
return [ |
148
|
|
|
$this->sortMapping($mapping, $prioritiesByEvent, $beforeByEvent, $afterByEvent), |
149
|
|
|
$refMap, |
150
|
|
|
]; |
151
|
|
|
} |
152
|
|
|
|
153
|
|
|
/** |
154
|
|
|
* @param array<string, mixed> $tag |
155
|
|
|
* |
156
|
|
|
* @return class-string |
157
|
|
|
*/ |
158
|
|
|
private function extractEvent(array $tag, string $id): string |
159
|
|
|
{ |
160
|
|
|
$event = $tag[self::ATTRIBUTE_EVENT] ?? null; |
161
|
|
|
|
162
|
|
|
if (!$event) { |
163
|
|
|
$attribute = self::ATTRIBUTE_EVENT; |
164
|
|
|
|
165
|
|
|
throw new InvalidArgumentException( |
166
|
|
|
"Missing event type for listener '$id'." |
167
|
|
|
. " Try to specify the event using the attribute '$attribute'." |
168
|
|
|
); |
169
|
|
|
} |
170
|
|
|
|
171
|
|
|
if (!class_exists($event) && !interface_exists($event)) { |
172
|
|
|
throw new InvalidArgumentException( |
173
|
|
|
"Unable to load event class or interface '$event' for listener '$id'." |
174
|
|
|
); |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
return $event; |
178
|
|
|
} |
179
|
|
|
|
180
|
|
|
/** |
181
|
|
|
* @param array<string, mixed> $attributes |
182
|
|
|
*/ |
183
|
|
|
public function assertPlacement(array $attributes, string $id): void |
184
|
|
|
{ |
185
|
|
|
$positions = array_intersect(self::PLACEMENT_ATTRIBUTES, array_keys($attributes)); |
186
|
|
|
|
187
|
|
|
if (count($positions) > 1) { |
188
|
|
|
throw new LogicException(sprintf( |
189
|
|
|
"Invalid definition for listener '$id', can only specify one of: %s.", |
190
|
|
|
implode(', ', self::PLACEMENT_ATTRIBUTES) |
191
|
|
|
)); |
192
|
|
|
} |
193
|
|
|
} |
194
|
|
|
|
195
|
|
|
/** |
196
|
|
|
* @param array<string, mixed> $listeners |
197
|
|
|
*/ |
198
|
|
|
public function assertRelative(array $listeners, string $relative, string $id): void |
199
|
|
|
{ |
200
|
|
|
if (!isset($listeners[$relative])) { |
201
|
|
|
throw new InvalidArgumentException("Undefined relative for listener '$id': $relative."); |
202
|
|
|
} |
203
|
|
|
} |
204
|
|
|
|
205
|
|
|
/** |
206
|
|
|
* @param array<string, mixed> $attributes |
207
|
|
|
* |
208
|
|
|
* @return int|string |
209
|
|
|
*/ |
210
|
|
|
private function extractPriority(array $attributes, string $id) |
211
|
|
|
{ |
212
|
|
|
$priority = $attributes[self::ATTRIBUTE_PRIORITY] ?? self::DEFAULT_PRIORITY; |
213
|
|
|
|
214
|
|
|
if ($priority !== self::PRIORITY_FIRST && $priority !== self::PRIORITY_LAST && !is_int($priority)) { |
215
|
|
|
throw new InvalidArgumentException( |
216
|
|
|
"Invalid priority value for listener '$id': $priority." |
217
|
|
|
. " Valid values are 'first', 'last', or an integer." |
218
|
|
|
); |
219
|
|
|
} |
220
|
|
|
|
221
|
|
|
return $priority; |
222
|
|
|
} |
223
|
|
|
|
224
|
|
|
/** |
225
|
|
|
* @param array<class-string, string[]> $mapping |
226
|
|
|
* Where _key_ is an Event and _value_ is an array of service identifiers. |
227
|
|
|
* @param array<class-string, array<string, int|string>> $prioritiesByEvent |
228
|
|
|
* Where _key_ is an Event and _value_ is an array where _key_ is a service identifier and _value_ a priority. |
229
|
|
|
* @param array<class-string, array<string, string>> $beforeByEvent |
230
|
|
|
* Where _key_ is an Event and _value_ is an array where _key_ and _value_ are both service identifiers. |
231
|
|
|
* @param array<class-string, array<string, string>> $afterByEvent |
232
|
|
|
* Where _key_ is an Event and _value_ is an array where _key_ and _value_ are both service identifiers. |
233
|
|
|
* |
234
|
|
|
* @return array<class-string, string[]> |
235
|
|
|
* Where _key_ is an Event and _value_ is an array of service identifiers, sorted. |
236
|
|
|
*/ |
237
|
|
|
private function sortMapping( |
238
|
|
|
array $mapping, |
239
|
|
|
array $prioritiesByEvent, |
240
|
|
|
array $beforeByEvent, |
241
|
|
|
array $afterByEvent |
242
|
|
|
): array { |
243
|
|
|
foreach ($mapping as $event => &$listeners) { |
244
|
|
|
$listeners = $this->sortListeners( |
245
|
|
|
$listeners, |
246
|
|
|
$prioritiesByEvent[$event] ?? [], |
247
|
|
|
$beforeByEvent[$event] ?? [], |
248
|
|
|
$afterByEvent[$event] ?? [] |
249
|
|
|
); |
250
|
|
|
} |
251
|
|
|
|
252
|
|
|
return $mapping; |
253
|
|
|
} |
254
|
|
|
|
255
|
|
|
/** |
256
|
|
|
* @param string[] $listeners |
257
|
|
|
* An array of service identifiers. |
258
|
|
|
* @param array<string, int|string> $priorities |
259
|
|
|
* Where _key_ is a service identifier and _value_ a priority. |
260
|
|
|
* @param array<string, string> $beforePositions |
261
|
|
|
* Where _key_ and _value_ are both service identifiers. |
262
|
|
|
* @param array<string, string> $afterPositions |
263
|
|
|
* Where _key_ and _value_ are both service identifiers. |
264
|
|
|
* |
265
|
|
|
* @return string[] |
266
|
|
|
* An array of service identifiers, sorted. |
267
|
|
|
*/ |
268
|
|
|
private function sortListeners( |
269
|
|
|
array $listeners, |
270
|
|
|
array $priorities, |
271
|
|
|
array $beforePositions, |
272
|
|
|
array $afterPositions |
273
|
|
|
): array { |
274
|
|
|
$priorities = $this->resolvePriorities($priorities); |
275
|
|
|
$listenersByPosition = array_flip($listeners); |
276
|
|
|
$listenersWithPriority = array_intersect_key($priorities, $listenersByPosition); |
277
|
|
|
$sortedListeners = array_fill_keys($this->sortListenersWithPriority($listenersWithPriority), 0); |
278
|
|
|
|
279
|
|
|
while ($beforePositions || $afterPositions) { |
|
|
|
|
280
|
|
|
$inserted = 0; |
281
|
|
|
|
282
|
|
|
foreach ($beforePositions as $id => $relative) { |
283
|
|
|
if (isset($sortedListeners[$relative])) { |
284
|
|
|
$this->insertBeforeListener($sortedListeners, $id, $relative); |
285
|
|
|
$inserted++; |
286
|
|
|
unset($beforePositions[$id]); |
287
|
|
|
} |
288
|
|
|
} |
289
|
|
|
|
290
|
|
|
foreach ($afterPositions as $id => $relative) { |
291
|
|
|
if (isset($sortedListeners[$relative])) { |
292
|
|
|
$this->insertAfterListener($sortedListeners, $id, $relative); |
293
|
|
|
$inserted++; |
294
|
|
|
unset($afterPositions[$id]); |
295
|
|
|
} |
296
|
|
|
} |
297
|
|
|
|
298
|
|
|
if (!$inserted) { |
299
|
|
|
throw new LogicException(sprintf( |
300
|
|
|
"Unable to insert the following listeners: %s. Please check the position logic.", |
301
|
|
|
implode(', ', array_merge(array_keys($beforePositions), array_keys($afterPositions))) |
302
|
|
|
)); |
303
|
|
|
} |
304
|
|
|
} |
305
|
|
|
|
306
|
|
|
// @phpstan-ignore-next-line |
307
|
|
|
return array_keys($sortedListeners); |
308
|
|
|
} |
309
|
|
|
|
310
|
|
|
/** |
311
|
|
|
* @param array<string, string|int> $priorities |
312
|
|
|
* |
313
|
|
|
* @return array<string, int> |
314
|
|
|
*/ |
315
|
|
|
private function resolvePriorities(array $priorities): array |
316
|
|
|
{ |
317
|
|
|
$numericPriorities = array_filter($priorities, 'is_int'); |
318
|
|
|
if (!$numericPriorities) { |
|
|
|
|
319
|
|
|
return []; |
320
|
|
|
} |
321
|
|
|
|
322
|
|
|
$min = min($numericPriorities); |
323
|
|
|
$max = max($numericPriorities); |
324
|
|
|
|
325
|
|
|
foreach ($priorities as &$priority) { |
326
|
|
|
if ($priority === self::PRIORITY_FIRST) { |
327
|
|
|
$priority = ++$max; |
328
|
|
|
} elseif ($priority === self::PRIORITY_LAST) { |
329
|
|
|
$priority = --$min; |
330
|
|
|
} |
331
|
|
|
} |
332
|
|
|
|
333
|
|
|
// @phpstan-ignore-next-line |
334
|
|
|
return $priorities; |
335
|
|
|
} |
336
|
|
|
|
337
|
|
|
/** |
338
|
|
|
* @param array<string, int> $listenersWithPriority |
339
|
|
|
* |
340
|
|
|
* @return string[] |
341
|
|
|
*/ |
342
|
|
|
private function sortListenersWithPriority(array $listenersWithPriority): array |
343
|
|
|
{ |
344
|
|
|
$listeners = array_keys($listenersWithPriority); |
345
|
|
|
$positions = array_flip($listeners); |
346
|
|
|
|
347
|
|
|
usort( |
348
|
|
|
$listeners, |
349
|
|
|
function (string $a, string $b) use ($listenersWithPriority, $positions): int { |
350
|
|
|
$pa = $listenersWithPriority[$a]; |
351
|
|
|
$pb = $listenersWithPriority[$b]; |
352
|
|
|
|
353
|
|
|
if ($pa === $pb) { |
354
|
|
|
// Same priority. Let's compare the original orders, which are ascending. |
355
|
|
|
return $positions[$a] <=> $positions[$b]; |
356
|
|
|
} |
357
|
|
|
|
358
|
|
|
// Priorities are descending. |
359
|
|
|
return $pb <=> $pa; |
360
|
|
|
} |
361
|
|
|
); |
362
|
|
|
|
363
|
|
|
return $listeners; |
364
|
|
|
} |
365
|
|
|
|
366
|
|
|
/** |
367
|
|
|
* @param array<string, mixed> $listeners Where _key_ is a service identifier. |
368
|
|
|
*/ |
369
|
|
|
public function insertBeforeListener(array &$listeners, string $id, string $relative): void |
370
|
|
|
{ |
371
|
|
|
$positions = array_flip(array_keys($listeners)); |
372
|
|
|
$p = $positions[$relative]; |
373
|
|
|
|
374
|
|
|
if ($p === 0) { |
375
|
|
|
$listeners = [ $id => 0 ] + $listeners; |
376
|
|
|
|
377
|
|
|
return; |
378
|
|
|
} |
379
|
|
|
|
380
|
|
|
$listeners = array_slice($listeners, 0, $p) |
381
|
|
|
+ [ $id => true ] |
382
|
|
|
+ array_slice($listeners, $p); |
383
|
|
|
} |
384
|
|
|
|
385
|
|
|
/** |
386
|
|
|
* @param array<string, mixed> $listeners Where _key_ is a service identifier. |
387
|
|
|
*/ |
388
|
|
|
public function insertAfterListener(array &$listeners, string $id, string $relative): void |
389
|
|
|
{ |
390
|
|
|
$positions = array_flip(array_keys($listeners)); |
391
|
|
|
$p = $positions[$relative]; |
392
|
|
|
|
393
|
|
|
if ($p + 1 === count($listeners)) { |
394
|
|
|
$listeners += [ $id => 0 ]; |
395
|
|
|
|
396
|
|
|
return; |
397
|
|
|
} |
398
|
|
|
|
399
|
|
|
$listeners = array_slice($listeners, 0, $p + 1) |
400
|
|
|
+ [ $id => true ] |
401
|
|
|
+ array_slice($listeners, $p - 1); |
402
|
|
|
} |
403
|
|
|
} |
404
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.