Skip to content

Commit 5f6fd0c

Browse files
committed
Preserve possible empty results in generic array merges
Benevolent unions can report definite iteration while still admitting an empty branch. Require an operand to exclude the empty array before marking the generic fallback nonempty. Cover explicit empty and optional-shape alternatives, required generic operands, and never-alternative normalization.
1 parent ae8d0ac commit 5f6fd0c

3 files changed

Lines changed: 64 additions & 2 deletions

File tree

src/ArrayMergeType.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ protected function getResult(): Type
306306
}
307307

308308
if ($nConstantArrays + $nOtherArrays === count($types)) {
309+
$emptyArray = ConstantArrayTypeBuilder::createEmpty()->getArray();
309310
$allIntegerKeys = true;
310311
$atLeastOneNonEmpty = false;
311312
$combinedKeyType = null;
@@ -339,7 +340,7 @@ protected function getResult(): Type
339340
return new ArrayType(new MixedType(true), new MixedType(true));
340341
}
341342

342-
if ($type->isIterableAtLeastOnce()->yes()) {
343+
if ($type->isSuperTypeOf($emptyArray)->no()) {
343344
$atLeastOneNonEmpty = true;
344345
}
345346

tests/ArrayMergeNonEmptyTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ public static function nonEmptyProvider(): iterable
6363
yield 'nonempty intersection survives integer-key reindexing' => [
6464
'array-merge<array{2?: 1, 7?: 2}&non-empty-array>',
6565
];
66+
yield 'nonempty generic operand before a possibly empty operand' => [
67+
'array-merge<non-empty-array<string, int>, array<string, int>>',
68+
];
69+
yield 'nonempty generic operand after a possibly empty operand' => [
70+
'array-merge<array<string, int>, non-empty-array<string, int>>',
71+
];
6672
}
6773

6874
#[DataProvider('nonEmptyProvider')]
@@ -92,6 +98,9 @@ public static function possiblyEmptyProvider(): iterable
9298
yield 'possible empty survivor beside an impossible branch' => [
9399
'array-merge<array{}|array{bad: never}|array{a: 1}>',
94100
];
101+
yield 'serialized benevolent union with conflicting key orders' => [
102+
'array-merge<__array_merge_benevolent<array{}|array{a: 1, b: 2}|array{b: 2, a: 1}>>',
103+
];
95104
}
96105

97106
#[DataProvider('possiblyEmptyProvider')]
@@ -201,6 +210,57 @@ public function testBenevolentUnionRetainsReachableOptionalShapeAlternative(): v
201210
$this->assertTrue($result->isIterableAtLeastOnce()->maybe(), $description);
202211
}
203212

213+
public function testBenevolentUnionRetainsEmptyThroughKeyOrderFallback(): void
214+
{
215+
$empty = self::constantArrayFromRuntime([]);
216+
$firstOrder = self::constantArrayFromRuntime(['a' => 1, 'b' => 2]);
217+
$secondOrder = self::constantArrayFromRuntime(['b' => 2, 'a' => 1]);
218+
$operand = new BenevolentUnionType([$empty, $firstOrder, $secondOrder]);
219+
$result = (new ArrayMergeType([$operand]))->resolve();
220+
$description = $result->describe(VerbosityLevel::precise());
221+
222+
foreach ([$result, $result->getKeysArray(), $result->getValuesArray()] as $type) {
223+
$this->assertTrue($type->isSuperTypeOf($empty)->yes(), $description);
224+
$this->assertTrue($type->isIterableAtLeastOnce()->maybe(), $description);
225+
}
226+
227+
$this->assertTrue($result->isSuperTypeOf($firstOrder)->yes(), $description);
228+
$this->assertTrue($result->isSuperTypeOf($secondOrder)->yes(), $description);
229+
}
230+
231+
public function testBenevolentUnionRetainsOptionalShapeThroughKeyOrderFallback(): void
232+
{
233+
$empty = self::constantArrayFromRuntime([]);
234+
$operand = new BenevolentUnionType([
235+
self::optionalStringShape(),
236+
self::constantArrayFromRuntime(['a' => 1, 'b' => 2]),
237+
self::constantArrayFromRuntime(['b' => 2, 'a' => 1]),
238+
]);
239+
$result = (new ArrayMergeType([$operand]))->resolve();
240+
$description = $result->describe(VerbosityLevel::precise());
241+
242+
foreach ([$result, $result->getKeysArray(), $result->getValuesArray()] as $type) {
243+
$this->assertTrue($type->isSuperTypeOf($empty)->yes(), $description);
244+
$this->assertTrue($type->isIterableAtLeastOnce()->maybe(), $description);
245+
}
246+
}
247+
248+
public function testBenevolentUnionOfNonEmptyGenericArraysRemainsNonEmpty(): void
249+
{
250+
$resolver = self::getContainer()->getByType(TypeStringResolver::class);
251+
$operand = new BenevolentUnionType([
252+
$resolver->resolve('non-empty-array<string, int>'),
253+
$resolver->resolve('non-empty-array<int, int>'),
254+
]);
255+
$result = (new ArrayMergeType([$operand]))->resolve();
256+
$description = $result->describe(VerbosityLevel::precise());
257+
258+
$this->assertTrue($result->isIterableAtLeastOnce()->yes(), $description);
259+
$this->assertTrue($result->isSuperTypeOf(self::constantArrayFromRuntime([]))->no(), $description);
260+
$this->assertTrue($result->isSuperTypeOf(self::constantArrayFromRuntime(['a' => 1]))->yes(), $description);
261+
$this->assertTrue($result->isSuperTypeOf(self::constantArrayFromRuntime([2]))->yes(), $description);
262+
}
263+
204264
public function testSerializedBenevolentUnionRetainsReachableEmptyAlternative(): void
205265
{
206266
$merge = self::getContainer()->getByType(TypeStringResolver::class)->resolve(

tests/ArrayMergeTypeTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,8 @@ public function testResolvePreservesBenevolentUnionWhenRemovingNeverAlternative(
186186
new BenevolentUnionType([$possiblyEmpty, $nonEmpty, new NeverType()]),
187187
]))->resolve();
188188

189-
$this->assertTrue($withoutNever->isIterableAtLeastOnce()->yes());
189+
$this->assertTrue($withoutNever->isIterableAtLeastOnce()->maybe());
190+
$this->assertTrue($withoutNever->isSuperTypeOf(ConstantArrayTypeBuilder::createEmpty()->getArray())->yes());
190191
$this->assertTrue($withoutNever->equals($withNever));
191192
}
192193

0 commit comments

Comments
 (0)