Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions Tests/Attributes/ValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Nejcc\PhpDatatypes\Tests\Attributes;

use Nejcc\PhpDatatypes\Attributes\Email;
use Nejcc\PhpDatatypes\Attributes\Length;
use Nejcc\PhpDatatypes\Attributes\NotNull;
use Nejcc\PhpDatatypes\Attributes\Range;
use Nejcc\PhpDatatypes\Attributes\Validator;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;

final class ValidatorTest extends TestCase
{
protected function setUp(): void
{
Validator::clearCache();
}

public function testValidatesRange(): void
{
$prop = new ReflectionProperty(ValidatorFixture::class, 'age');
Validator::validateProperty(30, $prop);
$this->expectException(\OutOfRangeException::class);
Validator::validateProperty(200, $prop);
}

public function testValidatesEmail(): void
{
$prop = new ReflectionProperty(ValidatorFixture::class, 'email');
Validator::validateProperty('user@example.com', $prop);
$this->expectException(\InvalidArgumentException::class);
Validator::validateProperty('not-an-email', $prop);
}

public function testValidatesLength(): void
{
$prop = new ReflectionProperty(ValidatorFixture::class, 'name');
Validator::validateProperty('Nejc', $prop);
$this->expectException(\InvalidArgumentException::class);
Validator::validateProperty('x', $prop);
}

public function testValidatesNotNull(): void
{
$prop = new ReflectionProperty(ValidatorFixture::class, 'name');
$this->expectException(\InvalidArgumentException::class);
Validator::validateProperty(null, $prop);
}

public function testCacheReusedAcrossCalls(): void
{
// First call populates the cache.
$prop1 = new ReflectionProperty(ValidatorFixture::class, 'age');
Validator::validateProperty(30, $prop1);

// A freshly constructed ReflectionProperty for the same field should
// hit the cache (key is Class::property, not the reflection object).
$prop2 = new ReflectionProperty(ValidatorFixture::class, 'age');
Validator::validateProperty(25, $prop2);

// Sanity: clearing resets state without errors.
Validator::clearCache();
Validator::validateProperty(40, $prop1);
$this->assertTrue(true);
}
}

final class ValidatorFixture
{
#[Range(min: 0, max: 150)]
public int $age = 0;

#[Email]
#[NotNull]
public ?string $email = null;

#[NotNull]
#[Length(min: 2, max: 50)]
public ?string $name = null;
}
87 changes: 50 additions & 37 deletions src/Abstract/AbstractVector.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ abstract class AbstractVector implements DataTypeInterface
{
protected array $components;

public function __construct(array $components)
/**
* @param array $components
* @param bool $trusted Internal use only. When true, skips component validation.
* Used by arithmetic ops whose result is known to be valid
* (same dimension, all numeric) by construction.
*/
public function __construct(array $components, bool $trusted = false)
{
$this->validateComponents($components);
if (!$trusted) {
$this->validateComponents($components);
}
$this->components = $components;
}

Expand All @@ -29,7 +37,11 @@ public function getComponents(): array

public function magnitude(): float
{
return sqrt(array_sum(array_map(fn ($component) => $component ** 2, $this->components)));
$sum = 0.0;
foreach ($this->components as $c) {
$sum += $c * $c;
}
return sqrt($sum);
}

public function normalize(): self
Expand All @@ -39,8 +51,11 @@ public function normalize(): self
throw new InvalidArgumentException("Cannot normalize a zero vector");
}

$normalized = array_map(fn ($component) => $component / $magnitude, $this->components);
return new static($normalized);
$result = [];
foreach ($this->components as $i => $c) {
$result[$i] = $c / $magnitude;
}
return new static($result, true);
}

public function dot(self $other): float
Expand All @@ -49,11 +64,12 @@ public function dot(self $other): float
throw new InvalidArgumentException("Cannot calculate dot product of vectors with different dimensions");
}

return array_sum(array_map(
fn ($a, $b) => $a * $b,
$this->components,
$other->components
));
$sum = 0.0;
$b = $other->components;
foreach ($this->components as $i => $a) {
$sum += $a * $b[$i];
}
return $sum;
}

public function add(self $other): self
Expand All @@ -62,13 +78,12 @@ public function add(self $other): self
throw new InvalidArgumentException("Cannot add vectors with different dimensions");
}

$result = array_map(
fn ($a, $b) => $a + $b,
$this->components,
$other->components
);

return new static($result);
$result = [];
$b = $other->components;
foreach ($this->components as $i => $a) {
$result[$i] = $a + $b[$i];
}
return new static($result, true);
}

public function subtract(self $other): self
Expand All @@ -77,23 +92,21 @@ public function subtract(self $other): self
throw new InvalidArgumentException("Cannot subtract vectors with different dimensions");
}

$result = array_map(
fn ($a, $b) => $a - $b,
$this->components,
$other->components
);

return new static($result);
$result = [];
$b = $other->components;
foreach ($this->components as $i => $a) {
$result[$i] = $a - $b[$i];
}
return new static($result, true);
}

public function scale(float $scalar): self
{
$result = array_map(
fn ($component) => $component * $scalar,
$this->components
);

return new static($result);
$result = [];
foreach ($this->components as $i => $c) {
$result[$i] = $c * $scalar;
}
return new static($result, true);
}

public function getComponent(int $index): float
Expand All @@ -119,13 +132,13 @@ public function distance(self $other): float
throw new InvalidArgumentException("Cannot calculate distance between vectors with different dimensions");
}

$squaredDiff = array_map(
fn ($a, $b) => ($a - $b) ** 2,
$this->components,
$other->components
);

return sqrt(array_sum($squaredDiff));
$sum = 0.0;
$b = $other->components;
foreach ($this->components as $i => $a) {
$diff = $a - $b[$i];
$sum += $diff * $diff;
}
return sqrt($sum);
}

abstract protected function validateComponents(array $components): void;
Expand Down
40 changes: 37 additions & 3 deletions src/Attributes/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,27 @@
final class Validator
{
/**
* Validate a property value against its attributes
* Cache of parsed attribute instances keyed by "Class::property".
*
* Reflection-driven attribute parsing is the dominant cost on this hot
* path (newInstance() per attribute, per call). Properties don't change
* structure at runtime, so we instantiate once and reuse.
*
* @var array<string, list<object>>
*/
private static array $cache = [];

/**
* Validate a property value against its attributes.
*/
public static function validateProperty(
mixed $value,
ReflectionProperty $property
): void {
foreach ($property->getAttributes() as $attribute) {
$instance = $attribute->newInstance();
$key = $property->class . '::' . $property->name;
$instances = self::$cache[$key] ??= self::compileAttributes($property);

foreach ($instances as $instance) {
match (true) {
$instance instanceof Range => self::validateRange($value, $instance),
$instance instanceof Email => self::validateEmail($value),
Expand All @@ -35,6 +48,27 @@ public static function validateProperty(
}
}

/**
* @return list<object>
*/
private static function compileAttributes(ReflectionProperty $property): array
{
$instances = [];
foreach ($property->getAttributes() as $attribute) {
$instances[] = $attribute->newInstance();
}
return $instances;
}

/**
* Clear the attribute cache. Useful for long-running processes that
* reload classes (rare) or for tests that want a clean slate.
*/
public static function clearCache(): void
{
self::$cache = [];
}

private static function validateRange(mixed $value, Range $range): void
{
if (!is_numeric($value)) {
Expand Down
58 changes: 38 additions & 20 deletions src/Composite/Struct/AdvancedStruct.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,40 @@
protected array $data = [];
protected array $schema = [];

public function __construct(array $schema, array $values = [])

Check failure on line 15 in src/Composite/Struct/AdvancedStruct.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 30 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Nejcc_php-datatypes&issues=AZ5yWX1IehU7qPRFc9-p&open=AZ5yWX1IehU7qPRFc9-p&pullRequest=19
{
$this->schema = $schema;
foreach ($schema as $field => $def) {
$alias = $def['alias'] ?? $field;
$type = $def['type'] ?? 'mixed';
$nullable = $def['nullable'] ?? false;
$default = $def['default'] ?? null;
$rules = $def['rules'] ?? [];
$value = $values[$field] ?? $values[$alias] ?? $default;
if ($value === null && !$nullable && $default === null && !array_key_exists($field, $values)) {
throw new InvalidArgumentException("Field '$field' is required and has no value");

// Resolve value: explicit field, then alias, then default.
if (isset($values[$field])) {
$value = $values[$field];
} elseif (array_key_exists($field, $values)) {
$value = null;
} elseif (isset($def['alias']) && array_key_exists($def['alias'], $values)) {
$value = $values[$def['alias']];
} else {
$value = $default;
if ($value === null && !$nullable && $default === null) {
throw new InvalidArgumentException("Field '$field' is required and has no value");
}
}

if ($value !== null) {
$this->validateField($field, $value, $type, $rules, $nullable);
if ($type !== 'mixed' && !self::isValidType($value, $type)) {
throw new InvalidArgumentException("Field '$field' must be of type $type");
}
if ($rules !== []) {
foreach ($rules as $rule) {
if (is_callable($rule) && !$rule($value)) {
throw new ValidationException("Validation failed for field '$field'");
}
}
}
}
$this->data[$field] = $value;
}
Expand All @@ -37,30 +56,29 @@
if ($value === null && $nullable) {
return;
}
// Type check
if ($type !== 'mixed' && !$this->isValidType($value, $type)) {
if ($type !== 'mixed' && !self::isValidType($value, $type)) {
throw new InvalidArgumentException("Field '$field' must be of type $type");
}
// Rules
foreach ($rules as $rule) {
if (is_callable($rule)) {
if (!$rule($value)) {
if ($rules !== []) {
foreach ($rules as $rule) {
if (is_callable($rule) && !$rule($value)) {
throw new ValidationException("Validation failed for field '$field'");
}
}
}
}

protected function isValidType($value, $type): bool
protected static function isValidType(mixed $value, string $type): bool
{
if ($type === 'int' || $type === 'integer') return is_int($value);
if ($type === 'float' || $type === 'double') return is_float($value);
if ($type === 'string') return is_string($value);
if ($type === 'bool' || $type === 'boolean') return is_bool($value);
if ($type === 'array') return is_array($value);
if ($type === 'object') return is_object($value);
if (class_exists($type)) return $value instanceof $type;
return true;
return match ($type) {
'int', 'integer' => is_int($value),
'float', 'double' => is_float($value),
'string' => is_string($value),
'bool', 'boolean' => is_bool($value),
'array' => is_array($value),
'object' => is_object($value),
default => class_exists($type) ? $value instanceof $type : true,
};
}

public function get(string $field)
Expand Down
Loading
Loading