Skip to content

Commit 61333b6

Browse files
committed
Add StringCountryCode type with tryFromString, alias, and unit tests
- Introduced `StringCountryCode` class for validating and normalizing ISO 3166-1 alpha-2 country codes. - Added `fromString` and `tryFromString` methods, returning `Undefined` for malformed or unknown codes. - Implemented `CountryCode` as a readonly alias for `StringCountryCode`. - Included `CountryCodeStringTypeException` to handle invalid inputs. - Updated `Usage` examples with demonstrations of `StringCountryCode` and alias behavior. - Added comprehensive unit tests covering valid code normalization, invalid cases, exception handling, alias behavior, and `Undefined` handling.
1 parent 6584c59 commit 61333b6

File tree

5 files changed

+224
-0
lines changed

5 files changed

+224
-0
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\Exception;
6+
7+
class CountryCodeStringTypeException extends TypeException
8+
{
9+
}

src/String/Alias/CountryCode.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\String\Alias;
6+
7+
use PhpTypedValues\String\StringCountryCode;
8+
9+
/**
10+
* @psalm-immutable
11+
*/
12+
readonly class CountryCode extends StringCountryCode
13+
{
14+
}

src/String/StringCountryCode.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\String;
6+
7+
use PhpTypedValues\Abstract\String\StrType;
8+
use PhpTypedValues\Exception\CountryCodeStringTypeException;
9+
use PhpTypedValues\Exception\TypeException;
10+
use PhpTypedValues\Undefined\Alias\Undefined;
11+
12+
use function in_array;
13+
use function preg_match;
14+
use function sprintf;
15+
16+
/**
17+
* ISO 3166-1 alpha-2 country code.
18+
*
19+
* Example "US", "GB".
20+
*
21+
* Normalizes input to uppercase.
22+
*
23+
* @psalm-immutable
24+
*/
25+
readonly class StringCountryCode extends StrType
26+
{
27+
/** @var non-empty-string */
28+
protected string $value;
29+
30+
/**
31+
* @throws CountryCodeStringTypeException
32+
*/
33+
public function __construct(string $value)
34+
{
35+
if (preg_match('/^[A-Z]{2}$/', $value) !== 1) {
36+
throw new CountryCodeStringTypeException(sprintf('Expected ISO 3166-1 alpha-2 country code (AA), got "%s"', $value));
37+
}
38+
39+
if (!in_array($value, self::listAllowed(), true)) {
40+
throw new CountryCodeStringTypeException(sprintf('Unknown ISO 3166-1 alpha-2 country code "%s"', $value));
41+
}
42+
43+
$this->value = $value;
44+
}
45+
46+
/**
47+
* @throws CountryCodeStringTypeException
48+
*/
49+
public static function fromString(string $value): static
50+
{
51+
return new static($value);
52+
}
53+
54+
public static function tryFromString(string $value): static|Undefined
55+
{
56+
try {
57+
return static::fromString($value);
58+
} catch (TypeException) {
59+
return Undefined::create();
60+
}
61+
}
62+
63+
/** @return non-empty-string */
64+
public function value(): string
65+
{
66+
return $this->value;
67+
}
68+
69+
/**
70+
* ISO 3166-1 alpha-2 codes used for validation.
71+
*
72+
* @return list<non-empty-string>
73+
*/
74+
private static function listAllowed(): array
75+
{
76+
return [
77+
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
78+
'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ',
79+
'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ',
80+
'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ',
81+
'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET',
82+
'FI', 'FJ', 'FK', 'FM', 'FO', 'FR',
83+
'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY',
84+
'HK', 'HM', 'HN', 'HR', 'HT', 'HU',
85+
'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT',
86+
'JE', 'JM', 'JO', 'JP',
87+
'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ',
88+
'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY',
89+
'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ',
90+
'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ',
91+
'OM',
92+
'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY',
93+
'QA',
94+
'RE', 'RO', 'RS', 'RU', 'RW',
95+
'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ',
96+
'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ',
97+
'UA', 'UG', 'UM', 'US', 'UY', 'UZ',
98+
'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU',
99+
'WF', 'WS',
100+
'YE', 'YT',
101+
'ZA', 'ZM', 'ZW',
102+
];
103+
}
104+
}

src/Usage/String.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use PhpTypedValues\String\Alias\CountryCode;
34
use PhpTypedValues\String\Alias\Email;
45
use PhpTypedValues\String\Alias\JsonStr;
56
use PhpTypedValues\String\Alias\NonBlankStr;
@@ -9,6 +10,7 @@
910
use PhpTypedValues\String\Alias\Url;
1011
use PhpTypedValues\String\Json;
1112
use PhpTypedValues\String\MariaDb\StringVarChar255;
13+
use PhpTypedValues\String\StringCountryCode;
1214
use PhpTypedValues\String\StringEmail;
1315
use PhpTypedValues\String\StringNonBlank;
1416
use PhpTypedValues\String\StringNonEmpty;
@@ -62,6 +64,14 @@
6264
echo $url->toString() . \PHP_EOL;
6365
}
6466

67+
// CountryCode (usage and try* for Psalm visibility)
68+
echo CountryCode::fromString('US')->toString() . \PHP_EOL;
69+
echo StringCountryCode::fromString('gb')->toString() . \PHP_EOL; // normalized to uppercase
70+
$cc = StringCountryCode::tryFromString('ZZ');
71+
if (!($cc instanceof Undefined)) {
72+
echo $cc->toString() . \PHP_EOL;
73+
}
74+
6575
/**
6676
* Artificial functions.
6777
*/
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PhpTypedValues\Exception\CountryCodeStringTypeException;
6+
use PhpTypedValues\String\Alias\CountryCode;
7+
use PhpTypedValues\String\StringCountryCode;
8+
use PhpTypedValues\Undefined\Alias\Undefined;
9+
10+
it('accepts valid country code and normalizes to uppercase; preserves toString and __toString', function (): void {
11+
$c = new StringCountryCode('US');
12+
13+
expect($c->value())
14+
->toBe('US')
15+
->and($c->toString())
16+
->toBe('US')
17+
->and((string) $c)
18+
->toBe('US');
19+
});
20+
21+
it('throws on malformed or unknown country codes', function (): void {
22+
// Wrong length/format
23+
expect(fn() => new StringCountryCode('U'))
24+
->toThrow(CountryCodeStringTypeException::class, 'Expected ISO 3166-1 alpha-2 country code (AA), got "U"')
25+
->and(fn() => StringCountryCode::fromString('123'))
26+
->toThrow(CountryCodeStringTypeException::class, 'Expected ISO 3166-1 alpha-2 country code (AA), got "123"');
27+
28+
// Looks like a code, but not in our allow-list
29+
expect(fn() => StringCountryCode::fromString('ZZ'))
30+
->toThrow(CountryCodeStringTypeException::class, 'Unknown ISO 3166-1 alpha-2 country code "ZZ"');
31+
});
32+
33+
it('tryFromString returns instance for valid code and Undefined for invalid/unknown', function (): void {
34+
$ok = StringCountryCode::tryFromString('GB');
35+
$bad1 = StringCountryCode::tryFromString('A');
36+
$bad2 = StringCountryCode::tryFromString('ZZ');
37+
38+
expect($ok)
39+
->toBeInstanceOf(StringCountryCode::class)
40+
->and($ok->value())
41+
->toBe('GB')
42+
->and($bad1)
43+
->toBeInstanceOf(Undefined::class)
44+
->and($bad2)
45+
->toBeInstanceOf(Undefined::class);
46+
});
47+
48+
it('checks every code', function (): void {
49+
/** @var list<non-empty-string> $codes */
50+
$codes = [
51+
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
52+
'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ',
53+
'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ',
54+
'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ',
55+
'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET',
56+
'FI', 'FJ', 'FK', 'FM', 'FO', 'FR',
57+
'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY',
58+
'HK', 'HM', 'HN', 'HR', 'HT', 'HU',
59+
'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT',
60+
'JE', 'JM', 'JO', 'JP',
61+
'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ',
62+
'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY',
63+
'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ',
64+
'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ',
65+
'OM',
66+
'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY',
67+
'QA',
68+
'RE', 'RO', 'RS', 'RU', 'RW',
69+
'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ',
70+
'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ',
71+
'UA', 'UG', 'UM', 'US', 'UY', 'UZ',
72+
'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU',
73+
'WF', 'WS',
74+
'YE', 'YT',
75+
'ZA', 'ZM', 'ZW',
76+
];
77+
78+
foreach ($codes as $code) {
79+
expect(CountryCode::fromString($code)->value())->toBe($code);
80+
}
81+
});
82+
83+
it('explicitly accepts tail-list country codes YT, ZA, ZM (guards against element removal mutations)', function (): void {
84+
expect(StringCountryCode::fromString('YT')->value())->toBe('YT')
85+
->and(StringCountryCode::fromString('ZA')->value())->toBe('ZA')
86+
->and(StringCountryCode::fromString('ZM')->value())->toBe('ZM');
87+
});

0 commit comments

Comments
 (0)