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
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
}
},
"scripts": {
"test": "vendor/bin/pest --configuration=phpunit.xml --coverage-clover=coverage.xml --log-junit=test.xml",
"test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage-html=coverage",
"test": "vendor/bin/pest --configuration=phpunit.xml --coverage --coverage-clover=coverage.xml --log-junit=test.xml",
"test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage --coverage-html=coverage",
"analyse": "vendor/bin/phpstan analyse src --no-progress --memory-limit=4G --level=8",
"check": [
"@analyse",
Expand Down
83 changes: 46 additions & 37 deletions src/IsModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
use ReflectionClass;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionUnionType;
use RuntimeException;
use ComplexHeart\Domain\Model\Exceptions\InstantiationException;
use TypeError;

/**
Expand Down Expand Up @@ -51,7 +52,7 @@ final public static function make(mixed ...$params): static
$constructor = $reflection->getConstructor();

if (!$constructor) {
throw new RuntimeException(
throw new InstantiationException(
sprintf('%s must have a constructor to use make()', static::class)
);
}
Expand Down Expand Up @@ -121,46 +122,54 @@ private static function validateConstructorParameters(

// Validate types for each parameter
foreach ($constructorParams as $index => $param) {
if (!isset($params[$index])) {
continue; // Optional parameter not provided
if (isset($params[$index])) {
self::validateParameterType($param, $params[$index]);
}
}
}

$value = $params[$index];
$type = $param->getType();

if ($type === null) {
continue; // No type hint
}
/**
* Validate a single parameter's type.
*
* @param ReflectionParameter $param
* @param mixed $value
* @return void
* @throws TypeError
*/
private static function validateParameterType(ReflectionParameter $param, mixed $value): void
{
$type = $param->getType();

$isValid = false;
$expectedTypes = '';

if ($type instanceof ReflectionNamedType) {
// Single type
$isValid = self::validateType($value, $type->getName(), $type->allowsNull());
$expectedTypes = $type->getName();
} elseif ($type instanceof ReflectionUnionType) {
// Union type (e.g., int|float|string)
$isValid = self::validateUnionType($value, $type);
$expectedTypes = implode('|', array_map(
fn ($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
$type->getTypes()
));
} else {
continue; // Intersection types or other complex types not supported yet
}
if ($type === null) {
return; // No type hint
}

if (!$isValid) {
throw new TypeError(
sprintf(
'%s::make() parameter "%s" must be of type %s, %s given',
basename(str_replace('\\', '/', static::class)),
$param->getName(),
$expectedTypes,
get_debug_type($value)
)
);
}
if ($type instanceof ReflectionNamedType) {
// Single type
$isValid = self::validateType($value, $type->getName(), $type->allowsNull());
$expectedTypes = $type->getName();
} elseif ($type instanceof ReflectionUnionType) {
// Union type (e.g., int|float|string)
$isValid = self::validateUnionType($value, $type);
$expectedTypes = implode('|', array_map(
fn ($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
$type->getTypes()
));
} else {
return; // Intersection types or other complex types not supported yet
}

if (!$isValid) {
throw new TypeError(
sprintf(
'%s::make() parameter "%s" must be of type %s, %s given',
basename(str_replace('\\', '/', static::class)),
$param->getName(),
$expectedTypes,
get_debug_type($value)
)
);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Traits/HasImmutability.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ trait HasImmutability
* @param mixed $_
* @return void
*/
final public function __set(string $name, $_): void
final public function __set(string $name, mixed $_): void
{
$class = static::class;
throw new ImmutabilityError("Cannot modify property $name from immutable $class object.");
Expand Down
12 changes: 11 additions & 1 deletion src/Traits/HasTypeCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,17 @@ protected function isValueTypeValid(mixed $value, string $validType): bool
return true;
}

$primitives = ['integer', 'boolean', 'float', 'string', 'array', 'object', 'callable'];
// Handle float/double equivalence (PHP uses 'double' internally)
if ($validType === 'float') {
$validType = 'double';
}

// Handle callable type check
if ($validType === 'callable') {
return is_callable($value);
}

$primitives = ['integer', 'boolean', 'double', 'string', 'array', 'object'];
$validation = in_array($validType, $primitives)
? fn ($value): bool => gettype($value) === $validType
: fn ($value): bool => $value instanceof $validType;
Expand Down
6 changes: 3 additions & 3 deletions tests/AggregatesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
$order2 = Order::create(1, ['id' => UUIDValue::random(), 'name' => 'Vincent Vega']);
$order3 = Order::create(2, ['id' => UUIDValue::random(), 'name' => 'Vincent Vega']);

expect($order1->equals($order2))->toBeTrue();
expect($order1->equals($order3))->toBeFalse();
expect($order1->equals(new stdClass()))->toBeFalse();
expect($order1->equals($order2))->toBeTrue()
->and($order1->equals($order3))->toBeFalse()
->and($order1->equals(new stdClass()))->toBeFalse();
})
->group('Unit');

Expand Down
43 changes: 0 additions & 43 deletions tests/AutoCheckInvariantsTest.php

This file was deleted.

146 changes: 146 additions & 0 deletions tests/HasEqualityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

declare(strict_types=1);

use ComplexHeart\Domain\Model\Traits\HasEquality;

test('HasEquality should compare objects by hash', function () {
$class = get_class(new class ('dummy') {
use HasEquality;

public function __construct(private string $value)
{
}

public function __toString(): string
{
return $this->value;
}
});

$obj1 = new $class('test');
$obj2 = new $class('test');

expect($obj1->equals($obj2))->toBeTrue();
})
->group('Unit');

test('HasEquality should return false for different string representations', function () {
$obj1 = new class ('test1') {
use HasEquality;

public function __construct(private string $value)
{
}

public function __toString(): string
{
return $this->value;
}
};

$obj2 = new class ('test2') {
use HasEquality;

public function __construct(private string $value)
{
}

public function __toString(): string
{
return $this->value;
}
};

expect($obj1->equals($obj2))->toBeFalse();
})
->group('Unit');

test('HasEquality should return false for different class types', function () {
$obj1 = new class ('test') {
use HasEquality;

public function __construct(private string $value)
{
}

public function __toString(): string
{
return $this->value;
}
};

$obj2 = new stdClass();

expect($obj1->equals($obj2))->toBeFalse();
})
->group('Unit');

test('HasEquality::hash should generate consistent SHA256 hash', function () {
$obj = new class ('test-value') {
use HasEquality;

public function __construct(private string $value)
{
}

public function __toString(): string
{
return $this->value;
}

public function getHash(): string
{
return $this->hash();
}
};

$expectedHash = hash('sha256', 'test-value');

expect($obj->getHash())->toBe($expectedHash);
})
->group('Unit');

test('HasEquality should handle complex objects', function () {
$class = get_class(new class (['dummy' => 'data']) {
use HasEquality;

public function __construct(private array $data)
{
}

public function __toString(): string
{
return json_encode($this->data);
}
});

$obj1 = new $class(['name' => 'John', 'age' => 30]);
$obj2 = new $class(['name' => 'John', 'age' => 30]);
$obj3 = new $class(['name' => 'Jane', 'age' => 25]);

expect($obj1->equals($obj2))->toBeTrue()
->and($obj1->equals($obj3))->toBeFalse();
})
->group('Unit');

test('HasEquality should handle empty strings', function () {
$class = get_class(new class ('dummy') {
use HasEquality;

public function __construct(private string $value)
{
}

public function __toString(): string
{
return $this->value;
}
});

$obj1 = new $class('');
$obj2 = new $class('');

expect($obj1->equals($obj2))->toBeTrue();
})
->group('Unit');
29 changes: 29 additions & 0 deletions tests/HasImmutabilityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

use ComplexHeart\Domain\Model\Errors\ImmutabilityError;
use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Price;

test('Object with HasImmutability should throw ImmutabilityError for any update properties attempts.', function () {
$price = new Price(100.0, 'EUR');
$price->amount = 0.0;
})
->group('Unit')
->throws(ImmutabilityError::class);

test('Object with HasImmutability should expose primitive values.', function () {
$price = new Price(100.0, 'EUR');
expect($price->amount)->toBeFloat()
->and($price->currency)->toBeString();
})
->group('Unit');

test('Object with HasImmutability should return new instance with override values.', function () {
$price = new Price(100.0, 'EUR');
$newPrice = $price->applyDiscount(10.0);

expect($newPrice)->toBeInstanceOf(Price::class)
->and($newPrice->amount)->toBe(90.0);
})
->group('Unit');
Loading