Inital commit after seperating from project

This commit is contained in:
René Halberstadt
2026-07-26 16:16:44 +02:00
commit 1c718b5317
21 changed files with 1920 additions and 0 deletions
+43
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.php-cs-fixer.cache
/.phpunit.result.cache
+47
View File
@@ -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);
+7
View File
@@ -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
+29
View File
@@ -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
+32
View File
@@ -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"
}
}
+14
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
parameters:
level: 9
paths:
- src
- tests
treatPhpDocTypesAsCertain: false
parallel:
maximumNumberOfProcesses: 1
+16
View File
@@ -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>
+73
View File
@@ -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;
};
}
}
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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 (v1v5, 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
View File
@@ -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)),
);
}
}
}
+154
View File
@@ -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);
}
}
+162
View File
@@ -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'));
}
}
+96
View File
@@ -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);
}
}
+280
View File
@@ -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'));
}
}
+185
View File
@@ -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'));
}
}
+148
View File
@@ -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']);
}
}
+175
View File
@@ -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'));
}
}