Files
duckbrain/src/Libs/Validator.php

421 lines
12 KiB
PHP

<?php
namespace Libs;
use Exception;
/**
* Validator - DuckBrain
*
* Complementary library to the Request library.
* Simplifies value verification.
*
* It has the ability to verify both individual rules and in batches.
*
* |----------+--------------------------------------------------------|
* | Rule | Description |
* |----------+--------------------------------------------------------|
* | not | Negates the next rule. Ex: not:float |
* | exists | Is required; must be defined and can be empty |
* | required | Is required; must be defined and not empty |
* | number | Is numeric |
* | int | Is an integer |
* | float | Is a float |
* | bool | Is a boolean |
* | email | Is an email |
* | enum | Is in a list of values. Ex: enum:admin,user,guest |
* | url | Is a valid URL |
* |----------+--------------------------------------------------------|
*
* Rule lists are separated by |, e.g., required|email
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Validator
{
/**
* Stores the last failed rule.
*
* @var string
*/
public static string $lastFailed = '';
/**
* Validates a list of rules against the properties of an object.
*
* @param array $rulesList The list of rules.
* @param Neuron $haystack The object whose properties will be validated.
*
* @return bool Returns true only if all rules are met, and false as soon as one fails.
*/
public static function validateList(array $rulesList, Neuron $haystack): bool
{
foreach ($rulesList as $target => $rules) {
$rules = preg_split('/\|/', $rules);
foreach ($rules as $rule) {
if (static::checkRule($haystack->{$target}, $rule)) {
continue;
}
static::$lastFailed = $target . '.' . $rule;
return false;
}
}
return true;
}
/**
* Splits a single rule string into its name and its raw argument string.
*
* The rule name is everything before the first ':'. The remainder is
* returned verbatim (it is NOT split further here) so that rules whose
* arguments legitimately contain ':' or ',' - such as regex or mimes -
* are preserved intact. The caller decides how to split the arguments.
*
* @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "enum:a,b".
*
* @return array A two element array: [name, rawArguments]. When the rule
* has no parameters, rawArguments is an empty string.
*/
public static function parseRule(string $rule): array
{
$separator = strpos($rule, ':');
if ($separator === false) {
return [$rule, ''];
}
return [substr($rule, 0, $separator), substr($rule, $separator + 1)];
}
/**
* Checks if a rule is met.
*
* @param mixed $subject The value to verify.
* @param string $rule The rule to test.
*
* @return bool
* @throws Exception If the rule is not callable.
*/
public static function checkRule(mixed $subject, string $rule): bool
{
[$name, $rawArguments] = static::parseRule($rule);
$method = [static::class, $name];
$arguments = array_merge([$subject], static::splitArguments($name, $rawArguments));
if (is_callable($method)) {
return call_user_func_array($method, $arguments);
}
throw new Exception('Bad rule: "' . $name . '"');
}
/**
* Splits a rule's raw argument string into the arguments to pass to the
* rule's method (the subject is not included).
*
* Most rules take a comma-separated list of values (e.g. "enum:a,b").
* A few rules receive an argument that must be kept intact because it can
* legitimately contain commas or colons: "regex" (a pattern) and "not"
* (a sub-rule that is itself parsed recursively). An empty argument string
* yields no extra arguments.
*
* @param string $name The rule name.
* @param string $raw The raw argument string as returned by parseRule().
*
* @return array The list of arguments to pass after the subject.
*/
private static function splitArguments(string $name, string $raw): array
{
if ($raw === '') {
return [];
}
$intactRules = ['regex', 'not'];
if (in_array($name, $intactRules, true)) {
return [$raw];
}
return explode(',', $raw);
}
/**
* Verifies the rule in a negative way.
*
* @param mixed $subject The value to verify.
* @param mixed $rule The rule to test.
*
* @return bool
*/
public static function not(mixed $subject, ...$rule): bool
{
return !static::checkRule($subject, join(':', $rule));
}
/**
* Checks if the value is defined/exists.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function exists(mixed $subject): bool
{
return isset($subject);
}
/**
* Checks if the value is defined and not empty.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function required(mixed $subject): bool
{
return isset($subject) && !empty($subject);
}
/**
* Checks if the value is numeric.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function number(mixed $subject): bool
{
return is_numeric($subject);
}
/**
* Checks if the value is an integer.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function int(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_INT) !== false;
}
/**
* Checks if the value is a float.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function float(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_FLOAT) !== false;
}
/**
* Checks if the value is a boolean.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function bool(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null;
}
/**
* Checks if the value is a string.
*
* Note this is the strict type check: a PHP integer (e.g. 42) or an array
* fails, while any string - including a numeric string like "42" - passes.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function string(mixed $subject): bool
{
return is_string($subject);
}
/**
* Checks if the value is an array.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function array(mixed $subject): bool
{
return is_array($subject);
}
/**
* Measures a value for the size-based rules (min/max/between/size).
*
* A numeric value is measured by its number, a plain string by its
* character length, and an array by its element count. This single helper
* keeps the size semantics consistent and is where the file case (kilobytes)
* is added later.
*
* @param mixed $subject The value to measure.
*
* @return float
*/
private static function measure(mixed $subject): float
{
if (is_numeric($subject)) {
return (float) $subject;
}
if (is_string($subject)) {
return (float) (function_exists('mb_strlen') ? mb_strlen($subject) : strlen($subject));
}
if (is_array($subject)) {
return (float) count($subject);
}
return 0.0;
}
/**
* Checks if the measured value is greater than or equal to a minimum.
*
* @param mixed $subject The value to check.
* @param mixed $min The minimum length, number or count.
*
* @return bool
*/
public static function min(mixed $subject, mixed $min): bool
{
return static::measure($subject) >= (float) $min;
}
/**
* Checks if the measured value is less than or equal to a maximum.
*
* @param mixed $subject The value to check.
* @param mixed $max The maximum length, number or count.
*
* @return bool
*/
public static function max(mixed $subject, mixed $max): bool
{
return static::measure($subject) <= (float) $max;
}
/**
* Checks if the measured value is within an inclusive range.
*
* @param mixed $subject The value to check.
* @param mixed $min The lower bound.
* @param mixed $max The upper bound.
*
* @return bool
*/
public static function between(mixed $subject, mixed $min, mixed $max): bool
{
$size = static::measure($subject);
return $size >= (float) $min && $size <= (float) $max;
}
/**
* Checks if the measured value is exactly a given size.
*
* @param mixed $subject The value to check.
* @param mixed $size The expected length, number or count.
*
* @return bool
*/
public static function size(mixed $subject, mixed $size): bool
{
return static::measure($subject) === (float) $size;
}
/**
* Checks if the value matches a regular expression.
*
* The pattern is received verbatim (delimiters included), thanks to
* splitArguments() treating "regex" as an intact argument, so patterns
* that contain ':' or ',' - such as "/^a,b$/" - work as expected.
*
* @param mixed $subject The value to check.
* @param string $pattern A PCRE pattern including its delimiters.
*
* @return bool
*/
public static function regex(mixed $subject, mixed $pattern): bool
{
return preg_match((string) $pattern, (string) $subject) === 1;
}
/**
* Checks if the value is a parseable date.
*
* Any format accepted by DateTime is valid (e.g. "2026-09-03", an ISO
* datetime, or a timestamp string). Empty and non-scalar values fail.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function date(mixed $subject): bool
{
if (!is_scalar($subject) || trim((string) $subject) === '') {
return false;
}
try {
new \DateTime((string) $subject);
return true;
} catch (Exception $e) {
return false;
}
}
/**
* Checks if the value is a valid email address.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function email(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* Checks if the value is a valid URL.
*
* @param mixed $subject The value to check.
*
* @return bool
*/
public static function url(mixed $subject): bool
{
return filter_var($subject, FILTER_VALIDATE_URL) !== false;
}
/**
* Checks if the value is present in a list of allowed values.
*
* @param mixed $subject The value to check.
* @param mixed ...$values A variable number of allowed values.
*
* @return bool
*/
public static function enum(mixed $subject, ...$values): bool
{
return in_array($subject, $values);
}
}