Inital commit after seperating from project
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
name: PHP Composer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate composer.json and composer.lock
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Cache Composer packages
|
||||
id: composer-cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-php-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: PHPStan
|
||||
run: composer phpstan
|
||||
|
||||
- name: Code style check
|
||||
run: composer cs-check
|
||||
|
||||
- name: Tests
|
||||
run: composer test
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
/.php-cs-fixer.cache
|
||||
/.phpunit.result.cache
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Config;
|
||||
use PhpCsFixer\Finder;
|
||||
|
||||
$finder = Finder::create()
|
||||
->in([
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/tests',
|
||||
])
|
||||
->name('*.php')
|
||||
->notName('*.blade.php')
|
||||
->ignoreDotFiles(true)
|
||||
->ignoreVCS(true);
|
||||
|
||||
return (new Config())
|
||||
->setRiskyAllowed(true)
|
||||
->setRules([
|
||||
'@PER-CS2.0' => true,
|
||||
'@PHP83Migration' => true,
|
||||
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||
'no_unused_imports' => true,
|
||||
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
|
||||
'single_quote' => true,
|
||||
'explicit_string_variable' => true,
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
'trim_array_spaces' => true,
|
||||
'no_whitespace_before_comma_in_array' => true,
|
||||
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
|
||||
'declare_strict_types' => true,
|
||||
'strict_param' => true,
|
||||
'strict_comparison' => true,
|
||||
'phpdoc_align' => ['align' => 'left'],
|
||||
'phpdoc_order' => true,
|
||||
'phpdoc_trim' => true,
|
||||
'phpdoc_scalar' => true,
|
||||
'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => true],
|
||||
'yoda_style' => false,
|
||||
'no_useless_else' => true,
|
||||
'no_useless_return' => true,
|
||||
'concat_space' => ['spacing' => 'one'],
|
||||
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
|
||||
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try', 'if', 'foreach', 'for', 'while']],
|
||||
])
|
||||
->setFinder($finder);
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM php:8.3-cli-alpine
|
||||
|
||||
RUN apk add --no-cache git unzip
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
@@ -0,0 +1,29 @@
|
||||
PHP = docker compose run --rm php
|
||||
|
||||
.PHONY: install test phpstan cs-fix cs-check audit shell build
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
install:
|
||||
$(PHP) composer install
|
||||
|
||||
test:
|
||||
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
|
||||
|
||||
phpstan:
|
||||
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
|
||||
|
||||
cs-fix:
|
||||
$(PHP) vendor/bin/php-cs-fixer fix --ansi
|
||||
|
||||
cs-check:
|
||||
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
|
||||
|
||||
audit:
|
||||
$(PHP) composer audit
|
||||
|
||||
ci: audit phpstan cs-check test
|
||||
|
||||
shell:
|
||||
docker compose run --rm php sh
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "tez/utils-validate",
|
||||
"type": "library",
|
||||
"description": "Lightweight PHP validation toolkit: guard assertions, boolean rules, schema validation, strict type casting, composite predicates, and array-item validation — PHP 8.3+, no dependencies.",
|
||||
"license": "MIT",
|
||||
"version": "1.0.0",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Tez\\Utils\\Validate\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tez\\Utils\\Tests\\Validate\\": "tests/"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"jetbrains/phpstorm-attributes": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cs-fix": "php-cs-fixer fix",
|
||||
"cs-check": "php-cs-fixer fix --dry-run --diff",
|
||||
"phpstan": "phpstan analyse",
|
||||
"test": "phpunit --testsuite Unit --colors=always"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- .:/app
|
||||
- composer-cache:/tmp/composer
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
environment:
|
||||
COMPOSER_HOME: /tmp/composer
|
||||
|
||||
volumes:
|
||||
composer-cache:
|
||||
@@ -0,0 +1,8 @@
|
||||
parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
treatPhpDocTypesAsCertain: false
|
||||
parallel:
|
||||
maximumNumberOfProcesses: 1
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Validate;
|
||||
|
||||
/**
|
||||
* Combines boolean predicates with AND / OR / NONE logic.
|
||||
* Each method returns a Closure that accepts a value and returns bool,
|
||||
* so the result can be passed directly to Guard::that().
|
||||
*/
|
||||
final class Composite
|
||||
{
|
||||
/**
|
||||
* Returns a predicate that passes only when every rule returns true (AND).
|
||||
* Short-circuits on the first failure.
|
||||
*
|
||||
* @param callable(mixed): bool ...$rules
|
||||
* @return \Closure(mixed): bool
|
||||
*/
|
||||
public static function all(callable ...$rules): \Closure
|
||||
{
|
||||
return static function (mixed $value) use ($rules): bool {
|
||||
foreach ($rules as $rule) {
|
||||
if (!$rule($value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a predicate that passes when at least one rule returns true (OR).
|
||||
* Short-circuits on the first success.
|
||||
*
|
||||
* @param callable(mixed): bool ...$rules
|
||||
* @return \Closure(mixed): bool
|
||||
*/
|
||||
public static function any(callable ...$rules): \Closure
|
||||
{
|
||||
return static function (mixed $value) use ($rules): bool {
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule($value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a predicate that passes only when every rule returns false (NOR).
|
||||
* Short-circuits on the first success.
|
||||
*
|
||||
* @param callable(mixed): bool ...$rules
|
||||
* @return \Closure(mixed): bool
|
||||
*/
|
||||
public static function none(callable ...$rules): \Closure
|
||||
{
|
||||
return static function (mixed $value) use ($rules): bool {
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule($value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Validate;
|
||||
|
||||
/**
|
||||
* Applies a validation callable to every item in an array.
|
||||
* On the first failure the caught exception is re-thrown with the index path
|
||||
* prepended to the message (e.g. "items[2]: Amount must be positive").
|
||||
*/
|
||||
final class Each
|
||||
{
|
||||
/**
|
||||
* Validates every item in $items by calling $rule($item, $index).
|
||||
* $rule should throw a Throwable on failure or return normally on success.
|
||||
* Fails fast — stops at the first failing item.
|
||||
*
|
||||
* @param array<mixed> $items
|
||||
* @param callable(mixed, int|string): void $rule
|
||||
* @throws \Throwable the original exception type with path prepended to the message
|
||||
*/
|
||||
public static function validate(
|
||||
array $items,
|
||||
callable $rule,
|
||||
string $path = 'items',
|
||||
): void {
|
||||
foreach ($items as $index => $item) {
|
||||
try {
|
||||
$rule($item, $index);
|
||||
} catch (\Throwable $e) {
|
||||
$message = sprintf('%s[%s]: %s', $path, (string) $index, $e->getMessage());
|
||||
$class = $e::class;
|
||||
|
||||
// Re-throw using the same exception type so callers can catch it by class.
|
||||
// Most PHP exceptions accept (string $message, int $code, Throwable $previous).
|
||||
throw new $class($message, (int) $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Validate;
|
||||
|
||||
final class Guard
|
||||
{
|
||||
/**
|
||||
* Asserts that a value is not null. Returns the value for direct assignment.
|
||||
*
|
||||
* @template T
|
||||
* @param T|null $value
|
||||
* @param class-string<\Throwable>|\Throwable|\Closure(): \Throwable $exception
|
||||
* @return T
|
||||
*/
|
||||
public static function notNull(
|
||||
mixed $value,
|
||||
string|\Throwable|\Closure $exception = \RuntimeException::class,
|
||||
): mixed {
|
||||
if ($value === null) {
|
||||
self::doThrow($exception, 'Value must not be null.');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a value is not empty (null, '', '0', 0, false, []). Returns the value.
|
||||
*
|
||||
* @template T
|
||||
* @param T $value
|
||||
* @param class-string<\Throwable>|\Throwable|\Closure(): \Throwable $exception
|
||||
* @return T
|
||||
*/
|
||||
public static function notEmpty(
|
||||
mixed $value,
|
||||
string|\Throwable|\Closure $exception = \RuntimeException::class,
|
||||
): mixed {
|
||||
if (empty($value)) {
|
||||
self::doThrow($exception, 'Value must not be empty.');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a numeric value lies within [$min, $max] (inclusive). Returns the value.
|
||||
*
|
||||
* @param class-string<\Throwable>|\Throwable|\Closure(): \Throwable $exception
|
||||
*/
|
||||
public static function inRange(
|
||||
int|float $value,
|
||||
int|float $min,
|
||||
int|float $max,
|
||||
string|\Throwable|\Closure $exception = \RuntimeException::class,
|
||||
): int|float {
|
||||
if ($value < $min || $value > $max) {
|
||||
self::doThrow($exception, sprintf('Value %s is not in range [%s, %s].', $value, $min, $max));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a value is an instance of the given class. Returns the typed value.
|
||||
*
|
||||
* @template T of object
|
||||
* @param class-string<T> $class
|
||||
* @param class-string<\Throwable>|\Throwable|\Closure(): \Throwable $exception
|
||||
* @return T
|
||||
*/
|
||||
public static function instanceOf(
|
||||
mixed $value,
|
||||
string $class,
|
||||
string|\Throwable|\Closure $exception = \RuntimeException::class,
|
||||
): object {
|
||||
if (!($value instanceof $class)) {
|
||||
self::doThrow($exception, sprintf('Value must be an instance of %s.', $class));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a boolean value is true. Returns true.
|
||||
*
|
||||
* @param class-string<\Throwable>|\Throwable|\Closure(): \Throwable $exception
|
||||
*/
|
||||
public static function isTrue(
|
||||
bool $value,
|
||||
string|\Throwable|\Closure $exception = \RuntimeException::class,
|
||||
): bool {
|
||||
if ($value !== true) {
|
||||
self::doThrow($exception, 'Value must be true.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a boolean value is false. Returns false.
|
||||
*
|
||||
* @param class-string<\Throwable>|\Throwable|\Closure(): \Throwable $exception
|
||||
*/
|
||||
public static function isFalse(
|
||||
bool $value,
|
||||
string|\Throwable|\Closure $exception = \RuntimeException::class,
|
||||
): bool {
|
||||
if ($value !== false) {
|
||||
self::doThrow($exception, 'Value must be false.');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a custom boolean condition using a Closure-based exception factory.
|
||||
*
|
||||
* @param \Closure(): \Throwable $exception
|
||||
*/
|
||||
public static function that(
|
||||
bool $condition,
|
||||
\Closure $exception,
|
||||
): bool {
|
||||
if (!$condition) {
|
||||
self::doThrow($exception, '');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<\Throwable>|\Throwable|\Closure(): \Throwable $exception
|
||||
*/
|
||||
private static function doThrow(string|\Throwable|\Closure $exception, string $defaultMessage): never
|
||||
{
|
||||
if ($exception instanceof \Throwable) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($exception instanceof \Closure) {
|
||||
throw $exception();
|
||||
}
|
||||
|
||||
throw new $exception($defaultMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a value is numeric. Returns the value for direct assignment.
|
||||
*
|
||||
* @template T
|
||||
* @param T|null $value
|
||||
* @param class-string<\Throwable>|\Throwable|\Closure(): \Throwable $exception
|
||||
* @throws \Throwable
|
||||
* @return T
|
||||
*/
|
||||
public static function isNumeric(
|
||||
mixed $value,
|
||||
string|\Throwable|\Closure $exception = \InvalidArgumentException::class,
|
||||
): mixed {
|
||||
if (!is_numeric($value)) {
|
||||
self::doThrow($exception, 'Expected a numeric string.');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Validate;
|
||||
|
||||
/**
|
||||
* Stateless boolean predicates for common validation needs.
|
||||
* Returns true/false — no exceptions, no side-effects.
|
||||
* Designed to work standalone and as input to Guard::that().
|
||||
*/
|
||||
final class Rules
|
||||
{
|
||||
/** Returns true if the value is a valid e-mail address. */
|
||||
public static function isEmail(string $value): bool
|
||||
{
|
||||
return filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the value is a valid URL.
|
||||
* When $requireHttps is true, only https:// URLs are accepted.
|
||||
*/
|
||||
public static function isUrl(string $value, bool $requireHttps = false): bool
|
||||
{
|
||||
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$requireHttps || str_starts_with($value, 'https://');
|
||||
}
|
||||
|
||||
/** Returns true if the value is a valid UUID (v1–v5, case-insensitive). */
|
||||
public static function isUuid(string $value): bool
|
||||
{
|
||||
return preg_match(
|
||||
'/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
|
||||
$value,
|
||||
) === 1;
|
||||
}
|
||||
|
||||
/** Returns true if the value is a valid IPv4 or IPv6 address. */
|
||||
public static function isIp(string $value): bool
|
||||
{
|
||||
return filter_var($value, FILTER_VALIDATE_IP) !== false;
|
||||
}
|
||||
|
||||
/** Returns true if the value is a valid IPv4 address. */
|
||||
public static function isIpv4(string $value): bool
|
||||
{
|
||||
return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
||||
}
|
||||
|
||||
/** Returns true if the value is a valid IPv6 address. */
|
||||
public static function isIpv6(string $value): bool
|
||||
{
|
||||
return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
}
|
||||
|
||||
/** Returns true if the multibyte length of $value is at least $min characters. */
|
||||
public static function minLength(string $value, int $min): bool
|
||||
{
|
||||
return mb_strlen($value) >= $min;
|
||||
}
|
||||
|
||||
/** Returns true if the multibyte length of $value is at most $max characters. */
|
||||
public static function maxLength(string $value, int $max): bool
|
||||
{
|
||||
return mb_strlen($value) <= $max;
|
||||
}
|
||||
|
||||
/** Returns true if the value matches the given regular expression pattern. */
|
||||
public static function matches(string $value, string $pattern): bool
|
||||
{
|
||||
return preg_match($pattern, $value) === 1;
|
||||
}
|
||||
|
||||
/** Returns true if $value is between $min and $max (inclusive). */
|
||||
public static function between(int|float $value, int|float $min, int|float $max): bool
|
||||
{
|
||||
return $value >= $min && $value <= $max;
|
||||
}
|
||||
|
||||
/** Returns true if $value is strictly greater than zero. */
|
||||
public static function isPositive(int|float $value): bool
|
||||
{
|
||||
return $value > 0;
|
||||
}
|
||||
|
||||
/** Returns true if $value is strictly less than zero. */
|
||||
public static function isNegative(int|float $value): bool
|
||||
{
|
||||
return $value < 0;
|
||||
}
|
||||
|
||||
/** Returns true if the value is not null, not an empty string, and not an empty array. */
|
||||
public static function notEmpty(mixed $value): bool
|
||||
{
|
||||
return $value !== null && $value !== '' && $value !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if $value is a valid date string matching the given format.
|
||||
* Uses strict validation — '2023-02-30' returns false even though PHP can parse it.
|
||||
*/
|
||||
public static function isDate(string $value, string $format = 'Y-m-d'): bool
|
||||
{
|
||||
$dt = \DateTimeImmutable::createFromFormat($format, $value);
|
||||
|
||||
return $dt !== false && $dt->format($format) === $value;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Validate;
|
||||
|
||||
/**
|
||||
* Validates an array against a shape definition.
|
||||
* Fails fast on the first violation and reports the dot-notation path.
|
||||
*
|
||||
* Schema field syntax:
|
||||
* 'string' | 'int' | 'float' | 'bool' | 'array' | 'mixed' — required, typed
|
||||
* '?string' (etc.) — optional (absent or null)
|
||||
* [...] (nested array) — required nested schema
|
||||
* callable — custom rule, throws on failure
|
||||
*/
|
||||
final class Schema
|
||||
{
|
||||
/**
|
||||
* Validates $data against $schema, throwing UnexpectedValueException on the first violation.
|
||||
* The exception message contains the dot-notation path to the failing field.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
* @param array<string, mixed> $schema
|
||||
* @throws \UnexpectedValueException
|
||||
* @throws \InvalidArgumentException for unknown type strings in the schema
|
||||
*/
|
||||
public static function validate(array $data, array $schema): void
|
||||
{
|
||||
self::validateNode($data, $schema, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @param array<string, mixed> $schema
|
||||
*/
|
||||
private static function validateNode(array $data, array $schema, string $prefix): void
|
||||
{
|
||||
foreach ($schema as $key => $definition) {
|
||||
$path = $prefix !== '' ? "{$prefix}.{$key}" : $key;
|
||||
|
||||
// Detect optional marker (only applies to string type definitions).
|
||||
$optional = false;
|
||||
|
||||
if (is_string($definition) && str_starts_with($definition, '?')) {
|
||||
$optional = true;
|
||||
$definition = substr($definition, 1);
|
||||
}
|
||||
|
||||
// Missing key.
|
||||
if (!array_key_exists($key, $data)) {
|
||||
if ($optional) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new \UnexpectedValueException("{$path}: required key is missing");
|
||||
}
|
||||
|
||||
$value = $data[$key];
|
||||
|
||||
// Optional and null — skip further validation.
|
||||
if ($optional && $value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nested schema (array of definitions).
|
||||
if (is_array($definition)) {
|
||||
if (!is_array($value)) {
|
||||
throw new \UnexpectedValueException(
|
||||
sprintf('%s: expected array, got %s', $path, get_debug_type($value)),
|
||||
);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $definition */
|
||||
self::validateNode($value, $definition, $path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Callable rule — throws on failure.
|
||||
if (is_callable($definition)) {
|
||||
try {
|
||||
$definition($value);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \UnexpectedValueException(
|
||||
sprintf('%s: %s', $path, $e->getMessage()),
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// String type name.
|
||||
if (is_string($definition)) {
|
||||
self::assertType($value, $definition, $path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('Invalid schema definition for key "%s".', $key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks that $value matches the expected type name, throws otherwise. */
|
||||
private static function assertType(mixed $value, string $type, string $path): void
|
||||
{
|
||||
$valid = match ($type) {
|
||||
'string' => is_string($value),
|
||||
'int' => is_int($value),
|
||||
'float' => is_float($value),
|
||||
'bool' => is_bool($value),
|
||||
'array' => is_array($value),
|
||||
'mixed' => true,
|
||||
default => throw new \InvalidArgumentException("Unknown schema type: '{$type}'"),
|
||||
};
|
||||
|
||||
if (!$valid) {
|
||||
throw new \UnexpectedValueException(
|
||||
sprintf('%s: expected %s, got %s', $path, $type, get_debug_type($value)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Validate;
|
||||
|
||||
/**
|
||||
* Strict type coercion — throws UnexpectedValueException when a value
|
||||
* cannot be unambiguously converted, unlike PHP built-ins (intval, settype)
|
||||
* which silently truncate or guess.
|
||||
*/
|
||||
final class TypeCast
|
||||
{
|
||||
/**
|
||||
* Converts a value to int.
|
||||
* Accepts: int (as-is), float with exact integer value (e.g. 3.0 → 3),
|
||||
* purely numeric integer string (e.g. '42', '-7').
|
||||
*
|
||||
* @throws \UnexpectedValueException for any other input
|
||||
*/
|
||||
public static function toInt(mixed $value): int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_float($value)) {
|
||||
if (floor($value) !== $value) {
|
||||
throw new \UnexpectedValueException(
|
||||
sprintf('Cannot convert float %s to int without data loss.', $value),
|
||||
);
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && preg_match('/^[+-]?\d+$/', $value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
throw new \UnexpectedValueException(
|
||||
sprintf('Cannot convert %s to int.', get_debug_type($value)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a value to float.
|
||||
* Accepts: float (as-is), int (promoted), numeric string.
|
||||
*
|
||||
* @throws \UnexpectedValueException for any other input
|
||||
*/
|
||||
public static function toFloat(mixed $value): float
|
||||
{
|
||||
if (is_float($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && is_numeric($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
throw new \UnexpectedValueException(
|
||||
sprintf('Cannot convert %s to float.', get_debug_type($value)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a value to string.
|
||||
* Accepts: string (as-is), int, float.
|
||||
*
|
||||
* @throws \UnexpectedValueException for null, bool, array, object, and other types
|
||||
*/
|
||||
public static function toString(mixed $value): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
throw new \UnexpectedValueException(
|
||||
sprintf('Cannot convert %s to string.', get_debug_type($value)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a value to bool using explicit coercion rules.
|
||||
*
|
||||
* Truthy: true, 1, "true", "yes", "on", "1"
|
||||
* Falsy: false, 0, "false", "no", "off", "0", ""
|
||||
*
|
||||
* @throws \UnexpectedValueException for any other input
|
||||
*/
|
||||
public static function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($value === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$lower = strtolower($value);
|
||||
|
||||
if (in_array($lower, ['true', 'yes', 'on', '1'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($lower, ['false', 'no', 'off', '0', ''], true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \UnexpectedValueException(
|
||||
sprintf('Cannot convert %s to bool.', get_debug_type($value)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Converts to int, or returns null if the value is null. */
|
||||
public static function toNullableInt(mixed $value): ?int
|
||||
{
|
||||
return $value === null ? null : self::toInt($value);
|
||||
}
|
||||
|
||||
/** Converts to float, or returns null if the value is null. */
|
||||
public static function toNullableFloat(mixed $value): ?float
|
||||
{
|
||||
return $value === null ? null : self::toFloat($value);
|
||||
}
|
||||
|
||||
/** Converts to string, or returns null if the value is null. */
|
||||
public static function toNullableString(mixed $value): ?string
|
||||
{
|
||||
return $value === null ? null : self::toString($value);
|
||||
}
|
||||
|
||||
/** Converts to bool, or returns null if the value is null. */
|
||||
public static function toNullableBool(mixed $value): ?bool
|
||||
{
|
||||
return $value === null ? null : self::toBool($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Validate;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Validate\Composite;
|
||||
use Tez\Utils\Validate\Rules;
|
||||
|
||||
final class CompositeTest extends TestCase
|
||||
{
|
||||
// --- all ---
|
||||
|
||||
public function testAllReturnsTrueWhenEveryRulePasses(): void
|
||||
{
|
||||
$check = Composite::all(
|
||||
fn($v) => is_string($v) && Rules::minLength($v, 3),
|
||||
fn($v) => is_string($v) && Rules::maxLength($v, 10),
|
||||
fn($v) => is_string($v) && Rules::matches($v, '/^[a-z]+$/'),
|
||||
);
|
||||
|
||||
self::assertTrue($check('hello'));
|
||||
}
|
||||
|
||||
public function testAllReturnsFalseWhenAnyRuleFails(): void
|
||||
{
|
||||
$check = Composite::all(
|
||||
fn($v) => is_string($v) && Rules::minLength($v, 3),
|
||||
fn($v) => is_string($v) && Rules::maxLength($v, 4),
|
||||
);
|
||||
|
||||
self::assertFalse($check('toolong'));
|
||||
}
|
||||
|
||||
public function testAllShortCircuitsOnFirstFailure(): void
|
||||
{
|
||||
$called = 0;
|
||||
$check = Composite::all(
|
||||
fn() => false,
|
||||
function () use (&$called) {
|
||||
$called++;
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
$check('x');
|
||||
|
||||
self::assertSame(0, $called);
|
||||
}
|
||||
|
||||
public function testAllWithNoRulesReturnsTrue(): void
|
||||
{
|
||||
$check = Composite::all();
|
||||
self::assertTrue($check('anything'));
|
||||
}
|
||||
|
||||
// --- any ---
|
||||
|
||||
public function testAnyReturnsTrueWhenOneRulePasses(): void
|
||||
{
|
||||
$check = Composite::any(
|
||||
fn($v) => is_string($v) && Rules::isEmail($v),
|
||||
fn($v) => is_string($v) && Rules::isUuid($v),
|
||||
);
|
||||
|
||||
self::assertTrue($check('user@example.com'));
|
||||
}
|
||||
|
||||
public function testAnyReturnsFalseWhenNoRulePasses(): void
|
||||
{
|
||||
$check = Composite::any(
|
||||
fn($v) => is_string($v) && Rules::isEmail($v),
|
||||
fn($v) => is_string($v) && Rules::isUuid($v),
|
||||
);
|
||||
|
||||
self::assertFalse($check('neither'));
|
||||
}
|
||||
|
||||
public function testAnyShortCircuitsOnFirstSuccess(): void
|
||||
{
|
||||
$called = 0;
|
||||
$check = Composite::any(
|
||||
fn() => true,
|
||||
function () use (&$called) {
|
||||
$called++;
|
||||
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
$check('x');
|
||||
|
||||
self::assertSame(0, $called);
|
||||
}
|
||||
|
||||
public function testAnyWithNoRulesReturnsFalse(): void
|
||||
{
|
||||
$check = Composite::any();
|
||||
self::assertFalse($check('anything'));
|
||||
}
|
||||
|
||||
// --- none ---
|
||||
|
||||
public function testNoneReturnsTrueWhenNoRuleMatches(): void
|
||||
{
|
||||
$isReserved = Composite::none(
|
||||
fn($v) => $v === 'admin',
|
||||
fn($v) => $v === 'root',
|
||||
);
|
||||
|
||||
self::assertTrue($isReserved('alice'));
|
||||
}
|
||||
|
||||
public function testNoneReturnsFalseWhenAnyRuleMatches(): void
|
||||
{
|
||||
$isReserved = Composite::none(
|
||||
fn($v) => $v === 'admin',
|
||||
fn($v) => $v === 'root',
|
||||
);
|
||||
|
||||
self::assertFalse($isReserved('admin'));
|
||||
}
|
||||
|
||||
public function testNoneShortCircuitsOnFirstMatch(): void
|
||||
{
|
||||
$called = 0;
|
||||
$check = Composite::none(
|
||||
fn() => true,
|
||||
function () use (&$called) {
|
||||
$called++;
|
||||
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
$check('x');
|
||||
|
||||
self::assertSame(0, $called);
|
||||
}
|
||||
|
||||
public function testNoneWithNoRulesReturnsTrue(): void
|
||||
{
|
||||
$check = Composite::none();
|
||||
self::assertTrue($check('anything'));
|
||||
}
|
||||
|
||||
// --- integration with Guard ---
|
||||
|
||||
public function testAllComposesWithGuard(): void
|
||||
{
|
||||
$isStrongPassword = Composite::all(
|
||||
fn($v) => is_string($v) && Rules::minLength($v, 8),
|
||||
fn($v) => is_string($v) && Rules::matches($v, '/[A-Z]/'),
|
||||
fn($v) => is_string($v) && Rules::matches($v, '/[0-9]/'),
|
||||
);
|
||||
|
||||
self::assertTrue($isStrongPassword('Secret42!'));
|
||||
self::assertFalse($isStrongPassword('weak'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Validate;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Validate\Each;
|
||||
use Tez\Utils\Validate\Guard;
|
||||
|
||||
final class EachTest extends TestCase
|
||||
{
|
||||
public function testPassesWhenAllItemsAreValid(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
Each::validate([1, 2, 3], function (mixed $item): void {
|
||||
Guard::isTrue(is_int($item) && $item > 0, \InvalidArgumentException::class);
|
||||
});
|
||||
}
|
||||
|
||||
public function testThrowsOnFirstInvalidItem(): void
|
||||
{
|
||||
$this->expectException(\DomainException::class);
|
||||
$this->expectExceptionMessageMatches('/items\[1\]/');
|
||||
|
||||
$orders = [
|
||||
['amount' => 50],
|
||||
['amount' => -10],
|
||||
['amount' => 200],
|
||||
];
|
||||
|
||||
Each::validate($orders, function (mixed $order): void {
|
||||
/** @var array{amount: int} $order */
|
||||
if ($order['amount'] <= 0) {
|
||||
throw new \DomainException('Amount must be positive.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function testIndexIsIncludedInMessage(): void
|
||||
{
|
||||
try {
|
||||
Each::validate(['ok', 'ok', 'fail'], function (mixed $item): void {
|
||||
if ($item === 'fail') {
|
||||
throw new \RuntimeException('bad value');
|
||||
}
|
||||
});
|
||||
|
||||
self::fail('Expected exception not thrown.');
|
||||
} catch (\RuntimeException $e) {
|
||||
self::assertStringContainsString('items[2]', $e->getMessage());
|
||||
self::assertStringContainsString('bad value', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function testCustomPathIsUsed(): void
|
||||
{
|
||||
try {
|
||||
Each::validate(['a', 'b'], fn() => throw new \RuntimeException('err'), path: 'lines');
|
||||
self::fail('Expected exception not thrown.');
|
||||
} catch (\RuntimeException $e) {
|
||||
self::assertStringContainsString('lines[', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function testEmptyArrayPasses(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
Each::validate([], fn() => throw new \RuntimeException('should not be called'));
|
||||
}
|
||||
|
||||
public function testOriginalExceptionIsAvailableAsPrevious(): void
|
||||
{
|
||||
$original = new \DomainException('original message');
|
||||
|
||||
try {
|
||||
Each::validate(['x'], function () use ($original): void {
|
||||
throw $original;
|
||||
});
|
||||
} catch (\DomainException $e) {
|
||||
self::assertSame($original, $e->getPrevious());
|
||||
}
|
||||
}
|
||||
|
||||
public function testRuleReceivesIndexAsSecondArgument(): void
|
||||
{
|
||||
$received = [];
|
||||
|
||||
Each::validate(['a', 'b', 'c'], function (mixed $item, int|string $index) use (&$received): void {
|
||||
$received[] = $index;
|
||||
});
|
||||
|
||||
self::assertSame([0, 1, 2], $received);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Validate;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Validate\Guard;
|
||||
|
||||
final class GuardTest extends TestCase
|
||||
{
|
||||
// --- notNull ---
|
||||
|
||||
public function testNotNullPassesAndReturnsValue(): void
|
||||
{
|
||||
self::assertSame('hello', Guard::notNull('hello'));
|
||||
self::assertSame(0, Guard::notNull(0));
|
||||
self::assertSame(false, Guard::notNull(false));
|
||||
}
|
||||
|
||||
public function testNotNullThrowsDefaultExceptionOnNull(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::notNull(null);
|
||||
}
|
||||
|
||||
public function testNotNullThrowsCustomExceptionClass(): void
|
||||
{
|
||||
$this->expectException(\LogicException::class);
|
||||
|
||||
Guard::notNull(null, \LogicException::class);
|
||||
}
|
||||
|
||||
public function testNotNullThrowsExceptionInstance(): void
|
||||
{
|
||||
$this->expectException(\OverflowException::class);
|
||||
$this->expectExceptionMessage('custom message');
|
||||
|
||||
Guard::notNull(null, new \OverflowException('custom message'));
|
||||
}
|
||||
|
||||
public function testNotNullThrowsExceptionFromClosure(): void
|
||||
{
|
||||
$this->expectException(\DomainException::class);
|
||||
$this->expectExceptionMessage('from closure');
|
||||
|
||||
Guard::notNull(null, fn() => new \DomainException('from closure'));
|
||||
}
|
||||
|
||||
// --- notEmpty ---
|
||||
|
||||
public function testNotEmptyPassesAndReturnsValue(): void
|
||||
{
|
||||
self::assertSame('hello', Guard::notEmpty('hello'));
|
||||
self::assertSame([1], Guard::notEmpty([1]));
|
||||
self::assertSame(1, Guard::notEmpty(1));
|
||||
}
|
||||
|
||||
public function testNotEmptyThrowsOnEmptyString(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::notEmpty('');
|
||||
}
|
||||
|
||||
public function testNotEmptyThrowsOnEmptyArray(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::notEmpty([]);
|
||||
}
|
||||
|
||||
public function testNotEmptyThrowsOnNull(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::notEmpty(null);
|
||||
}
|
||||
|
||||
public function testNotEmptyThrowsCustomException(): void
|
||||
{
|
||||
$this->expectException(\LogicException::class);
|
||||
|
||||
Guard::notEmpty('', \LogicException::class);
|
||||
}
|
||||
|
||||
// --- inRange ---
|
||||
|
||||
public function testInRangePassesAndReturnsValue(): void
|
||||
{
|
||||
self::assertSame(5, Guard::inRange(5, min: 1, max: 10));
|
||||
self::assertSame(1, Guard::inRange(1, min: 1, max: 10));
|
||||
self::assertSame(10, Guard::inRange(10, min: 1, max: 10));
|
||||
}
|
||||
|
||||
public function testInRangePassesForFloat(): void
|
||||
{
|
||||
self::assertSame(3.5, Guard::inRange(3.5, min: 0.0, max: 5.0));
|
||||
}
|
||||
|
||||
public function testInRangeThrowsBelowMin(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::inRange(0, min: 1, max: 10);
|
||||
}
|
||||
|
||||
public function testInRangeThrowsAboveMax(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::inRange(11, min: 1, max: 10);
|
||||
}
|
||||
|
||||
public function testInRangeThrowsCustomException(): void
|
||||
{
|
||||
$this->expectException(\OverflowException::class);
|
||||
|
||||
Guard::inRange(200, min: 0, max: 150, exception: \OverflowException::class);
|
||||
}
|
||||
|
||||
// --- instanceOf ---
|
||||
|
||||
public function testInstanceOfPassesAndReturnsTypedValue(): void
|
||||
{
|
||||
$dt = new \DateTime();
|
||||
$result = Guard::instanceOf($dt, \DateTime::class);
|
||||
|
||||
self::assertSame($dt, $result);
|
||||
}
|
||||
|
||||
public function testInstanceOfThrowsOnWrongType(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::instanceOf(new \stdClass(), \DateTime::class);
|
||||
}
|
||||
|
||||
public function testInstanceOfThrowsCustomException(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
|
||||
Guard::instanceOf('not an object', \DateTime::class, \UnexpectedValueException::class);
|
||||
}
|
||||
|
||||
// --- isTrue ---
|
||||
|
||||
public function testIsTruePassesAndReturnsTrue(): void
|
||||
{
|
||||
self::assertTrue(Guard::isTrue(true));
|
||||
}
|
||||
|
||||
public function testIsTrueThrowsOnFalse(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::isTrue(false);
|
||||
}
|
||||
|
||||
public function testIsTrueThrowsCustomException(): void
|
||||
{
|
||||
$this->expectException(\LogicException::class);
|
||||
|
||||
Guard::isTrue(false, \LogicException::class);
|
||||
}
|
||||
|
||||
// --- isFalse ---
|
||||
|
||||
public function testIsFalsePassesAndReturnsFalse(): void
|
||||
{
|
||||
self::assertFalse(Guard::isFalse(false));
|
||||
}
|
||||
|
||||
public function testIsFalseThrowsOnTrue(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
Guard::isFalse(true);
|
||||
}
|
||||
|
||||
// --- that ---
|
||||
|
||||
public function testThatPassesOnTrueCondition(): void
|
||||
{
|
||||
/** @var int $value */
|
||||
$value = 1;
|
||||
self::assertTrue(Guard::that($value > 0, fn() => new \DomainException('fail')));
|
||||
}
|
||||
|
||||
public function testThatThrowsOnFalseCondition(): void
|
||||
{
|
||||
$this->expectException(\DomainException::class);
|
||||
$this->expectExceptionMessage('Must be positive');
|
||||
|
||||
/** @var int $value */
|
||||
$value = -1;
|
||||
Guard::that($value > 0, fn() => new \DomainException('Must be positive'));
|
||||
}
|
||||
|
||||
// --- no InvalidArgumentException in default path ---
|
||||
|
||||
public function testDefaultExceptionIsNotInvalidArgumentException(): void
|
||||
{
|
||||
try {
|
||||
Guard::notNull(null);
|
||||
} catch (\Throwable $e) {
|
||||
self::assertNotInstanceOf(\InvalidArgumentException::class, $e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::fail('Expected exception was not thrown.');
|
||||
}
|
||||
|
||||
// --- isNumeric ---
|
||||
|
||||
public function testIsNumericPassesIntegerString(): void
|
||||
{
|
||||
self::assertSame('42', Guard::isNumeric('42'));
|
||||
}
|
||||
|
||||
public function testIsNumericPassesDecimalString(): void
|
||||
{
|
||||
self::assertSame('3.14', Guard::isNumeric('3.14'));
|
||||
}
|
||||
|
||||
public function testIsNumericPassesNegativeString(): void
|
||||
{
|
||||
self::assertSame('-99.5', Guard::isNumeric('-99.5'));
|
||||
}
|
||||
|
||||
public function testIsNumericPassesIntegerType(): void
|
||||
{
|
||||
self::assertSame(42, Guard::isNumeric(42));
|
||||
}
|
||||
|
||||
public function testIsNumericPassesFloatType(): void
|
||||
{
|
||||
self::assertSame(3.14, Guard::isNumeric(3.14));
|
||||
}
|
||||
|
||||
public function testIsNumericThrowsDefaultExceptionOnNonNumericString(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Guard::isNumeric('not-a-number');
|
||||
}
|
||||
|
||||
public function testIsNumericThrowsOnNull(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
Guard::isNumeric(null);
|
||||
}
|
||||
|
||||
public function testIsNumericThrowsOnEmptyString(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
Guard::isNumeric('');
|
||||
}
|
||||
|
||||
public function testIsNumericThrowsOnNonNumericString(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
Guard::isNumeric('not-a-number');
|
||||
}
|
||||
|
||||
public function testIsNumericThrowsCustomExceptionClass(): void
|
||||
{
|
||||
$this->expectException(\DomainException::class);
|
||||
Guard::isNumeric('abc', \DomainException::class);
|
||||
}
|
||||
|
||||
public function testIsNumericThrowsExceptionFromClosure(): void
|
||||
{
|
||||
$this->expectException(\OverflowException::class);
|
||||
$this->expectExceptionMessage('not numeric');
|
||||
Guard::isNumeric('abc', fn() => new \OverflowException('not numeric'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Validate;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Validate\Rules;
|
||||
|
||||
final class RulesTest extends TestCase
|
||||
{
|
||||
// --- isEmail ---
|
||||
|
||||
public function testIsEmailValid(): void
|
||||
{
|
||||
self::assertTrue(Rules::isEmail('user@example.com'));
|
||||
self::assertTrue(Rules::isEmail('user+tag@sub.example.org'));
|
||||
}
|
||||
|
||||
public function testIsEmailInvalid(): void
|
||||
{
|
||||
self::assertFalse(Rules::isEmail('not-an-email'));
|
||||
self::assertFalse(Rules::isEmail('@missing-local.com'));
|
||||
self::assertFalse(Rules::isEmail(''));
|
||||
}
|
||||
|
||||
// --- isUrl ---
|
||||
|
||||
public function testIsUrlValid(): void
|
||||
{
|
||||
self::assertTrue(Rules::isUrl('https://example.com'));
|
||||
self::assertTrue(Rules::isUrl('http://example.com/path?q=1'));
|
||||
}
|
||||
|
||||
public function testIsUrlInvalid(): void
|
||||
{
|
||||
self::assertFalse(Rules::isUrl('not a url'));
|
||||
self::assertFalse(Rules::isUrl(''));
|
||||
}
|
||||
|
||||
public function testIsUrlRequireHttps(): void
|
||||
{
|
||||
self::assertTrue(Rules::isUrl('https://example.com', requireHttps: true));
|
||||
self::assertFalse(Rules::isUrl('http://example.com', requireHttps: true));
|
||||
}
|
||||
|
||||
// --- isUuid ---
|
||||
|
||||
public function testIsUuidValid(): void
|
||||
{
|
||||
self::assertTrue(Rules::isUuid('550e8400-e29b-41d4-a716-446655440000'));
|
||||
self::assertTrue(Rules::isUuid('550E8400-E29B-41D4-A716-446655440000')); // uppercase
|
||||
}
|
||||
|
||||
public function testIsUuidInvalid(): void
|
||||
{
|
||||
self::assertFalse(Rules::isUuid('not-a-uuid'));
|
||||
self::assertFalse(Rules::isUuid('550e8400-e29b-41d4-a716-44665544000')); // too short
|
||||
self::assertFalse(Rules::isUuid(''));
|
||||
}
|
||||
|
||||
// --- isIp / isIpv4 / isIpv6 ---
|
||||
|
||||
public function testIsIpv4(): void
|
||||
{
|
||||
self::assertTrue(Rules::isIp('192.168.1.1'));
|
||||
self::assertTrue(Rules::isIpv4('192.168.1.1'));
|
||||
self::assertFalse(Rules::isIpv4('999.999.999.999'));
|
||||
}
|
||||
|
||||
public function testIsIpv6(): void
|
||||
{
|
||||
self::assertTrue(Rules::isIp('::1'));
|
||||
self::assertTrue(Rules::isIpv6('2001:db8::1'));
|
||||
self::assertFalse(Rules::isIpv6('192.168.1.1'));
|
||||
}
|
||||
|
||||
// --- minLength / maxLength ---
|
||||
|
||||
public function testMinLength(): void
|
||||
{
|
||||
self::assertTrue(Rules::minLength('hello', 3));
|
||||
self::assertTrue(Rules::minLength('hello', 5));
|
||||
self::assertFalse(Rules::minLength('hi', 3));
|
||||
}
|
||||
|
||||
public function testMaxLength(): void
|
||||
{
|
||||
self::assertTrue(Rules::maxLength('hi', 5));
|
||||
self::assertTrue(Rules::maxLength('hello', 5));
|
||||
self::assertFalse(Rules::maxLength('toolong', 5));
|
||||
}
|
||||
|
||||
public function testLengthIsMultibyteAware(): void
|
||||
{
|
||||
self::assertTrue(Rules::minLength('äöü', 3)); // 3 chars, not 6 bytes
|
||||
self::assertTrue(Rules::maxLength('äöü', 3));
|
||||
}
|
||||
|
||||
// --- matches ---
|
||||
|
||||
public function testMatchesValid(): void
|
||||
{
|
||||
self::assertTrue(Rules::matches('abc123', '/^[a-z0-9]+$/'));
|
||||
self::assertTrue(Rules::matches('test@example.com', '/@/'));
|
||||
}
|
||||
|
||||
public function testMatchesInvalid(): void
|
||||
{
|
||||
self::assertFalse(Rules::matches('ABC', '/^[a-z]+$/'));
|
||||
}
|
||||
|
||||
// --- between ---
|
||||
|
||||
public function testBetweenInclusive(): void
|
||||
{
|
||||
self::assertTrue(Rules::between(5, 1, 10));
|
||||
self::assertTrue(Rules::between(1, 1, 10)); // lower bound
|
||||
self::assertTrue(Rules::between(10, 1, 10)); // upper bound
|
||||
self::assertFalse(Rules::between(0, 1, 10));
|
||||
self::assertFalse(Rules::between(11, 1, 10));
|
||||
}
|
||||
|
||||
public function testBetweenWithFloats(): void
|
||||
{
|
||||
self::assertTrue(Rules::between(3.14, 0.0, 5.0));
|
||||
self::assertFalse(Rules::between(5.01, 0.0, 5.0));
|
||||
}
|
||||
|
||||
// --- isPositive / isNegative ---
|
||||
|
||||
public function testIsPositive(): void
|
||||
{
|
||||
self::assertTrue(Rules::isPositive(1));
|
||||
self::assertTrue(Rules::isPositive(0.01));
|
||||
self::assertFalse(Rules::isPositive(0));
|
||||
self::assertFalse(Rules::isPositive(-1));
|
||||
}
|
||||
|
||||
public function testIsNegative(): void
|
||||
{
|
||||
self::assertTrue(Rules::isNegative(-1));
|
||||
self::assertTrue(Rules::isNegative(-0.01));
|
||||
self::assertFalse(Rules::isNegative(0));
|
||||
self::assertFalse(Rules::isNegative(1));
|
||||
}
|
||||
|
||||
// --- notEmpty ---
|
||||
|
||||
public function testNotEmpty(): void
|
||||
{
|
||||
self::assertTrue(Rules::notEmpty('hello'));
|
||||
self::assertTrue(Rules::notEmpty(0));
|
||||
self::assertTrue(Rules::notEmpty(false));
|
||||
self::assertTrue(Rules::notEmpty([0]));
|
||||
}
|
||||
|
||||
public function testNotEmptyFalsy(): void
|
||||
{
|
||||
self::assertFalse(Rules::notEmpty(null));
|
||||
self::assertFalse(Rules::notEmpty(''));
|
||||
self::assertFalse(Rules::notEmpty([]));
|
||||
}
|
||||
|
||||
// --- isDate ---
|
||||
|
||||
public function testIsDateValid(): void
|
||||
{
|
||||
self::assertTrue(Rules::isDate('2026-04-23'));
|
||||
self::assertTrue(Rules::isDate('2000-01-01'));
|
||||
}
|
||||
|
||||
public function testIsDateInvalid(): void
|
||||
{
|
||||
self::assertFalse(Rules::isDate('2023-02-30')); // day out of range
|
||||
self::assertFalse(Rules::isDate('not-a-date'));
|
||||
self::assertFalse(Rules::isDate(''));
|
||||
}
|
||||
|
||||
public function testIsDateCustomFormat(): void
|
||||
{
|
||||
self::assertTrue(Rules::isDate('23.04.2026', 'd.m.Y'));
|
||||
self::assertFalse(Rules::isDate('2026-04-23', 'd.m.Y'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Validate;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Validate\Guard;
|
||||
use Tez\Utils\Validate\Schema;
|
||||
|
||||
final class SchemaTest extends TestCase
|
||||
{
|
||||
public function testValidDataPassesWithoutException(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
Schema::validate([
|
||||
'name' => 'Alice',
|
||||
'age' => 28,
|
||||
'active' => true,
|
||||
], [
|
||||
'name' => 'string',
|
||||
'age' => 'int',
|
||||
'active' => 'bool',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testThrowsWhenRequiredKeyIsMissing(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessageMatches('/name.*required/');
|
||||
|
||||
Schema::validate([], ['name' => 'string']);
|
||||
}
|
||||
|
||||
public function testThrowsOnWrongType(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessageMatches('/age.*expected int.*got string/');
|
||||
|
||||
Schema::validate(['age' => 'not-an-int'], ['age' => 'int']);
|
||||
}
|
||||
|
||||
public function testOptionalKeyCanBeAbsent(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
Schema::validate(['name' => 'Alice'], ['name' => 'string', 'email' => '?string']);
|
||||
}
|
||||
|
||||
public function testOptionalKeyCanBeNull(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
Schema::validate(['name' => 'Alice', 'email' => null], ['name' => 'string', 'email' => '?string']);
|
||||
}
|
||||
|
||||
public function testOptionalKeyWithWrongTypeThrows(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
|
||||
Schema::validate(['email' => 42], ['email' => '?string']);
|
||||
}
|
||||
|
||||
public function testNestedSchemaIsValidated(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
Schema::validate([
|
||||
'user' => ['name' => 'Alice', 'age' => 28],
|
||||
], [
|
||||
'user' => ['name' => 'string', 'age' => 'int'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testNestedSchemaFailsWithDotNotationPath(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessageMatches('/address\.zip/');
|
||||
|
||||
Schema::validate([
|
||||
'address' => ['city' => 'Berlin', 'zip' => 12345],
|
||||
], [
|
||||
'address' => ['city' => 'string', 'zip' => 'string'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testCallableRuleIsApplied(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
Schema::validate(['score' => 75], [
|
||||
'score' => fn($v) => Guard::inRange($v, 0, 100),
|
||||
]);
|
||||
}
|
||||
|
||||
public function testCallableRuleFailureIncludesPath(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessageMatches('/score:/');
|
||||
|
||||
Schema::validate(['score' => 150], [
|
||||
'score' => fn($v) => Guard::inRange($v, 0, 100),
|
||||
]);
|
||||
}
|
||||
|
||||
public function testMixedTypeAcceptsAnything(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
Schema::validate(
|
||||
['data' => ['anything', 42, true, null]],
|
||||
['data' => 'mixed'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testNestedOptionalField(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
Schema::validate([
|
||||
'address' => ['city' => 'Berlin'],
|
||||
], [
|
||||
'address' => ['city' => 'string', 'country' => '?string'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPathIsReportedCorrectlyForDeepNesting(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessageMatches('/user\.profile\.displayName/');
|
||||
|
||||
Schema::validate([
|
||||
'user' => [
|
||||
'profile' => ['displayName' => 123],
|
||||
],
|
||||
], [
|
||||
'user' => [
|
||||
'profile' => ['displayName' => 'string'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testUnknownTypeStringThrows(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Schema::validate(['x' => 'value'], ['x' => 'uuid']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Validate;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Validate\TypeCast;
|
||||
|
||||
final class TypeCastTest extends TestCase
|
||||
{
|
||||
// --- toInt ---
|
||||
|
||||
public function testToIntFromInt(): void
|
||||
{
|
||||
self::assertSame(42, TypeCast::toInt(42));
|
||||
self::assertSame(-7, TypeCast::toInt(-7));
|
||||
self::assertSame(0, TypeCast::toInt(0));
|
||||
}
|
||||
|
||||
public function testToIntFromExactFloat(): void
|
||||
{
|
||||
self::assertSame(3, TypeCast::toInt(3.0));
|
||||
self::assertSame(-5, TypeCast::toInt(-5.0));
|
||||
}
|
||||
|
||||
public function testToIntFromNumericString(): void
|
||||
{
|
||||
self::assertSame(42, TypeCast::toInt('42'));
|
||||
self::assertSame(-7, TypeCast::toInt('-7'));
|
||||
self::assertSame(0, TypeCast::toInt('0'));
|
||||
}
|
||||
|
||||
public function testToIntThrowsOnFractionalFloat(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toInt(3.7);
|
||||
}
|
||||
|
||||
public function testToIntThrowsOnNonNumericString(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toInt('42abc');
|
||||
}
|
||||
|
||||
public function testToIntThrowsOnNull(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toInt(null);
|
||||
}
|
||||
|
||||
public function testToIntThrowsOnBool(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toInt(true);
|
||||
}
|
||||
|
||||
// --- toFloat ---
|
||||
|
||||
public function testToFloatFromFloat(): void
|
||||
{
|
||||
self::assertSame(3.14, TypeCast::toFloat(3.14));
|
||||
}
|
||||
|
||||
public function testToFloatFromInt(): void
|
||||
{
|
||||
self::assertSame(42.0, TypeCast::toFloat(42));
|
||||
}
|
||||
|
||||
public function testToFloatFromNumericString(): void
|
||||
{
|
||||
self::assertSame(3.14, TypeCast::toFloat('3.14'));
|
||||
self::assertSame(42.0, TypeCast::toFloat('42'));
|
||||
}
|
||||
|
||||
public function testToFloatThrowsOnNonNumericString(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toFloat('3.14xyz');
|
||||
}
|
||||
|
||||
public function testToFloatThrowsOnNull(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toFloat(null);
|
||||
}
|
||||
|
||||
// --- toString ---
|
||||
|
||||
public function testToStringFromString(): void
|
||||
{
|
||||
self::assertSame('hello', TypeCast::toString('hello'));
|
||||
self::assertSame('', TypeCast::toString(''));
|
||||
}
|
||||
|
||||
public function testToStringFromInt(): void
|
||||
{
|
||||
self::assertSame('42', TypeCast::toString(42));
|
||||
}
|
||||
|
||||
public function testToStringFromFloat(): void
|
||||
{
|
||||
self::assertSame('3.14', TypeCast::toString(3.14));
|
||||
}
|
||||
|
||||
public function testToStringThrowsOnNull(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toString(null);
|
||||
}
|
||||
|
||||
public function testToStringThrowsOnBool(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toString(true);
|
||||
}
|
||||
|
||||
// --- toBool ---
|
||||
|
||||
public function testToBoolFromBool(): void
|
||||
{
|
||||
self::assertTrue(TypeCast::toBool(true));
|
||||
self::assertFalse(TypeCast::toBool(false));
|
||||
}
|
||||
|
||||
public function testToBoolFromInt(): void
|
||||
{
|
||||
self::assertTrue(TypeCast::toBool(1));
|
||||
self::assertFalse(TypeCast::toBool(0));
|
||||
}
|
||||
|
||||
public function testToBoolFromTruthyStrings(): void
|
||||
{
|
||||
foreach (['true', 'yes', 'on', '1', 'TRUE', 'YES'] as $v) {
|
||||
self::assertTrue(TypeCast::toBool($v), "Expected true for '{$v}'");
|
||||
}
|
||||
}
|
||||
|
||||
public function testToBoolFromFalsyStrings(): void
|
||||
{
|
||||
foreach (['false', 'no', 'off', '0', '', 'FALSE', 'NO'] as $v) {
|
||||
self::assertFalse(TypeCast::toBool($v), "Expected false for '{$v}'");
|
||||
}
|
||||
}
|
||||
|
||||
public function testToBoolThrowsOnAmbiguousString(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toBool('maybe');
|
||||
}
|
||||
|
||||
public function testToBoolThrowsOnNull(): void
|
||||
{
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
TypeCast::toBool(null);
|
||||
}
|
||||
|
||||
// --- nullable variants ---
|
||||
|
||||
public function testNullableReturnsNullOnNull(): void
|
||||
{
|
||||
self::assertNull(TypeCast::toNullableInt(null));
|
||||
self::assertNull(TypeCast::toNullableFloat(null));
|
||||
self::assertNull(TypeCast::toNullableString(null));
|
||||
self::assertNull(TypeCast::toNullableBool(null));
|
||||
}
|
||||
|
||||
public function testNullableConvertsNonNull(): void
|
||||
{
|
||||
self::assertSame(42, TypeCast::toNullableInt('42'));
|
||||
self::assertSame(3.14, TypeCast::toNullableFloat('3.14'));
|
||||
self::assertSame('hello', TypeCast::toNullableString('hello'));
|
||||
self::assertTrue(TypeCast::toNullableBool('true'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user