-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCollectUsing.php
More file actions
229 lines (193 loc) · 9.35 KB
/
CollectUsing.php
File metadata and controls
229 lines (193 loc) · 9.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
<?php
declare( strict_types = 1 );
namespace TheWebSolver\Codegarage\Scraper\Attributes;
use Attribute;
use BackedEnum;
use ReflectionClass;
use TheWebSolver\Codegarage\Scraper\Error\InvalidSource;
#[Attribute( flags: Attribute::TARGET_CLASS )]
final readonly class CollectUsing {
/** @var list<?string> */
public array $all;
/** @var non-empty-array<int,string> */
public array $items;
/** @var list<int> */
public array $offsets;
public ?string $indexKey;
/**
* @param class-string<BackedEnum<string>> $enumClass The BackedEnum classname whose case values will be mapped as keys of collected items.
* @param ?BackedEnum<string> $indexKey The key whose value to be used as items' dataset key.
* @param BackedEnum<string>|string|null $subsetCaseOrValueOrOffset Accepts subsets of enum cases or cases in different order than defined in the enum.
* Cases passed must be in sequential order they gets mapped to items being collected.
* If an in-between case/item needs to be omitted, `null` must be passed to offset it.
* @throws InvalidSource When `$enumClass` is not a enum classname, enum has no case defined, or all `subsetCaseOrValueOrOffset` are null.
* @no-named-arguments
*/
public function __construct(
private string $enumClass,
?BackedEnum $indexKey = null,
BackedEnum|null|string ...$subsetCaseOrValueOrOffset
) {
is_a( $enumClass, BackedEnum::class, allow_string: true ) || throw InvalidSource::nonCollectableItem(
reason: "with invalid enum classname \"{$enumClass}\" to compute"
);
[$this->all, $this->items, $this->offsets] = $this->computeFor( ...$subsetCaseOrValueOrOffset );
$this->indexKey = $indexKey?->value;
}
/**
* Gets collection instance when only arbitrary collectable names array is known without any enum class.
*
* @param non-empty-list<?string> $names Names used for collection. These must be passed in sequential order as
* they gets mapped to items being collected. If an in-between item needs
* to be omitted, `null` must be passed to offset it. Be aware that `null`
* is forbidden when using in combination with `$compute` set as `false`.
* @param bool $compute When this is `false`, `$names` are set as _all_ property value and
* _items_ property value without computing any in-between offsets.
* @throws InvalidSource When `$names` empty, `null` passed with `$compute` as `false`, or `$indexKey` not found in `$names`.
*/
public static function listOf( array $names, ?string $indexKey = null, bool $compute = false ): self {
! ! $names || throw InvalidSource::nonCollectableItem( 'because given list is empty. Provide at-least one' );
$indexKey && self::validateIndexKey( $indexKey, ...$names );
! $compute && in_array( null, $names, true ) && throw InvalidSource::nonCollectableItem(
reason: 'because when computation is disabled, "null" (offset) must not be passed as',
names: self::mapNullToString( ...$names )
);
$_this = ( new ReflectionClass( __CLASS__ ) )->newInstanceWithoutConstructor();
[$all, $items, $offsets] = $compute ? $_this->findOffsetsIn( ...$names ) : [ $names, $names, [] ];
return $_this->withProperties( compact( 'all', 'items', 'offsets', 'indexKey' ) );
}
/**
* @param string|BackedEnum<string> $value
* @throws InvalidSource When given name does not exist in already collected items.
*/
public function indexKeyAs( BackedEnum|string $value ): self {
return $this->withUpdatedProperties(
[ 'indexKey' => self::validateIndexKey( $this->toString( $value, validate: false ), ...$this->items ) ]
);
}
/**
* Gets new instance after re-computing offset between subset of items already registered as collectables.
*
* @param BackedEnum<string>|string ...$caseOrValue
* @see CollectUsing::recomputationOf()
*/
public function subsetOf( BackedEnum|string ...$caseOrValue ): self {
if ( ! $caseOrValue ) {
return $this;
}
[$items, $offsets] = $this->recomputationOf( ...$caseOrValue );
return $this->withUpdatedProperties( compact( 'items', 'offsets' ) );
}
/**
* Re-computes offset between subset of items already registered as collectables.
*
* Note that recomputation is based on previously set order of collectable items.
* The order cannot be changed here. It only computes offsets between items in
* same sequence they were registered at the time the class was instantiated.
*
* Consequently, the order in which `$subsetCaseOrValue` is passed does not matter.
*
* @param BackedEnum<string>|string ...$subsetCaseOrValue
* @return array{0:array<int,string>,1:(string|int)[]} Recomputed items and offset positions.
* @throws InvalidSource When none of given subset items were registered during instantiation.
*/
public function recomputationOf( BackedEnum|string ...$subsetCaseOrValue ): array {
if ( ! $subsetCaseOrValue ) {
return [ $this->items, $this->offsets ];
}
$values = array_map( $this->toString( ... ), $subsetCaseOrValue );
( $items = array_intersect( $this->items, $values ) ) || $this->throwRecomputationMismatch( ...$values );
$lastKey = array_key_last( $items );
$offsets = $lastKey ? array_keys( array_diff_key( range( 0, $lastKey ), $items ) ) : [];
return [ $items, $offsets ];
}
/** @return class-string<BackedEnum<string>> */
private function enum(): ?string {
return $this->enumClass ?? null;
}
/** @param array<string,mixed> $nameValuePair */
private function withProperties( array $nameValuePair ): self {
foreach ( $nameValuePair as $property => $value ) {
$this->{$property} = $value;
}
return $this;
}
/** @param array<string,mixed> $updatedValues */
private function withUpdatedProperties( array $updatedValues ): self {
return ( new ReflectionClass( $this ) )
->newInstanceWithoutConstructor()
->withProperties( [ ...get_object_vars( $this ), ...$updatedValues ] );
}
/**
* @param BackedEnum<string>|string $caseOrValue
* @throws InvalidSource When given item is string and cannot instantiate any enum case.
*/
private function toString( BackedEnum|string $caseOrValue, bool $validate = true ): string {
if ( $caseOrValue instanceof BackedEnum ) {
return $caseOrValue->value;
}
return ! $validate || ( ! $enum = $this->enum() ) || $enum::tryFrom( $caseOrValue )
? $caseOrValue
: throw InvalidSource::nonCollectableItem(
reason: "for enum \"$enum\". Cannot translate to corresponding case from given",
names: [ $caseOrValue ]
);
}
/**
* @param BackedEnum<string>|string|null ...$caseOrValueOrOffset
* @return array{0:list<?string>,1:non-empty-array<int,string>,2:list<int>}
* @throws InvalidSource When enum has no case defined or all given subset cases are `null`.
* @no-named-arguments
*/
private function computeFor( BackedEnum|string|null ...$caseOrValueOrOffset ): array {
if ( $caseOrValueOrOffset ) {
return $this->findOffsetsIn( ...$caseOrValueOrOffset );
}
$caseValues = array_column( $this->enumClass::cases(), 'value' );
return $caseValues ? [ $caseValues, $caseValues, [] ] : throw InvalidSource::nonCollectableItem(
reason: "because given enum \"{$this->enumClass}\" does not have any case to use as"
);
}
/**
* @param BackedEnum<string>|string|null ...$caseOrValueOrOffset
* @return array{0:list<?string>,1:non-empty-array<int,string>,2:list<int>}
* @throws InvalidSource When all args are null.
* @no-named-arguments
*/
private function findOffsetsIn( BackedEnum|string|null ...$caseOrValueOrOffset ): array {
$items = $offsets = $all = [];
$lastValueFound = false;
for ( $i = array_key_last( $caseOrValueOrOffset ); $i >= 0; $i-- ) {
$value = $caseOrValueOrOffset[ $i ];
if ( null === $value ) {
$all[] = null;
$lastValueFound && $offsets[] = (int) $i;
} else {
$lastValueFound = true;
$items[ (int) $i ] = $all[] = $this->toString( $value );
}
}
$items ?: throw InvalidSource::nonCollectableItem(
reason: "{$this->pre()}. All given arguments are \"null\" and none of them are valid"
);
return [ array_reverse( $all ), array_reverse( $items, preserve_keys: true ), array_reverse( $offsets ) ];
}
/** @return array<string> */
private static function mapNullToString( ?string ...$values ): array {
return array_map( static fn( ?string $v ): string => $v ??= '{{NULL}}', $values );
}
private function throwRecomputationMismatch( string ...$values ): never {
$items = implode( '", "', $this->items );
$plural = 1 === count( $this->items ) ? '' : 's';
throw InvalidSource::nonCollectableItem( "{$this->pre( 're-' )}. Allowed value{$plural}: [\"{$items}\"]. Given", $values );
}
private function pre( string $re = '' ): string {
return "during {$re}computation" . ( ( $enum = $this->enum() ) ? " with enum \"{$enum}\"" : '' );
}
private static function validateIndexKey( string $key, ?string ...$items ): string {
return in_array( $key, $items, true ) ? $key : throw InvalidSource::nonCollectableItem(
reason: "because index-key must be one of the value in items list. \"{$key}\" does not exist in list of",
names: self::mapNullToString( ...$items )
);
}
}