Skip to content

Commit 29ee488

Browse files
committed
Expand ArrayOfStrings to enforce non-empty lists, refine type handling, update tryFromArray behavior, and add comprehensive tests.
1 parent 94da325 commit 29ee488

File tree

7 files changed

+151
-28
lines changed

7 files changed

+151
-28
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ $profile->getHeight()->value(); // throws an exception on access the Undefined v
137137
### Documentation
138138

139139
- Development guide: `docs/DEVELOP.md`
140-
- Usage examples in `src/Usage`
140+
- Usage examples in `src/Usage` and `tests/Unit`
141141

142142
### License
143143

src/Abstract/Array/ArrayType.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
*
1515
* @psalm-internal PhpTypedValues
1616
*
17+
* @template TItem
18+
*
19+
* @implements IteratorAggregate<int, TItem>
20+
*
21+
* @template-implements ArrayTypeInterface<TItem>
22+
*
1723
* @psalm-immutable
1824
*/
1925
abstract readonly class ArrayType implements ArrayTypeInterface, IteratorAggregate, JsonSerializable

src/Abstract/Array/ArrayTypeInterface.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,24 @@
99
/**
1010
* Contract for array typed values.
1111
*
12+
* @template TItem
13+
*
1214
* @psalm-immutable
1315
*/
1416
interface ArrayTypeInterface
1517
{
18+
/**
19+
* @psalm-return list<TItem>
20+
*/
1621
public function value(): array;
1722

23+
/**
24+
* @psalm-param list<mixed> $value
25+
*/
1826
public static function fromArray(array $value): static;
1927

28+
/**
29+
* @psalm-param list<mixed> $value
30+
*/
2031
public static function tryFromArray(array $value): static|Undefined;
2132
}

src/Usage/Composite/Composite.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@
3535

3636
$test = ArrayOfStrings::tryFromArray(['string', 'not-empty', '1', 2, true]);
3737
echo json_encode($test, JSON_THROW_ON_ERROR) . PHP_EOL;
38+
echo json_encode($test->value(), JSON_THROW_ON_ERROR) . PHP_EOL;

src/Usage/Example/ArrayOfStrings.php

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,55 +10,74 @@
1010
use PhpTypedValues\Abstract\Array\ArrayType;
1111
use PhpTypedValues\Exception\StringTypeException;
1212
use PhpTypedValues\Exception\TypeException;
13-
use PhpTypedValues\Exception\UndefinedTypeException;
1413
use PhpTypedValues\String\StringNonEmpty;
15-
use PhpTypedValues\Undefined\Alias\Undefined;
1614

1715
/**
1816
* @internal
1917
*
2018
* @psalm-internal PhpTypedValues
2119
*
20+
* @extends ArrayType<StringNonEmpty>
21+
*
2222
* @psalm-immutable
2323
*/
2424
final readonly class ArrayOfStrings extends ArrayType
2525
{
2626
/**
27-
* @param StringNonEmpty|Undefined[] $value
27+
* @param non-empty-list<StringNonEmpty> $value
2828
*
2929
* @throws StringTypeException
3030
* @throws TypeException
3131
*/
3232
public function __construct(
33+
/** @var non-empty-list<StringNonEmpty> */
3334
private array $value,
3435
) {
3536
if ($value === []) {
3637
throw new TypeException('Expected non-empty array');
3738
}
3839

3940
foreach ($value as $item) {
40-
if ((!$item instanceof StringNonEmpty) && (!$item instanceof Undefined)) {
41+
if (!$item instanceof StringNonEmpty) {
4142
throw new StringTypeException('Expected array of StringNonEmpty or Undefined instance');
4243
}
4344
}
4445
}
4546

4647
/**
48+
* @psalm-param list<mixed> $value
49+
*
4750
* @throws StringTypeException
4851
* @throws TypeException
4952
*/
5053
public static function fromArray(array $value): static
5154
{
55+
if ($value === []) {
56+
throw new TypeException('Expected non-empty array');
57+
}
58+
5259
$typed = [];
5360
foreach ($value as $item) {
54-
$typed[] = StringNonEmpty::fromString($item);
61+
$typed[] = StringNonEmpty::fromString((string) $item);
5562
}
5663

64+
/** @var non-empty-list<StringNonEmpty> $typed */
5765
return new self($typed);
5866
}
5967

6068
/**
61-
* @return StringNonEmpty|Undefined[]
69+
* @psalm-param list<mixed> $value
70+
*
71+
* @throws StringTypeException
72+
* @throws TypeException
73+
*/
74+
public static function tryFromArray(array $value): static
75+
{
76+
return static::fromArray($value);
77+
}
78+
79+
/**
80+
* @psalm-return non-empty-list<StringNonEmpty>
6281
*/
6382
public function value(): array
6483
{
@@ -67,47 +86,33 @@ public function value(): array
6786

6887
/**
6988
* @return non-empty-list<non-empty-string>
70-
*
71-
* @throws UndefinedTypeException
7289
*/
7390
public function toArray(): array
7491
{
7592
$result = [];
7693
foreach ($this->value as $item) {
77-
/** @var non-empty-string $value */
78-
$value = $item->toString();
79-
$result[] = $value;
94+
/** @var non-empty-string $str */
95+
$str = $item->toString();
96+
$result[] = $str;
8097
}
8198

8299
/** @var non-empty-list<non-empty-string> $result */
83100
return $result;
84101
}
85102

86-
/** @return non-empty-list<non-empty-string> */
103+
/**
104+
* @return non-empty-list<non-empty-string>
105+
*/
87106
public function jsonSerialize(): array
88107
{
89108
return $this->toArray();
90109
}
91110

92111
/**
93-
* @return Generator<StringNonEmpty>
112+
* @return Generator<int, StringNonEmpty>
94113
*/
95114
public function getIterator(): Generator
96115
{
97116
yield from $this->value;
98117
}
99-
100-
/**
101-
* @throws StringTypeException
102-
* @throws TypeException
103-
*/
104-
public static function tryFromArray(array $value): static|Undefined
105-
{
106-
$typed = [];
107-
foreach ($value as $item) {
108-
$typed[] = StringNonEmpty::tryFromMixed($item);
109-
}
110-
111-
return new static($typed);
112-
}
113118
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PhpTypedValues\Exception\StringTypeException;
6+
use PhpTypedValues\Exception\TypeException;
7+
use PhpTypedValues\String\StringNonEmpty;
8+
use PhpTypedValues\Usage\Example\ArrayOfStrings;
9+
10+
it('creates from non-empty list of strings and exposes typed values', function (): void {
11+
$a = ArrayOfStrings::fromArray(['foo', 'bar']);
12+
13+
// value() returns non-empty-list<StringNonEmpty>
14+
$vals = $a->value();
15+
expect($vals)->toBeArray()->and($vals)->toHaveCount(2);
16+
foreach ($vals as $v) {
17+
expect($v)->toBeInstanceOf(StringNonEmpty::class);
18+
}
19+
20+
// toArray/jsonSerialize return non-empty-list<non-empty-string>
21+
expect($a->toArray())->toBe(['foo', 'bar']);
22+
expect($a->jsonSerialize())->toBe(['foo', 'bar']);
23+
24+
// IteratorAggregate yields the same items
25+
$iterated = [];
26+
foreach ($a as $item) {
27+
$iterated[] = $item->toString();
28+
}
29+
expect($iterated)->toBe(['foo', 'bar']);
30+
});
31+
32+
it('fails on empty array input (fromArray)', function (): void {
33+
expect(fn() => ArrayOfStrings::fromArray([]))
34+
->toThrow(TypeException::class, 'Expected non-empty array');
35+
});
36+
37+
it('fails on invalid item (empty string) (fromArray)', function (): void {
38+
expect(fn() => ArrayOfStrings::fromArray(['']))
39+
->toThrow(StringTypeException::class, 'Expected non-empty string, got ""');
40+
});
41+
42+
it('tryFromArray mirrors fromArray success', function (): void {
43+
$a = ArrayOfStrings::tryFromArray(['one']);
44+
expect($a)->toBeInstanceOf(ArrayOfStrings::class);
45+
expect($a->toArray())->toBe(['one']);
46+
});
47+
48+
it('tryFromArray fails on empty array just like fromArray (current behavior)', function (): void {
49+
// current implementation proxies to fromArray and throws TypeException
50+
expect(fn() => ArrayOfStrings::tryFromArray([]))
51+
->toThrow(TypeException::class, 'Expected non-empty array');
52+
});
53+
54+
it('can be constructed directly with non-empty-list<StringNonEmpty>', function (): void {
55+
$a = new ArrayOfStrings([new StringNonEmpty('x'), new StringNonEmpty('y')]);
56+
57+
// verify end-to-end
58+
expect($a->toArray())->toBe(['x', 'y']);
59+
$collected = [];
60+
foreach ($a as $item) {
61+
$collected[] = $item->toString();
62+
}
63+
expect($collected)->toBe(['x', 'y']);
64+
});
65+
66+
it('constructor fails on empty array', function (): void {
67+
expect(fn() => new ArrayOfStrings([]))
68+
->toThrow(TypeException::class, 'Expected non-empty array');
69+
});
70+
71+
it('constructor fails when element is not StringNonEmpty', function (): void {
72+
// mix valid object with invalid scalar to hit the element-type guard
73+
/** @var array $bad */
74+
$bad = [new StringNonEmpty('ok'), 'oops'];
75+
expect(fn() => new ArrayOfStrings($bad))
76+
->toThrow(StringTypeException::class, 'Expected array of StringNonEmpty or Undefined instance');
77+
});

tests/Unit/Usage/Example/OptionalFailTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PhpTypedValues\Exception\FloatTypeException;
66
use PhpTypedValues\Exception\IntegerTypeException;
7+
use PhpTypedValues\Exception\UndefinedTypeException;
78
use PhpTypedValues\Float\FloatPositive;
89
use PhpTypedValues\Undefined\Alias\Undefined;
910
use PhpTypedValues\Usage\Example\OptionalFail;
@@ -64,3 +65,25 @@
6465
expect(fn() => OptionalFail::fromScalars(id: 1, firstName: 'Name', height: 'abc'))
6566
->toThrow(FloatTypeException::class, 'String "abc" has no valid float value');
6667
});
68+
69+
it('jsonSerialize returns associative array of strings when all values are present', function (): void {
70+
$vo = OptionalFail::fromScalars(id: 1, firstName: 'Foo', height: 170);
71+
expect($vo->jsonSerialize())
72+
->toBe([
73+
'id' => '1',
74+
'firstName' => 'Foo',
75+
'height' => '170',
76+
]);
77+
});
78+
79+
it('jsonSerialize fails when firstName is Undefined (late fail)', function (): void {
80+
$vo = OptionalFail::fromScalars(id: 1, firstName: '', height: 10.0);
81+
expect(fn() => $vo->jsonSerialize())
82+
->toThrow(UndefinedTypeException::class, 'UndefinedType cannot be converted to string.');
83+
});
84+
85+
it('jsonSerialize fails when height is Undefined (late fail)', function (): void {
86+
$vo = OptionalFail::fromScalars(id: 1, firstName: 'Name', height: null);
87+
expect(fn() => $vo->jsonSerialize())
88+
->toThrow(UndefinedTypeException::class, 'UndefinedType cannot be converted to string.');
89+
});

0 commit comments

Comments
 (0)