Containerize sokil databases

The main focus of this change is to make those optional dependencies
more testable.

Unfortunately, some phpstan-ignores had to be included, since ::set
is not a PsrContainer method. We're only using it on tests though,
so it's fine. It targets our php-di container for testing purposes
only. The real implementation only relies on ::get.

This change also has the side effect of improving the performance
of those validators by not instantiating their databases each time
a iso validator is built, achieving massive improvements in those
scenarios. A small benchmark with no assertions was added to track
that improvement.
This commit is contained in:
Alexandre Gomes Gaigalas 2026-01-28 18:08:19 -03:00
commit d8e31dbc3a
11 changed files with 219 additions and 38 deletions

View file

@ -18,6 +18,8 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use Attribute;
use Psr\Container\NotFoundExceptionInterface;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Message\Template;
@ -25,7 +27,6 @@ use Respect\Validation\Result;
use Respect\Validation\Validator;
use Sokil\IsoCodes\Database\Countries;
use function class_exists;
use function in_array;
use function is_string;
@ -43,14 +44,6 @@ final readonly class CountryCode implements Validator
private string $set = 'alpha-2',
Countries|null $countries = null,
) {
if (!class_exists(Countries::class)) {
throw new MissingComposerDependencyException(
'SubdivisionCode rule requires PHP ISO Codes',
'sokil/php-isocodes',
'sokil/php-isocodes-db-only',
);
}
$availableOptions = ['alpha-2', 'alpha-3', 'numeric'];
if (!in_array($set, $availableOptions, true)) {
throw new InvalidValidatorException(
@ -60,7 +53,15 @@ final readonly class CountryCode implements Validator
);
}
$this->countries = $countries ?? new Countries();
try {
$this->countries = $countries ?? ContainerRegistry::getContainer()->get(Countries::class);
} catch (NotFoundExceptionInterface) {
throw new MissingComposerDependencyException(
'CountryCode rule requires PHP ISO Codes',
'sokil/php-isocodes',
'sokil/php-isocodes-db-only',
);
}
}
public function evaluate(mixed $input): Result

View file

@ -15,6 +15,8 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use Attribute;
use Psr\Container\NotFoundExceptionInterface;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Message\Template;
@ -22,7 +24,6 @@ use Respect\Validation\Result;
use Respect\Validation\Validator;
use Sokil\IsoCodes\Database\Currencies;
use function class_exists;
use function in_array;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
@ -39,14 +40,6 @@ final readonly class CurrencyCode implements Validator
private string $set = 'alpha-3',
Currencies|null $currencies = null,
) {
if (!class_exists(Currencies::class)) {
throw new MissingComposerDependencyException(
'CurrencyCode rule requires PHP ISO Codes',
'sokil/php-isocodes',
'sokil/php-isocodes-db-only',
);
}
$availableSets = ['alpha-3', 'numeric'];
if (!in_array($set, $availableSets, true)) {
throw new InvalidValidatorException(
@ -56,7 +49,15 @@ final readonly class CurrencyCode implements Validator
);
}
$this->currencies = $currencies ?? new Currencies();
try {
$this->currencies = $currencies ?? ContainerRegistry::getContainer()->get(Currencies::class);
} catch (NotFoundExceptionInterface) {
throw new MissingComposerDependencyException(
'CurrencyCode rule requires PHP ISO Codes',
'sokil/php-isocodes',
'sokil/php-isocodes-db-only',
);
}
}
public function evaluate(mixed $input): Result

View file

@ -15,15 +15,15 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use Attribute;
use Psr\Container\NotFoundExceptionInterface;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validator;
use Sokil\IsoCodes\Database\Countries;
use Sokil\IsoCodes\Database\Languages;
use function class_exists;
use function in_array;
use function is_string;
@ -41,14 +41,6 @@ final readonly class LanguageCode implements Validator
private readonly string $set = 'alpha-2',
Languages|null $languages = null,
) {
if (!class_exists(Countries::class)) {
throw new MissingComposerDependencyException(
'LanguageCode rule requires PHP ISO Codes',
'sokil/php-isocodes',
'sokil/php-isocodes-db-only',
);
}
$availableSets = ['alpha-2', 'alpha-3'];
if (!in_array($set, $availableSets, true)) {
throw new InvalidValidatorException(
@ -58,7 +50,15 @@ final readonly class LanguageCode implements Validator
);
}
$this->languages = $languages ?? new Languages();
try {
$this->languages = $languages ?? ContainerRegistry::getContainer()->get(Languages::class);
} catch (NotFoundExceptionInterface) {
throw new MissingComposerDependencyException(
'LanguageCode rule requires PHP ISO Codes',
'sokil/php-isocodes',
'sokil/php-isocodes-db-only',
);
}
}
public function evaluate(mixed $input): Result

View file

@ -21,6 +21,8 @@ namespace Respect\Validation\Validators;
use Attribute;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberUtil;
use Psr\Container\NotFoundExceptionInterface;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Message\Template;
@ -64,7 +66,9 @@ final class Phone implements Validator
return;
}
if (!class_exists(Countries::class)) {
try {
$countries ??= ContainerRegistry::getContainer()->get(Countries::class);
} catch (NotFoundExceptionInterface) {
throw new MissingComposerDependencyException(
'Phone rule with country code requires PHP ISO Codes',
'sokil/php-isocodes',
@ -72,7 +76,6 @@ final class Phone implements Validator
);
}
$countries ??= new Countries();
$this->country = $countries->getByAlpha2($countryCode);
if ($this->country === null) {
throw new InvalidValidatorException('Invalid country code %s', $countryCode);

View file

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use Attribute;
use Psr\Container\NotFoundExceptionInterface;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Helpers\CanValidateUndefined;
@ -21,8 +23,6 @@ use Respect\Validation\Validator;
use Sokil\IsoCodes\Database\Countries;
use Sokil\IsoCodes\Database\Subdivisions;
use function class_exists;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Template(
'{{subject}} must be a subdivision code of {{countryName|trans}}',
@ -41,7 +41,11 @@ final readonly class SubdivisionCode implements Validator
Countries|null $countries = null,
Subdivisions|null $subdivisions = null,
) {
if (!class_exists(Countries::class) || !class_exists(Subdivisions::class)) {
try {
$container = ContainerRegistry::getContainer();
$countries ??= $container->get(Countries::class);
$this->subdivisions = $subdivisions ?? $container->get(Subdivisions::class);
} catch (NotFoundExceptionInterface) {
throw new MissingComposerDependencyException(
'SubdivisionCode rule requires PHP ISO Codes',
'sokil/php-isocodes',
@ -49,14 +53,12 @@ final readonly class SubdivisionCode implements Validator
);
}
$countries ??= new Countries();
$country = $countries->getByAlpha2($countryCode);
if ($country === null) {
throw new InvalidValidatorException('"%s" is not a supported country code', $countryCode);
}
$this->country = $country;
$this->subdivisions = $subdivisions ?? new Subdivisions();
}
public function evaluate(mixed $input): Result

View file

@ -0,0 +1,69 @@
<?php
/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
*/
declare(strict_types=1);
namespace Respect\Validation\Benchmarks;
use PhpBench\Attributes as Bench;
use Respect\Validation\Test\SmokeTestProvider;
use Respect\Validation\ValidatorBuilder;
class IsoCodesBench
{
use SmokeTestProvider;
#[Bench\Iterations(10)]
#[Bench\RetryThreshold(5)]
#[Bench\Revs(5)]
#[Bench\Warmup(1)]
#[Bench\Subject]
public function subdivisionCode(): void
{
ValidatorBuilder::subdivisionCode('US')->evaluate('CA');
}
#[Bench\Iterations(10)]
#[Bench\RetryThreshold(5)]
#[Bench\Revs(5)]
#[Bench\Warmup(1)]
#[Bench\Subject]
public function countryCode(): void
{
ValidatorBuilder::countryCode()->evaluate('US');
}
#[Bench\Iterations(10)]
#[Bench\RetryThreshold(5)]
#[Bench\Revs(5)]
#[Bench\Warmup(1)]
#[Bench\Subject]
public function currencyCode(): void
{
ValidatorBuilder::currencyCode()->evaluate('USD');
}
#[Bench\Iterations(10)]
#[Bench\RetryThreshold(5)]
#[Bench\Revs(5)]
#[Bench\Warmup(1)]
#[Bench\Subject]
public function languageCode(): void
{
ValidatorBuilder::languageCode()->evaluate('en');
}
#[Bench\Iterations(10)]
#[Bench\RetryThreshold(5)]
#[Bench\Revs(5)]
#[Bench\Warmup(1)]
#[Bench\Subject]
public function phone(): void
{
ValidatorBuilder::phone('US')->evaluate('+1 202-555-0125');
}
}

View file

@ -14,10 +14,13 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use DI;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Test\RuleTestCase;
#[Group('validator')]
@ -36,6 +39,24 @@ final class CountryCodeTest extends RuleTestCase
new CountryCode('whatever');
}
#[Test]
public function shouldThrowWhenMissingComponent(): void
{
$mainContainer = ContainerRegistry::getContainer();
ContainerRegistry::setContainer((new DI\ContainerBuilder())->useAutowiring(false)->build());
try {
new CountryCode('alpha-3');
$this->fail('Expected MissingComposerDependencyException was not thrown.');
} catch (MissingComposerDependencyException $e) {
$this->assertStringContainsString(
'CountryCode rule requires PHP ISO Codes',
$e->getMessage(),
);
} finally {
ContainerRegistry::setContainer($mainContainer);
}
}
/** @return iterable<array{CountryCode, mixed}> */
public static function providerForValidInput(): iterable
{

View file

@ -14,10 +14,13 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use DI;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Test\RuleTestCase;
#[Group('validator')]
@ -36,6 +39,24 @@ final class CurrencyCodeTest extends RuleTestCase
new CurrencyCode('whatever');
}
#[Test]
public function shouldThrowWhenMissingComponent(): void
{
$mainContainer = ContainerRegistry::getContainer();
ContainerRegistry::setContainer((new DI\ContainerBuilder())->useAutowiring(false)->build());
try {
new CurrencyCode('alpha-3');
$this->fail('Expected MissingComposerDependencyException was not thrown.');
} catch (MissingComposerDependencyException $e) {
$this->assertStringContainsString(
'CurrencyCode rule requires PHP ISO Codes',
$e->getMessage(),
);
} finally {
ContainerRegistry::setContainer($mainContainer);
}
}
/** @return iterable<array{CurrencyCode, mixed}> */
public static function providerForValidInput(): iterable
{

View file

@ -13,10 +13,13 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use DI;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Test\RuleTestCase;
#[Group('validator')]
@ -35,6 +38,24 @@ final class LanguageCodeTest extends RuleTestCase
new LanguageCode('whatever');
}
#[Test]
public function shouldThrowWhenMissingComponent(): void
{
$mainContainer = ContainerRegistry::getContainer();
ContainerRegistry::setContainer((new DI\ContainerBuilder())->useAutowiring(false)->build());
try {
new LanguageCode('alpha-3');
$this->fail('Expected MissingComposerDependencyException was not thrown.');
} catch (MissingComposerDependencyException $e) {
$this->assertStringContainsString(
'LanguageCode rule requires PHP ISO Codes',
$e->getMessage(),
);
} finally {
ContainerRegistry::setContainer($mainContainer);
}
}
/** @return iterable<array{LanguageCode, mixed}> */
public static function providerForValidInput(): iterable
{

View file

@ -17,11 +17,14 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use DI;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Test\TestCase;
use stdClass;
@ -66,6 +69,24 @@ final class PhoneTest extends TestCase
new Phone('BRR');
}
#[Test]
public function shouldThrowWhenMissingComponent(): void
{
$mainContainer = ContainerRegistry::getContainer();
ContainerRegistry::setContainer((new DI\ContainerBuilder())->useAutowiring(false)->build());
try {
new Phone('US');
$this->fail('Expected MissingComposerDependencyException was not thrown.');
} catch (MissingComposerDependencyException $e) {
$this->assertStringContainsString(
'Phone rule with country code requires PHP ISO Codes',
$e->getMessage(),
);
} finally {
ContainerRegistry::setContainer($mainContainer);
}
}
/** @return array<array{mixed}> */
public static function providerForValidInputWithoutCountryCode(): array
{

View file

@ -12,10 +12,13 @@ declare(strict_types=1);
namespace Respect\Validation\Validators;
use DI;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\ContainerRegistry;
use Respect\Validation\Exceptions\InvalidValidatorException;
use Respect\Validation\Exceptions\MissingComposerDependencyException;
use Respect\Validation\Test\RuleTestCase;
#[Group('validator')]
@ -31,6 +34,24 @@ final class SubdivisionCodeTest extends RuleTestCase
new SubdivisionCode('whatever');
}
#[Test]
public function shouldThrowWhenMissingComponent(): void
{
$mainContainer = ContainerRegistry::getContainer();
ContainerRegistry::setContainer((new DI\ContainerBuilder())->useAutowiring(false)->build());
try {
new SubdivisionCode('US');
$this->fail('Expected MissingComposerDependencyException was not thrown.');
} catch (MissingComposerDependencyException $e) {
$this->assertStringContainsString(
'SubdivisionCode rule requires PHP ISO Codes',
$e->getMessage(),
);
} finally {
ContainerRegistry::setContainer($mainContainer);
}
}
/** @return iterable<array{SubdivisionCode, mixed}> */
public static function providerForValidInput(): iterable
{