From 29978d787d9c8a6d40637a361296f6f1889bf3eb Mon Sep 17 00:00:00 2001 From: nejc Date: Fri, 30 May 2025 11:47:04 +0200 Subject: [PATCH 01/10] Add missing exception classes: TypeMismatchException, ImmutableException, ValidationException --- src/Exceptions/ImmutableException.php | 5 +++++ src/Exceptions/TypeMismatchException.php | 5 +++++ src/Exceptions/ValidationException.php | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 src/Exceptions/ImmutableException.php create mode 100644 src/Exceptions/TypeMismatchException.php create mode 100644 src/Exceptions/ValidationException.php diff --git a/src/Exceptions/ImmutableException.php b/src/Exceptions/ImmutableException.php new file mode 100644 index 0000000..acbb13d --- /dev/null +++ b/src/Exceptions/ImmutableException.php @@ -0,0 +1,5 @@ + Date: Fri, 30 May 2025 11:47:16 +0200 Subject: [PATCH 02/10] Implement DataTypeInterface methods in TypeSafeArray and fix setValue signature --- src/Composite/Arrays/TypeSafeArray.php | 268 +++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 src/Composite/Arrays/TypeSafeArray.php diff --git a/src/Composite/Arrays/TypeSafeArray.php b/src/Composite/Arrays/TypeSafeArray.php new file mode 100644 index 0000000..b0c0b88 --- /dev/null +++ b/src/Composite/Arrays/TypeSafeArray.php @@ -0,0 +1,268 @@ +elementType = $elementType; + $this->data = []; + + if (!empty($initialData)) { + $this->validateArray($initialData); + $this->data = $initialData; + } + } + + /** + * Validate that all elements in an array match the required type + * + * @param array $data The array to validate + * @throws TypeMismatchException If any element doesn't match the required type + */ + private function validateArray(array $data): void + { + foreach ($data as $key => $value) { + if (!$this->isValidType($value)) { + throw new TypeMismatchException( + "Element at key '{$key}' must be of type {$this->elementType}" + ); + } + } + } + + /** + * Check if a value matches the required type + * + * @param mixed $value The value to check + * @return bool True if the value matches the required type + */ + private function isValidType($value): bool + { + return $value instanceof $this->elementType; + } + + /** + * Get the type of elements this array accepts + * + * @return string The element type + */ + public function getElementType(): string + { + return $this->elementType; + } + + /** + * Get all elements in the array + * + * @return array The array elements + */ + public function toArray(): array + { + return $this->data; + } + + /** + * ArrayAccess implementation + */ + public function offsetExists($offset): bool + { + return isset($this->data[$offset]); + } + + public function offsetGet($offset): mixed + { + return $this->data[$offset] ?? null; + } + + public function offsetSet($offset, $value): void + { + if (!$this->isValidType($value)) { + throw new TypeMismatchException( + "Value must be of type {$this->elementType}" + ); + } + + if (is_null($offset)) { + $this->data[] = $value; + } else { + $this->data[$offset] = $value; + } + } + + public function offsetUnset($offset): void + { + unset($this->data[$offset]); + } + + /** + * Countable implementation + */ + public function count(): int + { + return count($this->data); + } + + /** + * Iterator implementation + */ + public function current(): mixed + { + return $this->data[$this->position]; + } + + public function key(): mixed + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->data[$this->position]); + } + + /** + * Map operation - apply a callback to each element + * + * @param callable $callback The callback to apply + * @return TypeSafeArray A new array with the mapped values + * @throws TypeMismatchException If the callback returns invalid types + */ + public function map(callable $callback): self + { + $result = new self($this->elementType); + foreach ($this->data as $key => $value) { + $result[$key] = $callback($value, $key); + } + return $result; + } + + /** + * Filter operation - filter elements based on a callback + * + * @param callable $callback The callback to use for filtering + * @return TypeSafeArray A new array with the filtered values + */ + public function filter(callable $callback): self + { + $result = new self($this->elementType); + foreach ($this->data as $key => $value) { + if ($callback($value, $key)) { + $result[$key] = $value; + } + } + return $result; + } + + /** + * Reduce operation - reduce the array to a single value + * + * @param callable $callback The callback to use for reduction + * @param mixed $initial The initial value + * @return mixed The reduced value + */ + public function reduce(callable $callback, $initial = null) + { + return array_reduce($this->data, $callback, $initial); + } + + /** + * String representation of the array + * + * @return string + */ + public function __toString(): string + { + return json_encode($this->data); + } + + /** + * Get the array value + * + * @return array The array data + */ + public function getValue(): array + { + return $this->data; + } + + /** + * Set the array value + * + * @param mixed $value The new array data + * @throws TypeMismatchException If any element doesn't match the required type + */ + public function setValue(mixed $value): void + { + if (!is_array($value)) { + throw new TypeMismatchException('Value must be an array.'); + } + $this->validateArray($value); + $this->data = $value; + } + + /** + * Check if this array equals another array + * + * @param DataTypeInterface $other The other array to compare with + * @return bool True if the arrays are equal + */ + public function equals(DataTypeInterface $other): bool + { + if (!$other instanceof self) { + return false; + } + + if ($this->elementType !== $other->elementType) { + return false; + } + + return $this->data === $other->data; + } +} \ No newline at end of file From 00d3fcb6e7e8e130eb354764353433c88ea7d0ce Mon Sep 17 00:00:00 2001 From: nejc Date: Fri, 30 May 2025 11:47:38 +0200 Subject: [PATCH 03/10] Fix testIsFieldRequired by providing a default value for required field --- .../Composite/Struct/ImmutableStructTest.php | 678 ++++++++++++++++++ 1 file changed, 678 insertions(+) create mode 100644 Tests/Composite/Struct/ImmutableStructTest.php diff --git a/Tests/Composite/Struct/ImmutableStructTest.php b/Tests/Composite/Struct/ImmutableStructTest.php new file mode 100644 index 0000000..773b0ad --- /dev/null +++ b/Tests/Composite/Struct/ImmutableStructTest.php @@ -0,0 +1,678 @@ + ['type' => 'string'], + 'age' => ['type' => 'int'] + ]); + + $this->assertNull($struct->get('name')); + $this->assertNull($struct->get('age')); + } + + public function testStructWithInitialValues(): void + { + $struct = new ImmutableStruct( + [ + 'name' => ['type' => 'string'], + 'age' => ['type' => 'int'] + ], + [ + 'name' => 'John', + 'age' => 30 + ] + ); + + $this->assertEquals('John', $struct->get('name')); + $this->assertEquals(30, $struct->get('age')); + } + + public function testRequiredFields(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Required field 'name' has no value"); + + new ImmutableStruct( + [ + 'name' => ['type' => 'string', 'required' => true], + 'age' => ['type' => 'int'] + ] + ); + } + + public function testDefaultValues(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => 'string', 'default' => 'Unknown'], + 'age' => ['type' => 'int', 'default' => 0] + ]); + + $this->assertEquals('Unknown', $struct->get('name')); + $this->assertEquals(0, $struct->get('age')); + } + + public function testInvalidFieldAccess(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => 'string'] + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Field 'age' does not exist in the struct"); + + $struct->get('age'); + } + + public function testImmutableModification(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => 'string'] + ]); + + $this->expectException(ImmutableException::class); + $this->expectExceptionMessage("Cannot modify a frozen struct"); + + $struct->set('name', 'John'); + } + + public function testWithMethod(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => 'string'], + 'age' => ['type' => 'int'] + ]); + + $newStruct = $struct->with(['name' => 'John', 'age' => 30]); + + $this->assertNull($struct->get('name')); + $this->assertNull($struct->get('age')); + $this->assertEquals('John', $newStruct->get('name')); + $this->assertEquals(30, $newStruct->get('age')); + } + + public function testWithFieldMethod(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => 'string'], + 'age' => ['type' => 'int'] + ]); + + $newStruct = $struct->withField('name', 'John'); + + $this->assertNull($struct->get('name')); + $this->assertEquals('John', $newStruct->get('name')); + } + + public function testNestedStructs(): void + { + $address = new ImmutableStruct([ + 'street' => ['type' => 'string'], + 'city' => ['type' => 'string'] + ], [ + 'street' => '123 Main St', + 'city' => 'Boston' + ]); + + $person = new ImmutableStruct([ + 'name' => ['type' => 'string'], + 'address' => ['type' => ImmutableStruct::class] + ], [ + 'name' => 'John', + 'address' => $address + ]); + + $this->assertEquals('John', $person->get('name')); + $this->assertInstanceOf(ImmutableStruct::class, $person->get('address')); + $this->assertEquals('123 Main St', $person->get('address')->get('street')); + $this->assertEquals('Boston', $person->get('address')->get('city')); + } + + public function testNullableFields(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => '?string'], + 'age' => ['type' => '?int'] + ]); + + $this->assertNull($struct->get('name')); + $this->assertNull($struct->get('age')); + + $newStruct = $struct->with([ + 'name' => null, + 'age' => null + ]); + + $this->assertNull($newStruct->get('name')); + $this->assertNull($newStruct->get('age')); + } + + public function testToArray(): void + { + $address = new ImmutableStruct([ + 'street' => ['type' => 'string'], + 'city' => ['type' => 'string'] + ], [ + 'street' => '123 Main St', + 'city' => 'Boston' + ]); + + $person = new ImmutableStruct([ + 'name' => ['type' => 'string'], + 'age' => ['type' => 'int'], + 'address' => ['type' => ImmutableStruct::class] + ], [ + 'name' => 'John', + 'age' => 30, + 'address' => $address + ]); + + $expected = [ + 'name' => 'John', + 'age' => 30, + 'address' => [ + 'street' => '123 Main St', + 'city' => 'Boston' + ] + ]; + + $this->assertEquals($expected, $person->toArray()); + } + + public function testToString(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => 'string'], + 'age' => ['type' => 'int'] + ], [ + 'name' => 'John', + 'age' => 30 + ]); + + $expected = json_encode([ + 'name' => 'John', + 'age' => 30 + ]); + + $this->assertEquals($expected, (string)$struct); + } + + public function testGetFieldType(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => 'string'], + 'age' => ['type' => 'int'] + ]); + + $this->assertEquals('string', $struct->getFieldType('name')); + $this->assertEquals('int', $struct->getFieldType('age')); + + $this->expectException(InvalidArgumentException::class); + $struct->getFieldType('invalid'); + } + + public function testIsFieldRequired(): void + { + $struct = new ImmutableStruct([ + 'name' => ['type' => 'string', 'required' => true, 'default' => 'John'], + 'age' => ['type' => 'int', 'required' => false] + ]); + + $this->assertTrue($struct->isFieldRequired('name')); + $this->assertFalse($struct->isFieldRequired('age')); + + $this->expectException(InvalidArgumentException::class); + $struct->isFieldRequired('invalid'); + } + + public function testGetFieldRules(): void + { + $struct = new ImmutableStruct([ + 'name' => [ + 'type' => 'string', + 'rules' => [ + new MinLengthRule(3) + ] + ] + ]); + + $rules = $struct->getFieldRules('name'); + $this->assertCount(1, $rules); + $this->assertInstanceOf(MinLengthRule::class, $rules[0]); + + $this->expectException(InvalidArgumentException::class); + $struct->getFieldRules('invalid'); + } + + public function testMinLengthRule(): void + { + $struct = new ImmutableStruct([ + 'name' => [ + 'type' => 'string', + 'rules' => [ + new MinLengthRule(3) + ] + ] + ]); + + // Valid value + $newStruct = $struct->with(['name' => 'John']); + $this->assertEquals('John', $newStruct->get('name')); + + // Invalid value + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'name' must be at least 3 characters long"); + $struct->with(['name' => 'Jo']); + } + + public function testRangeRule(): void + { + $struct = new ImmutableStruct([ + 'age' => [ + 'type' => 'int', + 'rules' => [ + new RangeRule(0, 120) + ] + ] + ]); + + // Valid value + $newStruct = $struct->with(['age' => 30]); + $this->assertEquals(30, $newStruct->get('age')); + + // Invalid value - too low + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'age' must be between 0 and 120"); + $struct->with(['age' => -1]); + + // Invalid value - too high + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'age' must be between 0 and 120"); + $struct->with(['age' => 121]); + } + + public function testMultipleRules(): void + { + $struct = new ImmutableStruct([ + 'name' => [ + 'type' => 'string', + 'rules' => [ + new MinLengthRule(3), + new MinLengthRule(5) + ] + ] + ]); + + // Valid value + $newStruct = $struct->with(['name' => 'Johnny']); + $this->assertEquals('Johnny', $newStruct->get('name')); + + // Invalid value - fails first rule + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'name' must be at least 3 characters long"); + $struct->with(['name' => 'Jo']); + + // Invalid value - fails second rule + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'name' must be at least 5 characters long"); + $struct->with(['name' => 'John']); + } + + public function testPatternRule(): void + { + $struct = new ImmutableStruct([ + 'username' => [ + 'type' => 'string', + 'rules' => [ + new PatternRule('/^[a-zA-Z0-9_]{3,20}$/') + ] + ] + ]); + + // Valid value + $newStruct = $struct->with(['username' => 'john_doe123']); + $this->assertEquals('john_doe123', $newStruct->get('username')); + + // Invalid value - contains invalid characters + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'username' does not match the required pattern"); + $struct->with(['username' => 'john@doe']); + } + + public function testEmailRule(): void + { + $struct = new ImmutableStruct([ + 'email' => [ + 'type' => 'string', + 'rules' => [ + new EmailRule() + ] + ] + ]); + + // Valid value + $newStruct = $struct->with(['email' => 'john.doe@example.com']); + $this->assertEquals('john.doe@example.com', $newStruct->get('email')); + + // Invalid value - not an email + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'email' must be a valid email address"); + $struct->with(['email' => 'not-an-email']); + } + + public function testCustomRule(): void + { + $struct = new ImmutableStruct([ + 'password' => [ + 'type' => 'string', + 'rules' => [ + new CustomRule( + fn($value) => strlen($value) >= 8 && preg_match('/[A-Z]/', $value) && preg_match('/[a-z]/', $value) && preg_match('/[0-9]/', $value), + 'must be at least 8 characters long and contain uppercase, lowercase, and numbers' + ) + ] + ] + ]); + + // Valid value + $newStruct = $struct->with(['password' => 'Password123']); + $this->assertEquals('Password123', $newStruct->get('password')); + + // Invalid value - too short + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password': must be at least 8 characters long and contain uppercase, lowercase, and numbers"); + $struct->with(['password' => 'pass']); + + // Invalid value - missing uppercase + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password': must be at least 8 characters long and contain uppercase, lowercase, and numbers"); + $struct->with(['password' => 'password123']); + + // Invalid value - missing numbers + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password': must be at least 8 characters long and contain uppercase, lowercase, and numbers"); + $struct->with(['password' => 'Password']); + } + + public function testCombinedRules(): void + { + $struct = new ImmutableStruct([ + 'username' => [ + 'type' => 'string', + 'rules' => [ + new MinLengthRule(3), + new PatternRule('/^[a-zA-Z0-9_]+$/') + ] + ], + 'email' => [ + 'type' => 'string', + 'rules' => [ + new EmailRule(), + new CustomRule( + fn($value) => str_ends_with($value, '.com'), + 'must be a .com email address' + ) + ] + ] + ]); + + // Valid values + $newStruct = $struct->with([ + 'username' => 'john_doe', + 'email' => 'john.doe@example.com' + ]); + $this->assertEquals('john_doe', $newStruct->get('username')); + $this->assertEquals('john.doe@example.com', $newStruct->get('email')); + + // Invalid username - too short + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'username' must be at least 3 characters long"); + $struct->with(['username' => 'jo']); + + // Invalid username - invalid characters + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'username' does not match the required pattern"); + $struct->with(['username' => 'john@doe']); + + // Invalid email - not .com + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'email': must be a .com email address"); + $struct->with(['email' => 'john.doe@example.org']); + } + + public function testUrlRule(): void + { + $struct = new ImmutableStruct([ + 'website' => [ + 'type' => 'string', + 'rules' => [ + new UrlRule() + ] + ], + 'secureWebsite' => [ + 'type' => 'string', + 'rules' => [ + new UrlRule(true) + ] + ] + ]); + + // Valid URLs + $newStruct = $struct->with([ + 'website' => 'http://example.com', + 'secureWebsite' => 'https://example.com' + ]); + $this->assertEquals('http://example.com', $newStruct->get('website')); + $this->assertEquals('https://example.com', $newStruct->get('secureWebsite')); + + // Invalid URL + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'website' must be a valid URL"); + $struct->with(['website' => 'not-a-url']); + + // Non-HTTPS URL for secure field + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'secureWebsite' must be a secure HTTPS URL"); + $struct->with(['secureWebsite' => 'http://example.com']); + } + + public function testSlugRule(): void + { + $struct = new ImmutableStruct([ + 'slug' => [ + 'type' => 'string', + 'rules' => [ + new SlugRule(3, 50, true) + ] + ], + 'strictSlug' => [ + 'type' => 'string', + 'rules' => [ + new SlugRule(3, 50, false) + ] + ] + ]); + + // Valid slugs + $newStruct = $struct->with([ + 'slug' => 'my-awesome-post_123', + 'strictSlug' => 'my-awesome-post' + ]); + $this->assertEquals('my-awesome-post_123', $newStruct->get('slug')); + $this->assertEquals('my-awesome-post', $newStruct->get('strictSlug')); + + // Too short + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'slug' must be at least 3 characters long"); + $struct->with(['slug' => 'ab']); + + // Too long + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'slug' must not exceed 50 characters"); + $struct->with(['slug' => str_repeat('a', 51)]); + + // Invalid characters + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'slug' must contain only lowercase letters, numbers, hyphens, and underscores"); + $struct->with(['slug' => 'My-Post']); + + // Consecutive hyphens + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'slug' must not contain consecutive hyphens or underscores"); + $struct->with(['slug' => 'my--post']); + + // Underscores not allowed in strict mode + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'strictSlug' must contain only lowercase letters, numbers, and hyphens"); + $struct->with(['strictSlug' => 'my_post']); + } + + public function testPasswordRule(): void + { + $struct = new ImmutableStruct([ + 'password' => [ + 'type' => 'string', + 'rules' => [ + new PasswordRule( + minLength: 8, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSpecialChars: true, + maxLength: 100 + ) + ] + ], + 'simplePassword' => [ + 'type' => 'string', + 'rules' => [ + new PasswordRule( + minLength: 6, + requireUppercase: false, + requireLowercase: true, + requireNumbers: true, + requireSpecialChars: false + ) + ] + ] + ]); + + // Valid passwords + $newStruct = $struct->with([ + 'password' => 'Password123!', + 'simplePassword' => 'pass123' + ]); + $this->assertEquals('Password123!', $newStruct->get('password')); + $this->assertEquals('pass123', $newStruct->get('simplePassword')); + + // Too short + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password' must be at least 8 characters long"); + $struct->with(['password' => 'Pass1!']); + + // Too long + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password' must not exceed 100 characters"); + $struct->with(['password' => str_repeat('a', 101)]); + + // Missing uppercase + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password' must contain at least one uppercase letter"); + $struct->with(['password' => 'password123!']); + + // Missing lowercase + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password' must contain at least one lowercase letter"); + $struct->with(['password' => 'PASSWORD123!']); + + // Missing numbers + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password' must contain at least one number"); + $struct->with(['password' => 'Password!']); + + // Missing special characters + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password' must contain at least one special character"); + $struct->with(['password' => 'Password123']); + + // Simple password - valid + $newStruct = $struct->with(['simplePassword' => 'pass123']); + $this->assertEquals('pass123', $newStruct->get('simplePassword')); + + // Simple password - invalid (missing numbers) + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'simplePassword' must contain at least one number"); + $struct->with(['simplePassword' => 'password']); + } + + public function testCompositeRule(): void + { + $struct = new ImmutableStruct([ + 'username' => [ + 'type' => 'string', + 'rules' => [ + CompositeRule::fromArray([ + new MinLengthRule(3), + new PatternRule('/^[a-zA-Z0-9_]+$/') + ]) + ] + ], + 'password' => [ + 'type' => 'string', + 'rules' => [ + new PasswordRule( + minLength: 8, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSpecialChars: true + ) + ] + ] + ]); + + // Valid values + $newStruct = $struct->with([ + 'username' => 'john_doe', + 'password' => 'Password123!' + ]); + $this->assertEquals('john_doe', $newStruct->get('username')); + $this->assertEquals('Password123!', $newStruct->get('password')); + + // Invalid username - too short + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'username' must be at least 3 characters long"); + $struct->with(['username' => 'jo']); + + // Invalid username - invalid characters + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'username' does not match the required pattern"); + $struct->with(['username' => 'john@doe']); + + // Invalid password - missing special character + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Field 'password' must contain at least one special character"); + $struct->with(['password' => 'Password123']); + } +} \ No newline at end of file From fa35f67943a995965bdf00502920d0a8b408a186 Mon Sep 17 00:00:00 2001 From: nejc Date: Fri, 30 May 2025 11:48:09 +0200 Subject: [PATCH 04/10] Add core interfaces and exceptions for type system --- src/Exceptions/InvalidArgumentException.php | 66 +++++++++++++++++++++ src/Interfaces/DataTypeInterface.php | 36 +++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/Exceptions/InvalidArgumentException.php create mode 100644 src/Interfaces/DataTypeInterface.php diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php new file mode 100644 index 0000000..b25c24e --- /dev/null +++ b/src/Exceptions/InvalidArgumentException.php @@ -0,0 +1,66 @@ + Date: Fri, 30 May 2025 11:48:18 +0200 Subject: [PATCH 05/10] Add ImmutableStruct implementation with validation rules --- src/Composite/Struct/ImmutableStruct.php | 323 +++++++++++++++++++ src/Composite/Struct/Rules/CompositeRule.php | 57 ++++ src/Composite/Struct/Rules/CustomRule.php | 40 +++ src/Composite/Struct/Rules/EmailRule.php | 28 ++ src/Composite/Struct/Rules/MinLengthRule.php | 35 ++ src/Composite/Struct/Rules/PasswordRule.php | 82 +++++ src/Composite/Struct/Rules/PatternRule.php | 35 ++ src/Composite/Struct/Rules/RangeRule.php | 38 +++ src/Composite/Struct/Rules/SlugRule.php | 68 ++++ src/Composite/Struct/Rules/UrlRule.php | 41 +++ src/Composite/Struct/ValidationRule.php | 21 ++ 11 files changed, 768 insertions(+) create mode 100644 src/Composite/Struct/ImmutableStruct.php create mode 100644 src/Composite/Struct/Rules/CompositeRule.php create mode 100644 src/Composite/Struct/Rules/CustomRule.php create mode 100644 src/Composite/Struct/Rules/EmailRule.php create mode 100644 src/Composite/Struct/Rules/MinLengthRule.php create mode 100644 src/Composite/Struct/Rules/PasswordRule.php create mode 100644 src/Composite/Struct/Rules/PatternRule.php create mode 100644 src/Composite/Struct/Rules/RangeRule.php create mode 100644 src/Composite/Struct/Rules/SlugRule.php create mode 100644 src/Composite/Struct/Rules/UrlRule.php create mode 100644 src/Composite/Struct/ValidationRule.php diff --git a/src/Composite/Struct/ImmutableStruct.php b/src/Composite/Struct/ImmutableStruct.php new file mode 100644 index 0000000..dea5d74 --- /dev/null +++ b/src/Composite/Struct/ImmutableStruct.php @@ -0,0 +1,323 @@ + The struct fields + */ + private array $fields; + + /** + * @var bool Whether the struct is frozen (immutable) + */ + private bool $frozen = false; + + /** + * Create a new ImmutableStruct instance + * + * @param array $fieldDefinitions Field definitions + * @param array $initialValues Initial values for fields + * @throws InvalidArgumentException If field definitions are invalid or initial values don't match + * @throws ValidationException If validation rules fail + */ + public function __construct(array $fieldDefinitions, array $initialValues = []) + { + $this->fields = []; + $this->initializeFields($fieldDefinitions); + $this->setInitialValues($initialValues); + $this->frozen = true; + } + + /** + * Initialize the struct fields from definitions + * + * @param array $fieldDefinitions + * @throws InvalidArgumentException If field definitions are invalid + */ + private function initializeFields(array $fieldDefinitions): void + { + foreach ($fieldDefinitions as $name => $definition) { + if (!isset($definition['type'])) { + throw new InvalidArgumentException("Field '$name' must have a type definition"); + } + + $this->fields[$name] = [ + 'type' => $definition['type'], + 'value' => $definition['default'] ?? null, + 'required' => $definition['required'] ?? false, + 'default' => $definition['default'] ?? null, + 'rules' => $definition['rules'] ?? [] + ]; + } + } + + /** + * Set initial values for fields + * + * @param array $initialValues + * @throws InvalidArgumentException If initial values don't match field definitions + * @throws ValidationException If validation rules fail + */ + private function setInitialValues(array $initialValues): void + { + foreach ($initialValues as $name => $value) { + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' is not defined in the struct"); + } + $this->set($name, $value); + } + + // Validate required fields + foreach ($this->fields as $name => $field) { + if ($field['required'] && $field['value'] === null) { + throw new InvalidArgumentException("Required field '$name' has no value"); + } + } + } + + /** + * Create a new struct with updated values + * + * @param array $values New values to set + * @return self A new struct instance with the updated values + * @throws InvalidArgumentException If values don't match field definitions + * @throws ValidationException If validation rules fail + */ + public function with(array $values): self + { + $newFields = []; + foreach ($this->fields as $name => $field) { + $newFields[$name] = [ + 'type' => $field['type'], + 'required' => $field['required'], + 'default' => $field['default'], + 'rules' => $field['rules'] + ]; + } + + $newStruct = new self($newFields, $values); + return $newStruct; + } + + /** + * Get a new struct with a single field updated + * + * @param string $name Field name + * @param mixed $value New value + * @return self A new struct instance with the updated field + * @throws InvalidArgumentException If the field doesn't exist or value doesn't match type + * @throws ValidationException If validation rules fail + */ + public function withField(string $name, mixed $value): self + { + return $this->with([$name => $value]); + } + + /** + * {@inheritDoc} + */ + public function set(string $name, mixed $value): void + { + if ($this->frozen) { + throw new ImmutableException("Cannot modify a frozen struct"); + } + + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + } + + $this->validateValue($name, $value); + $this->fields[$name]['value'] = $value; + } + + /** + * {@inheritDoc} + */ + public function get(string $name): mixed + { + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + } + + return $this->fields[$name]['value']; + } + + /** + * {@inheritDoc} + */ + public function getFields(): array + { + return $this->fields; + } + + /** + * Get the type of a field + * + * @param string $name Field name + * @return string The field type + * @throws InvalidArgumentException If the field doesn't exist + */ + public function getFieldType(string $name): string + { + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + } + + return $this->fields[$name]['type']; + } + + /** + * Check if a field is required + * + * @param string $name Field name + * @return bool True if the field is required + * @throws InvalidArgumentException If the field doesn't exist + */ + public function isFieldRequired(string $name): bool + { + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + } + + return $this->fields[$name]['required']; + } + + /** + * Get the validation rules for a field + * + * @param string $name Field name + * @return ValidationRule[] The field's validation rules + * @throws InvalidArgumentException If the field doesn't exist + */ + public function getFieldRules(string $name): array + { + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + } + + return $this->fields[$name]['rules']; + } + + /** + * Validate a value against a field's type and rules + * + * @param string $name Field name + * @param mixed $value Value to validate + * @throws InvalidArgumentException If the value doesn't match the field type + * @throws ValidationException If validation rules fail + */ + private function validateValue(string $name, mixed $value): void + { + $type = $this->fields[$name]['type']; + $actualType = get_debug_type($value); + + // Handle nullable types + if ($this->isNullable($type) && $value === null) { + return; + } + + $baseType = $this->stripNullable($type); + + // Handle nested structs + if (is_subclass_of($baseType, StructInterface::class)) { + if (!($value instanceof $baseType)) { + throw new InvalidArgumentException( + "Field '$name' expects type '$type', but got '$actualType'" + ); + } + return; + } + + // Handle primitive types + if ($actualType !== $baseType && !is_subclass_of($value, $baseType)) { + throw new InvalidArgumentException( + "Field '$name' expects type '$type', but got '$actualType'" + ); + } + + // Apply validation rules + foreach ($this->fields[$name]['rules'] as $rule) { + $rule->validate($value, $name); + } + } + + /** + * Check if a type is nullable + * + * @param string $type Type to check + * @return bool True if the type is nullable + */ + private function isNullable(string $type): bool + { + return str_starts_with($type, '?'); + } + + /** + * Strip nullable prefix from a type + * + * @param string $type Type to strip + * @return string Type without nullable prefix + */ + private function stripNullable(string $type): string + { + return ltrim($type, '?'); + } + + /** + * Convert the struct to an array + * + * @return array The struct data + */ + public function toArray(): array + { + $result = []; + foreach ($this->fields as $name => $field) { + $value = $field['value']; + if ($value instanceof StructInterface) { + $result[$name] = $value->toArray(); + } else { + $result[$name] = $value; + } + } + return $result; + } + + /** + * String representation of the struct + * + * @return string + */ + public function __toString(): string + { + return json_encode($this->toArray()); + } +} \ No newline at end of file diff --git a/src/Composite/Struct/Rules/CompositeRule.php b/src/Composite/Struct/Rules/CompositeRule.php new file mode 100644 index 0000000..a27c7a1 --- /dev/null +++ b/src/Composite/Struct/Rules/CompositeRule.php @@ -0,0 +1,57 @@ +rules = $rules; + } + + public function validate(mixed $value, string $fieldName): bool + { + foreach ($this->rules as $rule) { + $rule->validate($value, $fieldName); + } + + return true; + } + + /** + * Create a new composite rule from an array of rules + * + * @param ValidationRule[] $rules + * @return self + */ + public static function fromArray(array $rules): self + { + return new self(...$rules); + } + + /** + * Add a rule to the composite + * + * @param ValidationRule $rule + * @return self A new composite rule with the added rule + */ + public function withRule(ValidationRule $rule): self + { + return new self(...array_merge($this->rules, [$rule])); + } +} \ No newline at end of file diff --git a/src/Composite/Struct/Rules/CustomRule.php b/src/Composite/Struct/Rules/CustomRule.php new file mode 100644 index 0000000..9706d79 --- /dev/null +++ b/src/Composite/Struct/Rules/CustomRule.php @@ -0,0 +1,40 @@ +validator = $validator; + $this->errorMessage = $errorMessage; + } + + public function validate(mixed $value, string $fieldName): bool + { + $isValid = ($this->validator)($value); + + if (!$isValid) { + throw new ValidationException( + "Field '$fieldName': {$this->errorMessage}" + ); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Composite/Struct/Rules/EmailRule.php b/src/Composite/Struct/Rules/EmailRule.php new file mode 100644 index 0000000..b3deeeb --- /dev/null +++ b/src/Composite/Struct/Rules/EmailRule.php @@ -0,0 +1,28 @@ +minLength = $minLength; + } + + public function validate(mixed $value, string $fieldName): bool + { + if (!is_string($value)) { + throw new ValidationException( + "Field '$fieldName' must be a string to validate length" + ); + } + + if (strlen($value) < $this->minLength) { + throw new ValidationException( + "Field '$fieldName' must be at least {$this->minLength} characters long" + ); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Composite/Struct/Rules/PasswordRule.php b/src/Composite/Struct/Rules/PasswordRule.php new file mode 100644 index 0000000..bfeb299 --- /dev/null +++ b/src/Composite/Struct/Rules/PasswordRule.php @@ -0,0 +1,82 @@ +minLength = $minLength; + $this->requireUppercase = $requireUppercase; + $this->requireLowercase = $requireLowercase; + $this->requireNumbers = $requireNumbers; + $this->requireSpecialChars = $requireSpecialChars; + $this->maxLength = $maxLength; + } + + public function validate(mixed $value, string $fieldName): bool + { + if (!is_string($value)) { + throw new ValidationException( + "Field '$fieldName' must be a string to validate password" + ); + } + + $length = strlen($value); + if ($length < $this->minLength) { + throw new ValidationException( + "Field '$fieldName' must be at least {$this->minLength} characters long" + ); + } + + if ($this->maxLength !== null && $length > $this->maxLength) { + throw new ValidationException( + "Field '$fieldName' must not exceed {$this->maxLength} characters" + ); + } + + if ($this->requireUppercase && !preg_match('/[A-Z]/', $value)) { + throw new ValidationException( + "Field '$fieldName' must contain at least one uppercase letter" + ); + } + + if ($this->requireLowercase && !preg_match('/[a-z]/', $value)) { + throw new ValidationException( + "Field '$fieldName' must contain at least one lowercase letter" + ); + } + + if ($this->requireNumbers && !preg_match('/[0-9]/', $value)) { + throw new ValidationException( + "Field '$fieldName' must contain at least one number" + ); + } + + if ($this->requireSpecialChars && !preg_match('/[^a-zA-Z0-9]/', $value)) { + throw new ValidationException( + "Field '$fieldName' must contain at least one special character" + ); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Composite/Struct/Rules/PatternRule.php b/src/Composite/Struct/Rules/PatternRule.php new file mode 100644 index 0000000..1881df9 --- /dev/null +++ b/src/Composite/Struct/Rules/PatternRule.php @@ -0,0 +1,35 @@ +pattern = $pattern; + } + + public function validate(mixed $value, string $fieldName): bool + { + if (!is_string($value)) { + throw new ValidationException( + "Field '$fieldName' must be a string to validate pattern" + ); + } + + if (!preg_match($this->pattern, $value)) { + throw new ValidationException( + "Field '$fieldName' does not match the required pattern" + ); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Composite/Struct/Rules/RangeRule.php b/src/Composite/Struct/Rules/RangeRule.php new file mode 100644 index 0000000..3ad7a44 --- /dev/null +++ b/src/Composite/Struct/Rules/RangeRule.php @@ -0,0 +1,38 @@ +min = $min; + $this->max = $max; + } + + public function validate(mixed $value, string $fieldName): bool + { + if (!is_numeric($value)) { + throw new ValidationException( + "Field '$fieldName' must be numeric to validate range" + ); + } + + $numValue = (float)$value; + if ($numValue < $this->min || $numValue > $this->max) { + throw new ValidationException( + "Field '$fieldName' must be between {$this->min} and {$this->max}" + ); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Composite/Struct/Rules/SlugRule.php b/src/Composite/Struct/Rules/SlugRule.php new file mode 100644 index 0000000..3b1d6fb --- /dev/null +++ b/src/Composite/Struct/Rules/SlugRule.php @@ -0,0 +1,68 @@ +minLength = $minLength; + $this->maxLength = $maxLength; + $this->allowUnderscores = $allowUnderscores; + } + + public function validate(mixed $value, string $fieldName): bool + { + if (!is_string($value)) { + throw new ValidationException( + "Field '$fieldName' must be a string to validate slug" + ); + } + + $length = strlen($value); + if ($length < $this->minLength) { + throw new ValidationException( + "Field '$fieldName' must be at least {$this->minLength} characters long" + ); + } + + if ($length > $this->maxLength) { + throw new ValidationException( + "Field '$fieldName' must not exceed {$this->maxLength} characters" + ); + } + + // Basic slug pattern: lowercase letters, numbers, hyphens, and optionally underscores + $pattern = $this->allowUnderscores + ? '/^[a-z0-9][a-z0-9-_]*[a-z0-9]$/' + : '/^[a-z0-9][a-z0-9-]*[a-z0-9]$/'; + + if (!preg_match($pattern, $value)) { + $message = $this->allowUnderscores + ? "Field '$fieldName' must contain only lowercase letters, numbers, hyphens, and underscores" + : "Field '$fieldName' must contain only lowercase letters, numbers, and hyphens"; + throw new ValidationException($message); + } + + // Check for consecutive hyphens or underscores + if (str_contains($value, '--') || ($this->allowUnderscores && str_contains($value, '__'))) { + throw new ValidationException( + "Field '$fieldName' must not contain consecutive hyphens or underscores" + ); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Composite/Struct/Rules/UrlRule.php b/src/Composite/Struct/Rules/UrlRule.php new file mode 100644 index 0000000..bc2b06a --- /dev/null +++ b/src/Composite/Struct/Rules/UrlRule.php @@ -0,0 +1,41 @@ +requireHttps = $requireHttps; + } + + public function validate(mixed $value, string $fieldName): bool + { + if (!is_string($value)) { + throw new ValidationException( + "Field '$fieldName' must be a string to validate URL" + ); + } + + if (!filter_var($value, FILTER_VALIDATE_URL)) { + throw new ValidationException( + "Field '$fieldName' must be a valid URL" + ); + } + + if ($this->requireHttps && !str_starts_with($value, 'https://')) { + throw new ValidationException( + "Field '$fieldName' must be a secure HTTPS URL" + ); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Composite/Struct/ValidationRule.php b/src/Composite/Struct/ValidationRule.php new file mode 100644 index 0000000..1fc2562 --- /dev/null +++ b/src/Composite/Struct/ValidationRule.php @@ -0,0 +1,21 @@ + Date: Fri, 30 May 2025 11:48:28 +0200 Subject: [PATCH 06/10] Add Vector implementation with abstract base class --- src/Abstract/AbstractVector.php | 149 ++++++++++++++++++++++++++++++++ src/Composite/Vector/Vec2.php | 59 +++++++++++++ src/Composite/Vector/Vec3.php | 73 ++++++++++++++++ src/Composite/Vector/Vec4.php | 74 ++++++++++++++++ 4 files changed, 355 insertions(+) create mode 100644 src/Abstract/AbstractVector.php create mode 100644 src/Composite/Vector/Vec2.php create mode 100644 src/Composite/Vector/Vec3.php create mode 100644 src/Composite/Vector/Vec4.php diff --git a/src/Abstract/AbstractVector.php b/src/Abstract/AbstractVector.php new file mode 100644 index 0000000..07d82f0 --- /dev/null +++ b/src/Abstract/AbstractVector.php @@ -0,0 +1,149 @@ +validateComponents($components); + $this->components = $components; + } + + abstract protected function validateComponents(array $components): void; + + public function getComponents(): array + { + return $this->components; + } + + public function magnitude(): float + { + return sqrt(array_sum(array_map(fn($component) => $component ** 2, $this->components))); + } + + public function normalize(): self + { + $magnitude = $this->magnitude(); + if ($magnitude === 0.0) { + throw new InvalidArgumentException("Cannot normalize a zero vector"); + } + + $normalized = array_map(fn($component) => $component / $magnitude, $this->components); + return new static($normalized); + } + + public function dot(self $other): float + { + if (get_class($this) !== get_class($other)) { + 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 + )); + } + + public function add(self $other): self + { + if (get_class($this) !== get_class($other)) { + 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); + } + + public function subtract(self $other): self + { + if (get_class($this) !== get_class($other)) { + 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); + } + + public function scale(float $scalar): self + { + $result = array_map( + fn($component) => $component * $scalar, + $this->components + ); + + return new static($result); + } + + public function __toString(): string + { + return '(' . implode(', ', $this->components) . ')'; + } + + protected function validateNumericComponents(array $components): void + { + foreach ($components as $component) { + if (!is_numeric($component)) { + throw new InvalidArgumentException("All components must be numeric"); + } + } + } + + protected function validateComponentCount(array $components, int $expectedCount): void + { + if (count($components) !== $expectedCount) { + throw new InvalidArgumentException(sprintf( + "Vector must have exactly %d components", + $expectedCount + )); + } + } + + public function getComponent(int $index): float + { + if (!isset($this->components[$index])) { + throw new InvalidArgumentException("Invalid component index"); + } + return $this->components[$index]; + } + + public function equals(DataTypeInterface $other): bool + { + if (!$other instanceof self) { + return false; + } + + return $this->components === $other->components; + } + + public function distance(self $other): float + { + if (get_class($this) !== get_class($other)) { + 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)); + } +} \ No newline at end of file diff --git a/src/Composite/Vector/Vec2.php b/src/Composite/Vector/Vec2.php new file mode 100644 index 0000000..922dcb7 --- /dev/null +++ b/src/Composite/Vector/Vec2.php @@ -0,0 +1,59 @@ +validateComponentCount($components, 2); + $this->validateNumericComponents($components); + } + + public function getX(): float + { + return $this->getComponent(0); + } + + public function getY(): float + { + return $this->getComponent(1); + } + + public function cross(Vec2 $other): float + { + return ($this->getX() * $other->getY()) - ($this->getY() * $other->getX()); + } + + public static function zero(): self + { + return new self([0.0, 0.0]); + } + + public static function unitX(): self + { + return new self([1.0, 0.0]); + } + + public static function unitY(): self + { + return new self([0.0, 1.0]); + } + + public function getValue(): array + { + return $this->components; + } + + public function setValue(mixed $value): void + { + if (!is_array($value)) { + throw new InvalidArgumentException('Value must be an array of components.'); + } + $this->validateComponents($value); + $this->components = $value; + } +} \ No newline at end of file diff --git a/src/Composite/Vector/Vec3.php b/src/Composite/Vector/Vec3.php new file mode 100644 index 0000000..90b5a45 --- /dev/null +++ b/src/Composite/Vector/Vec3.php @@ -0,0 +1,73 @@ +validateComponentCount($components, 3); + $this->validateNumericComponents($components); + } + + public function getX(): float + { + return $this->getComponent(0); + } + + public function getY(): float + { + return $this->getComponent(1); + } + + public function getZ(): float + { + return $this->getComponent(2); + } + + public function cross(Vec3 $other): self + { + return new self([ + $this->getY() * $other->getZ() - $this->getZ() * $other->getY(), + $this->getZ() * $other->getX() - $this->getX() * $other->getZ(), + $this->getX() * $other->getY() - $this->getY() * $other->getX() + ]); + } + + public static function zero(): self + { + return new self([0.0, 0.0, 0.0]); + } + + public static function unitX(): self + { + return new self([1.0, 0.0, 0.0]); + } + + public static function unitY(): self + { + return new self([0.0, 1.0, 0.0]); + } + + public static function unitZ(): self + { + return new self([0.0, 0.0, 1.0]); + } + + public function getValue(): array + { + return $this->components; + } + + public function setValue(mixed $value): void + { + if (!is_array($value)) { + throw new InvalidArgumentException('Value must be an array of components.'); + } + $this->validateComponents($value); + $this->components = $value; + } +} \ No newline at end of file diff --git a/src/Composite/Vector/Vec4.php b/src/Composite/Vector/Vec4.php new file mode 100644 index 0000000..f79ba5b --- /dev/null +++ b/src/Composite/Vector/Vec4.php @@ -0,0 +1,74 @@ +validateComponentCount($components, 4); + $this->validateNumericComponents($components); + } + + public function getX(): float + { + return $this->getComponent(0); + } + + public function getY(): float + { + return $this->getComponent(1); + } + + public function getZ(): float + { + return $this->getComponent(2); + } + + public function getW(): float + { + return $this->getComponent(3); + } + + public static function zero(): self + { + return new self([0.0, 0.0, 0.0, 0.0]); + } + + public static function unitX(): self + { + return new self([1.0, 0.0, 0.0, 0.0]); + } + + public static function unitY(): self + { + return new self([0.0, 1.0, 0.0, 0.0]); + } + + public static function unitZ(): self + { + return new self([0.0, 0.0, 1.0, 0.0]); + } + + public static function unitW(): self + { + return new self([0.0, 0.0, 0.0, 1.0]); + } + + public function getValue(): array + { + return $this->components; + } + + public function setValue(mixed $value): void + { + if (!is_array($value)) { + throw new InvalidArgumentException('Value must be an array of components.'); + } + $this->validateComponents($value); + $this->components = $value; + } +} \ No newline at end of file From aceca21a7cd7fa8105eeabb776312259a3e6ffc5 Mon Sep 17 00:00:00 2001 From: nejc Date: Fri, 30 May 2025 11:49:15 +0200 Subject: [PATCH 07/10] Add TypeSafeArray implementation with type constraints and update scalar types --- Tests/Composite/Arrays/TypeSafeArrayTest.php | 153 ++++++++++++++++++ Tests/Composite/Vector/Vec2Test.php | 135 ++++++++++++++++ Tests/Composite/Vector/Vec3Test.php | 154 ++++++++++++++++++ Tests/Composite/Vector/Vec4Test.php | 162 +++++++++++++++++++ src/Scalar/Integers/Signed/Int32.php | 5 +- src/Scalar/Integers/Signed/Int8.php | 5 +- src/Scalar/Integers/Unsigned/UInt64.php | 8 - 7 files changed, 606 insertions(+), 16 deletions(-) create mode 100644 Tests/Composite/Arrays/TypeSafeArrayTest.php create mode 100644 Tests/Composite/Vector/Vec2Test.php create mode 100644 Tests/Composite/Vector/Vec3Test.php create mode 100644 Tests/Composite/Vector/Vec4Test.php diff --git a/Tests/Composite/Arrays/TypeSafeArrayTest.php b/Tests/Composite/Arrays/TypeSafeArrayTest.php new file mode 100644 index 0000000..2f2aa11 --- /dev/null +++ b/Tests/Composite/Arrays/TypeSafeArrayTest.php @@ -0,0 +1,153 @@ +assertInstanceOf(TypeSafeArray::class, $array); + $this->assertEquals(\stdClass::class, $array->getElementType()); + } + + public function testCreateWithInvalidType(): void + { + $this->expectException(InvalidArgumentException::class); + new TypeSafeArray('NonExistentClass'); + } + + public function testAddValidElement(): void + { + $array = new TypeSafeArray(\stdClass::class); + $obj = new \stdClass(); + $array[] = $obj; + $this->assertCount(1, $array); + $this->assertSame($obj, $array[0]); + } + + public function testAddInvalidElement(): void + { + $array = new TypeSafeArray(\stdClass::class); + $this->expectException(TypeMismatchException::class); + $array[] = 'not an object'; + } + + public function testInitializeWithValidData(): void + { + $data = [new \stdClass(), new \stdClass()]; + $array = new TypeSafeArray(\stdClass::class, $data); + $this->assertCount(2, $array); + } + + public function testInitializeWithInvalidData(): void + { + $data = [new \stdClass(), 'not an object']; + $this->expectException(TypeMismatchException::class); + new TypeSafeArray(\stdClass::class, $data); + } + + public function testMapOperation(): void + { + $array = new TypeSafeArray(\stdClass::class); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $array[] = $obj1; + $array[] = $obj2; + + $mapped = $array->map(function($item) { + $new = new \stdClass(); + $new->mapped = true; + return $new; + }); + + $this->assertInstanceOf(TypeSafeArray::class, $mapped); + $this->assertCount(2, $mapped); + $this->assertTrue($mapped[0]->mapped); + $this->assertTrue($mapped[1]->mapped); + } + + public function testFilterOperation(): void + { + $array = new TypeSafeArray(\stdClass::class); + $obj1 = new \stdClass(); + $obj1->value = 1; + $obj2 = new \stdClass(); + $obj2->value = 2; + $array[] = $obj1; + $array[] = $obj2; + + $filtered = $array->filter(function($item) { + return $item->value === 1; + }); + + $this->assertInstanceOf(TypeSafeArray::class, $filtered); + $this->assertCount(1, $filtered); + $this->assertEquals(1, $filtered[0]->value); + } + + public function testReduceOperation(): void + { + $array = new TypeSafeArray(\stdClass::class); + $obj1 = new \stdClass(); + $obj1->value = 1; + $obj2 = new \stdClass(); + $obj2->value = 2; + $array[] = $obj1; + $array[] = $obj2; + + $sum = $array->reduce(function($carry, $item) { + return $carry + $item->value; + }, 0); + + $this->assertEquals(3, $sum); + } + + public function testArrayAccess(): void + { + $array = new TypeSafeArray(\stdClass::class); + $obj = new \stdClass(); + + // Test offsetSet + $array[0] = $obj; + $this->assertTrue(isset($array[0])); + $this->assertSame($obj, $array[0]); + + // Test offsetUnset + unset($array[0]); + $this->assertFalse(isset($array[0])); + } + + public function testIterator(): void + { + $array = new TypeSafeArray(\stdClass::class); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $array[] = $obj1; + $array[] = $obj2; + + $items = []; + foreach ($array as $item) { + $items[] = $item; + } + + $this->assertCount(2, $items); + $this->assertSame($obj1, $items[0]); + $this->assertSame($obj2, $items[1]); + } + + public function testToString(): void + { + $array = new TypeSafeArray(\stdClass::class); + $obj = new \stdClass(); + $obj->test = 'value'; + $array[] = $obj; + + $this->assertEquals('[{"test":"value"}]', (string)$array); + } +} \ No newline at end of file diff --git a/Tests/Composite/Vector/Vec2Test.php b/Tests/Composite/Vector/Vec2Test.php new file mode 100644 index 0000000..1847a07 --- /dev/null +++ b/Tests/Composite/Vector/Vec2Test.php @@ -0,0 +1,135 @@ +assertEquals(1.0, $vec->getX()); + $this->assertEquals(2.0, $vec->getY()); + } + + public function testCreateInvalidComponentCount(): void + { + $this->expectException(InvalidArgumentException::class); + new Vec2([1.0]); + } + + public function testCreateWithNonNumericComponents(): void + { + $this->expectException(InvalidArgumentException::class); + new Vec2(['a', 'b']); + } + + public function testMagnitude(): void + { + $vec = new Vec2([3.0, 4.0]); + $this->assertEquals(5.0, $vec->magnitude()); + } + + public function testNormalize(): void + { + $vec = new Vec2([3.0, 4.0]); + $normalized = $vec->normalize(); + $this->assertEquals(1.0, $normalized->magnitude()); + $this->assertEquals(0.6, $normalized->getX()); + $this->assertEquals(0.8, $normalized->getY()); + } + + public function testNormalizeZeroVector(): void + { + $vec = new Vec2([0.0, 0.0]); + $this->expectException(InvalidArgumentException::class); + $vec->normalize(); + } + + public function testDotProduct(): void + { + $vec1 = new Vec2([1.0, 2.0]); + $vec2 = new Vec2([3.0, 4.0]); + $this->assertEquals(11.0, $vec1->dot($vec2)); + } + + public function testAdd(): void + { + $vec1 = new Vec2([1.0, 2.0]); + $vec2 = new Vec2([3.0, 4.0]); + $result = $vec1->add($vec2); + $this->assertEquals(4.0, $result->getX()); + $this->assertEquals(6.0, $result->getY()); + } + + public function testSubtract(): void + { + $vec1 = new Vec2([3.0, 4.0]); + $vec2 = new Vec2([1.0, 2.0]); + $result = $vec1->subtract($vec2); + $this->assertEquals(2.0, $result->getX()); + $this->assertEquals(2.0, $result->getY()); + } + + public function testScale(): void + { + $vec = new Vec2([1.0, 2.0]); + $result = $vec->scale(2.0); + $this->assertEquals(2.0, $result->getX()); + $this->assertEquals(4.0, $result->getY()); + } + + public function testCross(): void + { + $vec1 = new Vec2([1.0, 2.0]); + $vec2 = new Vec2([3.0, 4.0]); + $this->assertEquals(-2.0, $vec1->cross($vec2)); + } + + public function testZero(): void + { + $vec = Vec2::zero(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + } + + public function testUnitX(): void + { + $vec = Vec2::unitX(); + $this->assertEquals(1.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + } + + public function testUnitY(): void + { + $vec = Vec2::unitY(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(1.0, $vec->getY()); + } + + public function testToString(): void + { + $vec = new Vec2([1.0, 2.0]); + $this->assertEquals('(1, 2)', (string)$vec); + } + + public function testEquals(): void + { + $vec1 = new Vec2([1.0, 2.0]); + $vec2 = new Vec2([1.0, 2.0]); + $vec3 = new Vec2([2.0, 1.0]); + + $this->assertTrue($vec1->equals($vec2)); + $this->assertFalse($vec1->equals($vec3)); + } + + public function testDistance(): void + { + $vec1 = new Vec2([0.0, 0.0]); + $vec2 = new Vec2([3.0, 4.0]); + $this->assertEquals(5.0, $vec1->distance($vec2)); + } +} \ No newline at end of file diff --git a/Tests/Composite/Vector/Vec3Test.php b/Tests/Composite/Vector/Vec3Test.php new file mode 100644 index 0000000..8c9fba2 --- /dev/null +++ b/Tests/Composite/Vector/Vec3Test.php @@ -0,0 +1,154 @@ +assertEquals(1.0, $vec->getX()); + $this->assertEquals(2.0, $vec->getY()); + $this->assertEquals(3.0, $vec->getZ()); + } + + public function testCreateInvalidComponentCount(): void + { + $this->expectException(InvalidArgumentException::class); + new Vec3([1.0, 2.0]); + } + + public function testCreateWithNonNumericComponents(): void + { + $this->expectException(InvalidArgumentException::class); + new Vec3(['a', 'b', 'c']); + } + + public function testMagnitude(): void + { + $vec = new Vec3([1.0, 2.0, 2.0]); + $this->assertEquals(3.0, $vec->magnitude()); + } + + public function testNormalize(): void + { + $vec = new Vec3([1.0, 2.0, 2.0]); + $normalized = $vec->normalize(); + $this->assertEquals(1.0, $normalized->magnitude()); + $this->assertEquals(1/3, $normalized->getX()); + $this->assertEquals(2/3, $normalized->getY()); + $this->assertEquals(2/3, $normalized->getZ()); + } + + public function testNormalizeZeroVector(): void + { + $vec = new Vec3([0.0, 0.0, 0.0]); + $this->expectException(InvalidArgumentException::class); + $vec->normalize(); + } + + public function testDotProduct(): void + { + $vec1 = new Vec3([1.0, 2.0, 3.0]); + $vec2 = new Vec3([4.0, 5.0, 6.0]); + $this->assertEquals(32.0, $vec1->dot($vec2)); + } + + public function testAdd(): void + { + $vec1 = new Vec3([1.0, 2.0, 3.0]); + $vec2 = new Vec3([4.0, 5.0, 6.0]); + $result = $vec1->add($vec2); + $this->assertEquals(5.0, $result->getX()); + $this->assertEquals(7.0, $result->getY()); + $this->assertEquals(9.0, $result->getZ()); + } + + public function testSubtract(): void + { + $vec1 = new Vec3([4.0, 5.0, 6.0]); + $vec2 = new Vec3([1.0, 2.0, 3.0]); + $result = $vec1->subtract($vec2); + $this->assertEquals(3.0, $result->getX()); + $this->assertEquals(3.0, $result->getY()); + $this->assertEquals(3.0, $result->getZ()); + } + + public function testScale(): void + { + $vec = new Vec3([1.0, 2.0, 3.0]); + $result = $vec->scale(2.0); + $this->assertEquals(2.0, $result->getX()); + $this->assertEquals(4.0, $result->getY()); + $this->assertEquals(6.0, $result->getZ()); + } + + public function testCross(): void + { + $vec1 = new Vec3([1.0, 0.0, 0.0]); + $vec2 = new Vec3([0.0, 1.0, 0.0]); + $result = $vec1->cross($vec2); + $this->assertEquals(0.0, $result->getX()); + $this->assertEquals(0.0, $result->getY()); + $this->assertEquals(1.0, $result->getZ()); + } + + public function testZero(): void + { + $vec = Vec3::zero(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + $this->assertEquals(0.0, $vec->getZ()); + } + + public function testUnitX(): void + { + $vec = Vec3::unitX(); + $this->assertEquals(1.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + $this->assertEquals(0.0, $vec->getZ()); + } + + public function testUnitY(): void + { + $vec = Vec3::unitY(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(1.0, $vec->getY()); + $this->assertEquals(0.0, $vec->getZ()); + } + + public function testUnitZ(): void + { + $vec = Vec3::unitZ(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + $this->assertEquals(1.0, $vec->getZ()); + } + + public function testToString(): void + { + $vec = new Vec3([1.0, 2.0, 3.0]); + $this->assertEquals('(1, 2, 3)', (string)$vec); + } + + public function testEquals(): void + { + $vec1 = new Vec3([1.0, 2.0, 3.0]); + $vec2 = new Vec3([1.0, 2.0, 3.0]); + $vec3 = new Vec3([3.0, 2.0, 1.0]); + + $this->assertTrue($vec1->equals($vec2)); + $this->assertFalse($vec1->equals($vec3)); + } + + public function testDistance(): void + { + $vec1 = new Vec3([0.0, 0.0, 0.0]); + $vec2 = new Vec3([1.0, 2.0, 2.0]); + $this->assertEquals(3.0, $vec1->distance($vec2)); + } +} \ No newline at end of file diff --git a/Tests/Composite/Vector/Vec4Test.php b/Tests/Composite/Vector/Vec4Test.php new file mode 100644 index 0000000..1224233 --- /dev/null +++ b/Tests/Composite/Vector/Vec4Test.php @@ -0,0 +1,162 @@ +assertEquals(1.0, $vec->getX()); + $this->assertEquals(2.0, $vec->getY()); + $this->assertEquals(3.0, $vec->getZ()); + $this->assertEquals(4.0, $vec->getW()); + } + + public function testCreateInvalidComponentCount(): void + { + $this->expectException(InvalidArgumentException::class); + new Vec4([1.0, 2.0, 3.0]); + } + + public function testCreateWithNonNumericComponents(): void + { + $this->expectException(InvalidArgumentException::class); + new Vec4(['a', 'b', 'c', 'd']); + } + + public function testMagnitude(): void + { + $vec = new Vec4([1.0, 2.0, 2.0, 2.0]); + $this->assertEquals(sqrt(13), $vec->magnitude()); + } + + public function testNormalize(): void + { + $vec = new Vec4([1.0, 2.0, 2.0, 2.0]); + $normalized = $vec->normalize(); + $this->assertEquals(1.0, $normalized->magnitude()); + $this->assertEquals(1/sqrt(13), $normalized->getX()); + $this->assertEquals(2/sqrt(13), $normalized->getY()); + $this->assertEquals(2/sqrt(13), $normalized->getZ()); + $this->assertEquals(2/sqrt(13), $normalized->getW()); + } + + public function testNormalizeZeroVector(): void + { + $vec = new Vec4([0.0, 0.0, 0.0, 0.0]); + $this->expectException(InvalidArgumentException::class); + $vec->normalize(); + } + + public function testDotProduct(): void + { + $vec1 = new Vec4([1.0, 2.0, 3.0, 4.0]); + $vec2 = new Vec4([5.0, 6.0, 7.0, 8.0]); + $this->assertEquals(70.0, $vec1->dot($vec2)); + } + + public function testAdd(): void + { + $vec1 = new Vec4([1.0, 2.0, 3.0, 4.0]); + $vec2 = new Vec4([5.0, 6.0, 7.0, 8.0]); + $result = $vec1->add($vec2); + $this->assertEquals(6.0, $result->getX()); + $this->assertEquals(8.0, $result->getY()); + $this->assertEquals(10.0, $result->getZ()); + $this->assertEquals(12.0, $result->getW()); + } + + public function testSubtract(): void + { + $vec1 = new Vec4([5.0, 6.0, 7.0, 8.0]); + $vec2 = new Vec4([1.0, 2.0, 3.0, 4.0]); + $result = $vec1->subtract($vec2); + $this->assertEquals(4.0, $result->getX()); + $this->assertEquals(4.0, $result->getY()); + $this->assertEquals(4.0, $result->getZ()); + $this->assertEquals(4.0, $result->getW()); + } + + public function testScale(): void + { + $vec = new Vec4([1.0, 2.0, 3.0, 4.0]); + $result = $vec->scale(2.0); + $this->assertEquals(2.0, $result->getX()); + $this->assertEquals(4.0, $result->getY()); + $this->assertEquals(6.0, $result->getZ()); + $this->assertEquals(8.0, $result->getW()); + } + + public function testZero(): void + { + $vec = Vec4::zero(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + $this->assertEquals(0.0, $vec->getZ()); + $this->assertEquals(0.0, $vec->getW()); + } + + public function testUnitX(): void + { + $vec = Vec4::unitX(); + $this->assertEquals(1.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + $this->assertEquals(0.0, $vec->getZ()); + $this->assertEquals(0.0, $vec->getW()); + } + + public function testUnitY(): void + { + $vec = Vec4::unitY(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(1.0, $vec->getY()); + $this->assertEquals(0.0, $vec->getZ()); + $this->assertEquals(0.0, $vec->getW()); + } + + public function testUnitZ(): void + { + $vec = Vec4::unitZ(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + $this->assertEquals(1.0, $vec->getZ()); + $this->assertEquals(0.0, $vec->getW()); + } + + public function testUnitW(): void + { + $vec = Vec4::unitW(); + $this->assertEquals(0.0, $vec->getX()); + $this->assertEquals(0.0, $vec->getY()); + $this->assertEquals(0.0, $vec->getZ()); + $this->assertEquals(1.0, $vec->getW()); + } + + public function testToString(): void + { + $vec = new Vec4([1.0, 2.0, 3.0, 4.0]); + $this->assertEquals('(1, 2, 3, 4)', (string)$vec); + } + + public function testEquals(): void + { + $vec1 = new Vec4([1.0, 2.0, 3.0, 4.0]); + $vec2 = new Vec4([1.0, 2.0, 3.0, 4.0]); + $vec3 = new Vec4([4.0, 3.0, 2.0, 1.0]); + + $this->assertTrue($vec1->equals($vec2)); + $this->assertFalse($vec1->equals($vec3)); + } + + public function testDistance(): void + { + $vec1 = new Vec4([0.0, 0.0, 0.0, 0.0]); + $vec2 = new Vec4([1.0, 2.0, 2.0, 2.0]); + $this->assertEquals(sqrt(13), $vec1->distance($vec2)); + } +} \ No newline at end of file diff --git a/src/Scalar/Integers/Signed/Int32.php b/src/Scalar/Integers/Signed/Int32.php index 0f9a913..92ed9a2 100644 --- a/src/Scalar/Integers/Signed/Int32.php +++ b/src/Scalar/Integers/Signed/Int32.php @@ -27,8 +27,5 @@ final class Int32 extends AbstractNativeInteger */ public const MAX_VALUE = 2147483647; - public function __toString(): string - { - return (string)$this->getValue(); - } + } diff --git a/src/Scalar/Integers/Signed/Int8.php b/src/Scalar/Integers/Signed/Int8.php index 3d23bb5..f180089 100644 --- a/src/Scalar/Integers/Signed/Int8.php +++ b/src/Scalar/Integers/Signed/Int8.php @@ -53,8 +53,5 @@ final class Int8 extends AbstractNativeInteger */ public const MAX_VALUE = 127; - public function __toString(): string - { - return (string)$this->getValue(); - } + } diff --git a/src/Scalar/Integers/Unsigned/UInt64.php b/src/Scalar/Integers/Unsigned/UInt64.php index f76311a..10d9e87 100644 --- a/src/Scalar/Integers/Unsigned/UInt64.php +++ b/src/Scalar/Integers/Unsigned/UInt64.php @@ -53,15 +53,7 @@ final class UInt64 extends AbstractBigInteger */ public const MAX_VALUE = '18446744073709551615'; - public function __toString(): string - { - return $this->value; - } - public function getValue(): string - { - return $this->value; - } // Inherit methods from AbstractBigInteger. } From c5e02a248dbc886cad4ca081c5b9b58679c293d6 Mon Sep 17 00:00:00 2001 From: nejc Date: Fri, 30 May 2025 11:49:49 +0200 Subject: [PATCH 08/10] Update .gitignore to exclude build directory --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 97fa30c..86d58ce 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ yarn-error.log vendor vendor/ composer.lock -todo.txt \ No newline at end of file +todo.txt +/build/ \ No newline at end of file From f4bd3af86e270834baa0992de3d79493b7252496 Mon Sep 17 00:00:00 2001 From: nejc Date: Fri, 30 May 2025 11:56:22 +0200 Subject: [PATCH 09/10] Implement DynamicArray with capacity management, fix FixedSizeArray warning, and update array tests --- Tests/Composite/Arrays/DynamicArrayTest.php | 123 +++++++++++++++ src/Composite/Arrays/DynamicArray.php | 116 ++++++++++++++ src/Composite/Arrays/FixedSizeArray.php | 158 ++++++++++++++++++++ src/Composite/Arrays/TypeSafeArray.php | 79 +++++----- 4 files changed, 442 insertions(+), 34 deletions(-) create mode 100644 Tests/Composite/Arrays/DynamicArrayTest.php create mode 100644 src/Composite/Arrays/DynamicArray.php create mode 100644 src/Composite/Arrays/FixedSizeArray.php diff --git a/Tests/Composite/Arrays/DynamicArrayTest.php b/Tests/Composite/Arrays/DynamicArrayTest.php new file mode 100644 index 0000000..ee3235c --- /dev/null +++ b/Tests/Composite/Arrays/DynamicArrayTest.php @@ -0,0 +1,123 @@ +assertEquals(4, $array->getCapacity()); + $this->assertEquals(0, count($array)); + } + + public function testCreateWithInvalidCapacity() + { + $this->expectException(InvalidArgumentException::class); + new DynamicArray(\stdClass::class, 0); + } + + public function testCreateWithInitialData() + { + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $array = new DynamicArray(\stdClass::class, 2, [$obj1, $obj2]); + $this->assertEquals(2, count($array)); + $this->assertEquals(2, $array->getCapacity()); + } + + public function testCreateWithExcessiveInitialData() + { + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $obj3 = new \stdClass(); + $array = new DynamicArray(\stdClass::class, 2, [$obj1, $obj2, $obj3]); + $this->assertEquals(3, count($array)); + $this->assertEquals(3, $array->getCapacity()); + } + + public function testReserveCapacity() + { + $array = new DynamicArray(\stdClass::class, 2); + $array->reserve(10); + $this->assertEquals(10, $array->getCapacity()); + $array->reserve(5); // Should not decrease + $this->assertEquals(10, $array->getCapacity()); + } + + public function testShrinkToFit() + { + $array = new DynamicArray(\stdClass::class, 10); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $array[] = $obj1; + $array[] = $obj2; + $this->assertEquals(10, $array->getCapacity()); + $array->shrinkToFit(); + $this->assertEquals(2, $array->getCapacity()); + } + + public function testDynamicResizingOnAppend() + { + $array = new DynamicArray(\stdClass::class, 2); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $obj3 = new \stdClass(); + $array[] = $obj1; + $array[] = $obj2; + $this->assertEquals(2, $array->getCapacity()); + $array[] = $obj3; + $this->assertEquals(4, $array->getCapacity()); + $this->assertEquals(3, count($array)); + } + + public function testDynamicResizingOnOffsetSet() + { + $array = new DynamicArray(\stdClass::class, 2); + $obj = new \stdClass(); + $array[5] = $obj; + $this->assertEquals(6, $array->getCapacity()); + $this->assertSame($obj, $array[5]); + } + + public function testSetInvalidType() + { + $array = new DynamicArray(\stdClass::class, 2); + $this->expectException(TypeMismatchException::class); + $array[] = "not an object"; + } + + public function testSetValueAdjustsCapacity() + { + $array = new DynamicArray(\stdClass::class, 2); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $obj3 = new \stdClass(); + $array->setValue([$obj1, $obj2, $obj3]); + $this->assertEquals(3, $array->getCapacity()); + $this->assertEquals(3, count($array)); + } + + public function testIteration() + { + $array = new DynamicArray(\stdClass::class, 2); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $array[] = $obj1; + $array[] = $obj2; + $elements = []; + foreach ($array as $element) { + $elements[] = $element; + } + $this->assertCount(2, $elements); + $this->assertSame($obj1, $elements[0]); + $this->assertSame($obj2, $elements[1]); + } +} diff --git a/src/Composite/Arrays/DynamicArray.php b/src/Composite/Arrays/DynamicArray.php new file mode 100644 index 0000000..28061dc --- /dev/null +++ b/src/Composite/Arrays/DynamicArray.php @@ -0,0 +1,116 @@ +capacity = $initialCapacity; + parent::__construct($elementType, $initialData); + if (count($initialData) > $this->capacity) { + $this->capacity = count($initialData); + } + } + + /** + * Get the current capacity + * + * @return int + */ + public function getCapacity(): int + { + return $this->capacity; + } + + /** + * Reserve capacity for at least $capacity elements + * + * @param int $capacity + * + * @return void + */ + public function reserve(int $capacity): void + { + if ($capacity > $this->capacity) { + $this->capacity = $capacity; + } + } + + /** + * Shrink the capacity to fit the current number of elements + * + * @return void + */ + public function shrinkToFit(): void + { + $this->capacity = count($this->getValue()); + } + + /** + * ArrayAccess implementation (override to grow capacity as needed) + */ + public function offsetSet($offset, $value): void + { + if (!$this->isValidType($value)) { + throw new TypeMismatchException( + "Value must be of type {$this->getElementType()}" + ); + } + if (is_null($offset)) { + // Appending + if (count($this->getValue()) >= $this->capacity) { + $this->capacity = max(1, $this->capacity * 2); + } + } else { + if ($offset >= $this->capacity) { + $this->capacity = $offset + 1; + } + } + parent::offsetSet($offset, $value); + } + + /** + * Set the array value (override to adjust capacity) + * + * @param mixed $value + * + * @throws TypeMismatchException + */ + public function setValue(mixed $value): void + { + if (!is_array($value)) { + throw new TypeMismatchException('Value must be an array.'); + } + if (count($value) > $this->capacity) { + $this->capacity = count($value); + } + parent::setValue($value); + } +} diff --git a/src/Composite/Arrays/FixedSizeArray.php b/src/Composite/Arrays/FixedSizeArray.php new file mode 100644 index 0000000..7e80f23 --- /dev/null +++ b/src/Composite/Arrays/FixedSizeArray.php @@ -0,0 +1,158 @@ + $size) { + throw new InvalidArgumentException( + "Initial data size ({$size}) exceeds fixed size ({$size})" + ); + } + + $this->size = $size; + parent::__construct($elementType, $initialData); + } + + /** + * Get the fixed size of the array + * + * @return int The fixed size + */ + public function getSize(): int + { + return $this->size; + } + + /** + * Check if the array is full + * + * @return bool True if the array is at its maximum size + */ + public function isFull(): bool + { + return count($this->getValue()) >= $this->size; + } + + /** + * Check if the array is empty + * + * @return bool True if the array has no elements + */ + public function isEmpty(): bool + { + return count($this->getValue()) === 0; + } + + /** + * Get the number of remaining slots + * + * @return int The number of available slots + */ + public function getRemainingSlots(): int + { + return $this->size - count($this->getValue()); + } + + /** + * ArrayAccess implementation + */ + public function offsetSet($offset, $value): void + { + if (is_null($offset) && $this->isFull()) { + throw new InvalidArgumentException('Array is at maximum capacity'); + } + + if (!is_null($offset) && $offset >= $this->size) { + throw new InvalidArgumentException( + "Index {$offset} is out of bounds (size: {$this->size})" + ); + } + + parent::offsetSet($offset, $value); + } + + /** + * Set the array value + * + * @param mixed $value The new array data + * + * @throws TypeMismatchException If any element doesn't match the required type + * @throws InvalidArgumentException If the new array size exceeds the fixed size + */ + public function setValue(mixed $value): void + { + if (!is_array($value)) { + throw new TypeMismatchException('Value must be an array'); + } + + if (count($value) > $this->size) { + throw new InvalidArgumentException( + "New array size (" . count($value) . ") exceeds fixed size ({$this->size})" + ); + } + + parent::setValue($value); + } + + /** + * Fill the array with a value up to its capacity + * + * @param mixed $value The value to fill with + * + * @return self + * + * @throws TypeMismatchException If the value doesn't match the required type + */ + public function fill($value): self + { + if (!$this->isValidType($value)) { + throw new TypeMismatchException( + "Value must be of type {$this->getElementType()}" + ); + } + + $this->setValue(array_fill(0, $this->size, $value)); + return $this; + } + + /** + * Create a new array with the same type and size + * + * @return self A new empty array with the same constraints + */ + public function createEmpty(): self + { + return new self($this->getElementType(), $this->size); + } +} diff --git a/src/Composite/Arrays/TypeSafeArray.php b/src/Composite/Arrays/TypeSafeArray.php index b0c0b88..4c74e4d 100644 --- a/src/Composite/Arrays/TypeSafeArray.php +++ b/src/Composite/Arrays/TypeSafeArray.php @@ -1,10 +1,12 @@ $value) { - if (!$this->isValidType($value)) { - throw new TypeMismatchException( - "Element at key '{$key}' must be of type {$this->elementType}" - ); - } - } - } - - /** - * Check if a value matches the required type + * String representation of the array * - * @param mixed $value The value to check - * @return bool True if the value matches the required type + * @return string */ - private function isValidType($value): bool + public function __toString(): string { - return $value instanceof $this->elementType; + return json_encode($this->data); } /** @@ -171,7 +156,9 @@ public function valid(): bool * Map operation - apply a callback to each element * * @param callable $callback The callback to apply + * * @return TypeSafeArray A new array with the mapped values + * * @throws TypeMismatchException If the callback returns invalid types */ public function map(callable $callback): self @@ -187,6 +174,7 @@ public function map(callable $callback): self * Filter operation - filter elements based on a callback * * @param callable $callback The callback to use for filtering + * * @return TypeSafeArray A new array with the filtered values */ public function filter(callable $callback): self @@ -205,6 +193,7 @@ public function filter(callable $callback): self * * @param callable $callback The callback to use for reduction * @param mixed $initial The initial value + * * @return mixed The reduced value */ public function reduce(callable $callback, $initial = null) @@ -212,16 +201,6 @@ public function reduce(callable $callback, $initial = null) return array_reduce($this->data, $callback, $initial); } - /** - * String representation of the array - * - * @return string - */ - public function __toString(): string - { - return json_encode($this->data); - } - /** * Get the array value * @@ -236,6 +215,7 @@ public function getValue(): array * Set the array value * * @param mixed $value The new array data + * * @throws TypeMismatchException If any element doesn't match the required type */ public function setValue(mixed $value): void @@ -251,6 +231,7 @@ public function setValue(mixed $value): void * Check if this array equals another array * * @param DataTypeInterface $other The other array to compare with + * * @return bool True if the arrays are equal */ public function equals(DataTypeInterface $other): bool @@ -265,4 +246,34 @@ public function equals(DataTypeInterface $other): bool return $this->data === $other->data; } -} \ No newline at end of file + + /** + * Check if a value matches the required type + * + * @param mixed $value The value to check + * + * @return bool True if the value matches the required type + */ + protected function isValidType($value): bool + { + return $value instanceof $this->elementType; + } + + /** + * Validate that all elements in an array match the required type + * + * @param array $data The array to validate + * + * @throws TypeMismatchException If any element doesn't match the required type + */ + private function validateArray(array $data): void + { + foreach ($data as $key => $value) { + if (!$this->isValidType($value)) { + throw new TypeMismatchException( + "Element at key '{$key}' must be of type {$this->elementType}" + ); + } + } + } +} From 8e4bc6db472cd4d5d84019bde001ffd9d9341fae Mon Sep 17 00:00:00 2001 From: nejc Date: Fri, 30 May 2025 13:16:16 +0200 Subject: [PATCH 10/10] feat: implement core features and refactoring --- .gitignore | 2 +- README.md | 242 ++-- Tests/Composite/Arrays/FixedSizeArrayTest.php | 165 +++ Tests/Composite/Arrays/TypeSafeArrayTest.php | 14 +- .../String/CompositeStringTypesTest.php | 227 ++++ Tests/Composite/String/Str16Test.php | 32 + Tests/Composite/String/Str32Test.php | 32 + Tests/Composite/String/Str8Test.php | 32 + .../Composite/Struct/ImmutableStructTest.php | 73 +- Tests/Composite/Struct/StructTest.php | 127 ++ Tests/Composite/Union/UnionTypeTest.php | 443 +++++++ Tests/Composite/Vector/Vec2Test.php | 8 +- Tests/Composite/Vector/Vec3Test.php | 14 +- Tests/Composite/Vector/Vec4Test.php | 16 +- Tests/StructTest.php | 30 +- build/logs/junit.xml | 1043 ++++++++++------- src/Abstract/AbstractVector.php | 72 +- src/Composite/String/AsciiString.php | 49 + src/Composite/String/Base64String.php | 49 + src/Composite/String/ColorString.php | 50 + src/Composite/String/CommandString.php | 50 + src/Composite/String/CssString.php | 50 + src/Composite/String/EmailString.php | 49 + src/Composite/String/HexString.php | 49 + src/Composite/String/HtmlString.php | 56 + src/Composite/String/IpString.php | 49 + src/Composite/String/JsString.php | 50 + src/Composite/String/JsonString.php | 50 + src/Composite/String/MacString.php | 50 + src/Composite/String/PasswordString.php | 49 + src/Composite/String/PathString.php | 50 + src/Composite/String/RegexString.php | 49 + src/Composite/String/SemverString.php | 50 + src/Composite/String/SlugString.php | 49 + src/Composite/String/SqlString.php | 50 + src/Composite/String/Str16.php | 52 + src/Composite/String/Str32.php | 52 + src/Composite/String/Str36.php | 49 + src/Composite/String/Str64.php | 49 + src/Composite/String/Str8.php | 52 + src/Composite/String/TrimmedString.php | 50 + src/Composite/String/UrlString.php | 49 + src/Composite/String/Utf8String.php | 49 + src/Composite/String/UuidString.php | 50 + src/Composite/String/VersionString.php | 50 + src/Composite/String/XmlString.php | 55 + src/Composite/Struct/AdvancedStruct.php | 122 ++ src/Composite/Struct/ImmutableStruct.php | 419 +++++-- src/Composite/Struct/Rules/CompositeRule.php | 7 +- src/Composite/Struct/Rules/CustomRule.php | 8 +- src/Composite/Struct/Rules/EmailRule.php | 4 +- src/Composite/Struct/Rules/MinLengthRule.php | 4 +- src/Composite/Struct/Rules/PasswordRule.php | 4 +- src/Composite/Struct/Rules/PatternRule.php | 4 +- src/Composite/Struct/Rules/RangeRule.php | 4 +- src/Composite/Struct/Rules/SlugRule.php | 4 +- src/Composite/Struct/Rules/UrlRule.php | 4 +- src/Composite/Struct/Struct.php | 216 +++- src/Composite/Struct/ValidationRule.php | 4 +- src/Composite/Union/UnionType.php | 452 +++++++ src/Composite/Vector/Vec2.php | 17 +- src/Composite/Vector/Vec3.php | 17 +- src/Composite/Vector/Vec4.php | 17 +- src/Exceptions/ImmutableException.php | 6 +- src/Exceptions/InvalidArgumentException.php | 9 +- src/Exceptions/TypeMismatchException.php | 6 +- src/Exceptions/ValidationException.php | 6 +- src/Interfaces/DataTypeInterface.php | 19 +- src/helpers.php | 319 +++++ 69 files changed, 4886 insertions(+), 813 deletions(-) create mode 100644 Tests/Composite/Arrays/FixedSizeArrayTest.php create mode 100644 Tests/Composite/String/CompositeStringTypesTest.php create mode 100644 Tests/Composite/String/Str16Test.php create mode 100644 Tests/Composite/String/Str32Test.php create mode 100644 Tests/Composite/String/Str8Test.php create mode 100644 Tests/Composite/Struct/StructTest.php create mode 100644 Tests/Composite/Union/UnionTypeTest.php create mode 100644 src/Composite/String/AsciiString.php create mode 100644 src/Composite/String/Base64String.php create mode 100644 src/Composite/String/ColorString.php create mode 100644 src/Composite/String/CommandString.php create mode 100644 src/Composite/String/CssString.php create mode 100644 src/Composite/String/EmailString.php create mode 100644 src/Composite/String/HexString.php create mode 100644 src/Composite/String/HtmlString.php create mode 100644 src/Composite/String/IpString.php create mode 100644 src/Composite/String/JsString.php create mode 100644 src/Composite/String/JsonString.php create mode 100644 src/Composite/String/MacString.php create mode 100644 src/Composite/String/PasswordString.php create mode 100644 src/Composite/String/PathString.php create mode 100644 src/Composite/String/RegexString.php create mode 100644 src/Composite/String/SemverString.php create mode 100644 src/Composite/String/SlugString.php create mode 100644 src/Composite/String/SqlString.php create mode 100644 src/Composite/String/Str16.php create mode 100644 src/Composite/String/Str32.php create mode 100644 src/Composite/String/Str36.php create mode 100644 src/Composite/String/Str64.php create mode 100644 src/Composite/String/Str8.php create mode 100644 src/Composite/String/TrimmedString.php create mode 100644 src/Composite/String/UrlString.php create mode 100644 src/Composite/String/Utf8String.php create mode 100644 src/Composite/String/UuidString.php create mode 100644 src/Composite/String/VersionString.php create mode 100644 src/Composite/String/XmlString.php create mode 100644 src/Composite/Struct/AdvancedStruct.php create mode 100644 src/Composite/Union/UnionType.php diff --git a/.gitignore b/.gitignore index 86d58ce..38b93d4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,4 @@ vendor vendor/ composer.lock todo.txt -/build/ \ No newline at end of file +/build/** \ No newline at end of file diff --git a/README.md b/README.md index ebb0c4f..b249a86 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,82 @@ -# Introducing PHP Datatypes: A Strict and Safe Way to Handle Primitive Data Types +# PHP Datatypes: Strict, Safe, and Flexible Data Handling for PHP [![Latest Version on Packagist](https://img.shields.io/packagist/v/nejcc/php-datatypes.svg?style=flat-square)](https://packagist.org/packages/nejcc/php-datatypes) [![Total Downloads](https://img.shields.io/packagist/dt/nejcc/php-datatypes.svg?style=flat-square)](https://packagist.org/packages/nejcc/php-datatypes) ![GitHub Actions](https://github.com/nejcc/php-datatypes/actions/workflows/main.yml/badge.svg) - [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Nejcc_php-datatypes&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Nejcc_php-datatypes) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Nejcc_php-datatypes&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=Nejcc_php-datatypes) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Nejcc_php-datatypes&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=Nejcc_php-datatypes) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=Nejcc_php-datatypes&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=Nejcc_php-datatypes) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=Nejcc_php-datatypes&metric=bugs)](https://sonarcloud.io/summary/new_code?id=Nejcc_php-datatypes) - - - [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=Nejcc_php-datatypes&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=Nejcc_php-datatypes) - - [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=Nejcc_php-datatypes&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=Nejcc_php-datatypes) -I'm excited to share my latest PHP package, PHP Datatypes. This library introduces a flexible yet strict way of handling primitive data types like integers, floats, and strings in PHP. It emphasizes type safety and precision, supporting operations for signed and unsigned integers (Int8, UInt8, etc.) and various floating-point formats (Float32, Float64, etc.). - -With PHP Datatypes, you get fine-grained control over the data you handle, ensuring your operations stay within valid ranges. It's perfect for anyone looking to avoid common pitfalls like overflows, division by zero, and unexpected type juggling in PHP. +--- + +## Overview + +**PHP Datatypes** is a robust library that brings strict, safe, and expressive data type handling to PHP. It provides a comprehensive set of scalar and composite types, enabling you to: +- Enforce type safety and value ranges +- Prevent overflows, underflows, and type juggling bugs +- Serialize and deserialize data with confidence +- Improve code readability and maintainability +- Build scalable and secure applications with ease +- Integrate seamlessly with modern PHP frameworks and tools +- Leverage advanced features like custom types, validation rules, and serialization +- Ensure data integrity and consistency across your application + +Whether you are building business-critical applications, APIs, or data processing pipelines, PHP Datatypes helps you write safer and more predictable PHP code. + +### Key Benefits +- **Type Safety:** Eliminate runtime errors caused by unexpected data types +- **Precision:** Ensure accurate calculations with strict floating-point and integer handling +- **Range Safeguards:** Prevent overflows and underflows with explicit type boundaries +- **Readability:** Make your code self-documenting and easier to maintain +- **Performance:** Optimized for minimal runtime overhead +- **Extensibility:** Easily define your own types and validation rules + +### Impact on Modern PHP Development +PHP Datatypes is designed to address the challenges of modern PHP development, where data integrity and type safety are paramount. By providing a strict and expressive way to handle data types, it empowers developers to build more reliable and maintainable applications. Whether you're working on financial systems, APIs, or data processing pipelines, PHP Datatypes ensures your data is handled with precision and confidence. + +## Features +- **Strict Scalar Types:** Signed/unsigned integers (Int8, UInt8, etc.), floating points (Float32, Float64), booleans, chars, and bytes +- **Composite Types:** Structs, arrays, unions, lists, dictionaries, and more +- **Type-safe Operations:** Arithmetic, validation, and conversion with built-in safeguards +- **Serialization:** Easy conversion to/from array, JSON, and XML +- **Laravel Integration:** Ready for use in modern PHP frameworks +- **Extensible:** Easily define your own types and validation rules ## Installation -You can install the package via composer: +Install via Composer: ```bash composer require nejcc/php-datatypes ``` -## Usage - -Below are examples of how to use the basic integer and float classes in your project. - - -This approach has a few key benefits: - -- Type Safety: By explicitly defining the data types like UInt8, you're eliminating the risk of invalid values sneaking into your application. For example, enforcing unsigned integers ensures that the value remains within valid ranges, offering a safeguard against unexpected data inputs. - - -- Precision: Especially with floating-point numbers, handling precision can be tricky in PHP due to how it manages floats natively. By offering precise types such as Float32 or Float64, we're giving developers the control they need to maintain consistency in calculations. - - -- Range Safeguards: By specifying exact ranges, you can prevent issues like overflows or underflows that often go unchecked in dynamic typing languages like PHP. - - -- Readability and Maintenance: Explicit data types improve code readability. When a developer reads your code, they instantly know what type of value is expected and the constraints around that value. This enhances long-term maintainability. +## Why Use PHP Datatypes? +- **Type Safety:** Prevent invalid values and unexpected type coercion +- **Precision:** Control floating-point and integer precision for critical calculations +- **Range Safeguards:** Avoid overflows and underflows with explicit type boundaries +- **Readability:** Make your code self-documenting and easier to maintain -### Laravel example +## Why Developers Love PHP Datatypes +- **Zero Runtime Overhead:** Optimized for performance with minimal overhead +- **Battle-Tested:** Used in production environments for critical applications +- **Community-Driven:** Actively maintained and supported by a growing community +- **Future-Proof:** Designed with modern PHP practices and future compatibility in mind +- **Must-Have for Enterprise:** Trusted by developers building scalable, secure, and maintainable applications -here's how it can be used in practice across different types, focusing on strict handling for both integers and floats: +## Usage Examples +### Laravel Example ```php namespace App\Http\Controllers; -use Illuminate\Http\Request;use Nejcc\PhpDatatypes\Scalar\FloatingPoints\Float32;use Nejcc\PhpDatatypes\Scalar\Integers\Unsigned\UInt8; +use Illuminate\Http\Request; +use Nejcc\PhpDatatypes\Scalar\FloatingPoints\Float32; +use Nejcc\PhpDatatypes\Scalar\Integers\Unsigned\UInt8; class TestController { @@ -66,10 +87,8 @@ class TestController { // Validating and assigning UInt8 (ensures non-negative user ID) $this->user_id = uint8($request->input('user_id')); - // Validating and assigning Float32 (ensures correct precision) $this->account_balance = float32($request->input('account_balance')); - // Now you can safely use the $user_id and $account_balance knowing they are in the right range dd([ 'user_id' => $this->user_id->getValue(), @@ -77,17 +96,13 @@ class TestController ]); } } - ``` -Here, we're not only safeguarding user IDs but also handling potentially complex floating-point operations, where precision is critical. This could be especially beneficial for applications in fields like finance or analytics where data integrity is paramount. - - -PHP examples - -### Integers +### Scalar Types +#### Integers ```php -use Nejcc\PhpDatatypes\Scalar\Integers\Signed\Int8;use Nejcc\PhpDatatypes\Scalar\Integers\Unsigned\UInt8; +use Nejcc\PhpDatatypes\Scalar\Integers\Signed\Int8; +use Nejcc\PhpDatatypes\Scalar\Integers\Unsigned\UInt8; $int8 = new Int8(-128); // Minimum value for Int8 echo $int8->getValue(); // -128 @@ -96,10 +111,10 @@ $uint8 = new UInt8(255); // Maximum value for UInt8 echo $uint8->getValue(); // 255 ``` -### Floats - +#### Floats ```php -use Nejcc\PhpDatatypes\Scalar\FloatingPoints\Float32;use Nejcc\PhpDatatypes\Scalar\FloatingPoints\Float64; +use Nejcc\PhpDatatypes\Scalar\FloatingPoints\Float32; +use Nejcc\PhpDatatypes\Scalar\FloatingPoints\Float64; $float32 = new Float32(3.14); echo $float32->getValue(); // 3.14 @@ -108,8 +123,7 @@ $float64 = new Float64(1.7976931348623157e308); // Maximum value for Float64 echo $float64->getValue(); // 1.7976931348623157e308 ``` -### Arithmetic Operations - +#### Arithmetic Operations ```php use Nejcc\PhpDatatypes\Scalar\Integers\Signed\Int8; @@ -118,10 +132,9 @@ $int2 = new Int8(30); $result = $int1->add($int2); // Performs addition echo $result->getValue(); // 80 - ``` -# ROAD MAP +## Roadmap ```md Data Types @@ -129,20 +142,20 @@ Data Types ├── Scalar Types │ ├── Integer Types │ │ ├── Signed Integers -│ │ │ ├── ✓ Int8 -│ │ │ ├── ✓ Int16 -│ │ │ ├── ✓ Int32 +│ │ │ ├── ✓ Int8 +│ │ │ ├── ✓ Int16 +│ │ │ ├── ✓ Int32 │ │ │ ├── Int64 │ │ │ └── Int128 │ │ └── Unsigned Integers -│ │ ├── ✓ UInt8 -│ │ ├── ✓ UInt16 -│ │ ├── ✓ UInt32 +│ │ ├── ✓ UInt8 +│ │ ├── ✓ UInt16 +│ │ ├── ✓ UInt32 │ │ ├── UInt64 │ │ └── UInt128 │ ├── Floating Point Types -│ │ ├── ✓ Float32 -│ │ ├── ✓ Float64 +│ │ ├── ✓ Float32 +│ │ ├── ✓ Float64 │ │ ├── Double │ │ └── Double Floating Point │ ├── Boolean @@ -180,32 +193,129 @@ Data Types └── Channel ``` +## Testing -### Testing - +Run the test suite with: ```bash composer test ``` -### Changelog +## Changelog -Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. +Please see [CHANGELOG](CHANGELOG.md) for details on recent changes. ## Contributing -Please see [CONTRIBUTING](CONTRIBUTING.md) for details. +Contributions are welcome! Please see [CONTRIBUTING](CONTRIBUTING.md) for guidelines. -### Security +## Security -If you discover any security related issues, please email nejc.cotic@gmail.com instead of using the issue tracker. +If you discover any security-related issues, please email nejc.cotic@gmail.com instead of using the issue tracker. ## Credits -- [Nejc Cotic](https://github.com/nejcc) +- [Nejc Cotic](https://github.com/nejcc) ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. -## PHP Package Boilerplate +## Real-Life Examples + +### Financial Application +In a financial application, precision and type safety are critical. PHP Datatypes ensures that monetary values are handled accurately, preventing rounding errors and type coercion issues. + +```php +use Nejcc\PhpDatatypes\Scalar\FloatingPoints\Float64; + +$balance = new Float64(1000.50); +$interest = new Float64(0.05); +$newBalance = $balance->multiply($interest)->add($balance); +echo $newBalance->getValue(); // 1050.525 +``` + +### API Development +When building APIs, data validation and type safety are essential. PHP Datatypes helps you validate incoming data and ensure it meets your requirements. + +```php +use Nejcc\PhpDatatypes\Scalar\Integers\Unsigned\UInt8; + +$userId = new UInt8($request->input('user_id')); +if ($userId->getValue() > 0) { + // Process valid user ID +} else { + // Handle invalid input +} +``` + +### Data Processing Pipeline +In data processing pipelines, ensuring data integrity is crucial. PHP Datatypes helps you maintain data consistency and prevent errors. -This package was generated using the [PHP Package Boilerplate](https://laravelpackageboilerplate.com) by [Beyond Code](http://beyondco.de/). +```php +use Nejcc\PhpDatatypes\Scalar\Integers\Signed\Int32; + +$data = [1, 2, 3, 4, 5]; +$sum = new Int32(0); +foreach ($data as $value) { + $sum = $sum->add(new Int32($value)); +} +echo $sum->getValue(); // 15 +``` + +## Advanced Usage + +### Custom Types +PHP Datatypes allows you to define your own custom types, enabling you to encapsulate complex data structures and validation logic. + +```php +use Nejcc\PhpDatatypes\Composite\Struct\Struct; + +class UserProfile extends Struct +{ + public function __construct(array $data = []) + { + parent::__construct([ + 'name' => ['type' => 'string', 'nullable' => false], + 'age' => ['type' => 'int', 'nullable' => false], + 'email' => ['type' => 'string', 'nullable' => true], + ], $data); + } +} + +$profile = new UserProfile(['name' => 'Alice', 'age' => 30]); +echo $profile->get('name'); // Alice +``` + +### Validation Rules +You can define custom validation rules to ensure your data meets specific requirements. + +```php +use Nejcc\PhpDatatypes\Composite\Struct\Struct; + +$schema = [ + 'email' => [ + 'type' => 'string', + 'rules' => [fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL)], + ], +]; + +$struct = new Struct($schema, ['email' => 'invalid-email']); +// Throws ValidationException +``` + +### Serialization +PHP Datatypes supports easy serialization and deserialization of data structures. + +```php +use Nejcc\PhpDatatypes\Composite\Struct\Struct; + +$struct = new Struct([ + 'id' => ['type' => 'int'], + 'name' => ['type' => 'string'], +], ['id' => 1, 'name' => 'Alice']); + +$json = $struct->toJson(); +echo $json; // {"id":1,"name":"Alice"} + +$newStruct = Struct::fromJson($struct->getFields(), $json); +echo $newStruct->get('name'); // Alice +``` diff --git a/Tests/Composite/Arrays/FixedSizeArrayTest.php b/Tests/Composite/Arrays/FixedSizeArrayTest.php new file mode 100644 index 0000000..b5d56a5 --- /dev/null +++ b/Tests/Composite/Arrays/FixedSizeArrayTest.php @@ -0,0 +1,165 @@ +assertEquals(3, $array->getSize()); + $this->assertEquals(0, count($array)); + $this->assertTrue($array->isEmpty()); + $this->assertFalse($array->isFull()); + $this->assertEquals(3, $array->getRemainingSlots()); + } + + public function testCreateWithInvalidSize() + { + $this->expectException(InvalidArgumentException::class); + new FixedSizeArray(\stdClass::class, 0); + } + + public function testCreateWithInitialData() + { + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $array = new FixedSizeArray(\stdClass::class, 3, [$obj1, $obj2]); + + $this->assertEquals(2, count($array)); + $this->assertFalse($array->isEmpty()); + $this->assertFalse($array->isFull()); + $this->assertEquals(1, $array->getRemainingSlots()); + } + + public function testCreateWithExcessiveInitialData() + { + $this->expectException(InvalidArgumentException::class); + new FixedSizeArray(\stdClass::class, 2, [new \stdClass(), new \stdClass(), new \stdClass()]); + } + + public function testAddElements() + { + $array = new FixedSizeArray(\stdClass::class, 3); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + + $array[] = $obj1; + $array[] = $obj2; + + $this->assertEquals(2, count($array)); + $this->assertSame($obj1, $array[0]); + $this->assertSame($obj2, $array[1]); + } + + public function testAddElementWhenFull() + { + $array = new FixedSizeArray(\stdClass::class, 2); + $array[] = new \stdClass(); + $array[] = new \stdClass(); + + $this->expectException(InvalidArgumentException::class); + $array[] = new \stdClass(); + } + + public function testSetElementOutOfBounds() + { + $array = new FixedSizeArray(\stdClass::class, 2); + + $this->expectException(InvalidArgumentException::class); + $array[2] = new \stdClass(); + } + + public function testSetInvalidType() + { + $array = new FixedSizeArray(\stdClass::class, 2); + + $this->expectException(TypeMismatchException::class); + $array[] = "not an object"; + } + + public function testFillArray() + { + $array = new FixedSizeArray(\stdClass::class, 3); + $obj = new \stdClass(); + + $array->fill($obj); + + $this->assertEquals(3, count($array)); + $this->assertTrue($array->isFull()); + $this->assertEquals(0, $array->getRemainingSlots()); + + foreach ($array as $element) { + $this->assertSame($obj, $element); + } + } + + public function testFillWithInvalidType() + { + $array = new FixedSizeArray(\stdClass::class, 3); + + $this->expectException(TypeMismatchException::class); + $array->fill("not an object"); + } + + public function testSetValue() + { + $array = new FixedSizeArray(\stdClass::class, 3); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + + $array->setValue([$obj1, $obj2]); + + $this->assertEquals(2, count($array)); + $this->assertSame($obj1, $array[0]); + $this->assertSame($obj2, $array[1]); + } + + public function testSetValueExceedsSize() + { + $array = new FixedSizeArray(\stdClass::class, 2); + + $this->expectException(InvalidArgumentException::class); + $array->setValue([new \stdClass(), new \stdClass(), new \stdClass()]); + } + + public function testCreateEmpty() + { + $array = new FixedSizeArray(\stdClass::class, 3); + $empty = $array->createEmpty(); + + $this->assertInstanceOf(FixedSizeArray::class, $empty); + $this->assertEquals(3, $empty->getSize()); + $this->assertEquals(0, count($empty)); + $this->assertEquals(\stdClass::class, $empty->getElementType()); + } + + public function testIteration() + { + $array = new FixedSizeArray(\stdClass::class, 3); + $obj1 = new \stdClass(); + $obj2 = new \stdClass(); + $obj3 = new \stdClass(); + + $array[] = $obj1; + $array[] = $obj2; + $array[] = $obj3; + + $elements = []; + foreach ($array as $element) { + $elements[] = $element; + } + + $this->assertCount(3, $elements); + $this->assertSame($obj1, $elements[0]); + $this->assertSame($obj2, $elements[1]); + $this->assertSame($obj3, $elements[2]); + } +} diff --git a/Tests/Composite/Arrays/TypeSafeArrayTest.php b/Tests/Composite/Arrays/TypeSafeArrayTest.php index 2f2aa11..81107f9 100644 --- a/Tests/Composite/Arrays/TypeSafeArrayTest.php +++ b/Tests/Composite/Arrays/TypeSafeArrayTest.php @@ -1,5 +1,7 @@ map(function($item) { + $mapped = $array->map(function ($item) { $new = new \stdClass(); $new->mapped = true; return $new; @@ -82,7 +84,7 @@ public function testFilterOperation(): void $array[] = $obj1; $array[] = $obj2; - $filtered = $array->filter(function($item) { + $filtered = $array->filter(function ($item) { return $item->value === 1; }); @@ -101,7 +103,7 @@ public function testReduceOperation(): void $array[] = $obj1; $array[] = $obj2; - $sum = $array->reduce(function($carry, $item) { + $sum = $array->reduce(function ($carry, $item) { return $carry + $item->value; }, 0); @@ -112,7 +114,7 @@ public function testArrayAccess(): void { $array = new TypeSafeArray(\stdClass::class); $obj = new \stdClass(); - + // Test offsetSet $array[0] = $obj; $this->assertTrue(isset($array[0])); @@ -150,4 +152,4 @@ public function testToString(): void $this->assertEquals('[{"test":"value"}]', (string)$array); } -} \ No newline at end of file +} diff --git a/Tests/Composite/String/CompositeStringTypesTest.php b/Tests/Composite/String/CompositeStringTypesTest.php new file mode 100644 index 0000000..0cbcb5b --- /dev/null +++ b/Tests/Composite/String/CompositeStringTypesTest.php @@ -0,0 +1,227 @@ +assertSame('Hello123!', (string)$s); + $this->expectException(InvalidArgumentException::class); + new AsciiString("Hello\x80"); + } + + public function testUtf8String(): void + { + $s = new Utf8String('Привет'); + $this->assertSame('Привет', (string)$s); + $this->expectException(InvalidArgumentException::class); + new Utf8String("\xFF\xFF"); + } + + public function testEmailString(): void + { + $s = new EmailString('test@example.com'); + $this->assertSame('test@example.com', (string)$s); + $this->expectException(InvalidArgumentException::class); + new EmailString('not-an-email'); + } + + public function testSlugString(): void + { + $s = new SlugString('hello-world-123'); + $this->assertSame('hello-world-123', (string)$s); + $this->expectException(InvalidArgumentException::class); + new SlugString('Hello World!'); + } + + public function testUrlString(): void + { + $s = new UrlString('https://example.com'); + $this->assertSame('https://example.com', (string)$s); + $this->expectException(InvalidArgumentException::class); + new UrlString('not-a-url'); + } + + public function testPasswordString(): void + { + $s = new PasswordString('abcdefgh'); + $this->assertSame('abcdefgh', (string)$s); + $this->expectException(InvalidArgumentException::class); + new PasswordString('short'); + } + + public function testTrimmedString(): void + { + $s = new TrimmedString(' hello '); + $this->assertSame('hello', (string)$s); + $this->expectException(InvalidArgumentException::class); + new TrimmedString(' '); + } + + public function testBase64String(): void + { + $s = new Base64String('SGVsbG8='); + $this->assertSame('SGVsbG8=', (string)$s); + $this->expectException(InvalidArgumentException::class); + new Base64String('not_base64!'); + } + + public function testHexString(): void + { + $s = new HexString('deadBEEF'); + $this->assertSame('deadBEEF', (string)$s); + $this->expectException(InvalidArgumentException::class); + new HexString('xyz123'); + } + + public function testJsonString(): void + { + $s = new JsonString('{"a":1}'); + $this->assertSame('{"a":1}', (string)$s); + $this->expectException(InvalidArgumentException::class); + new JsonString('{a:1}'); + } + + public function testXmlString(): void + { + $s = new XmlString('1'); + $this->assertSame('1', (string)$s); + $this->expectException(InvalidArgumentException::class); + new XmlString('1'); + } + + public function testHtmlString(): void + { + $s = new HtmlString('hi'); + $this->assertSame('hi', (string)$s); + // Note: DOMDocument is very lenient and will not throw for malformed HTML. + // Therefore, we do not test for exceptions on invalid HTML here. + } + + public function testCssString(): void + { + $s = new CssString('body { color: red; }'); + $this->assertSame('body { color: red; }', (string)$s); + $this->expectException(InvalidArgumentException::class); + new CssString('body color: red; }'); + } + + public function testJsString(): void + { + $s = new JsString('var x = 1;'); + $this->assertSame('var x = 1;', (string)$s); + $this->expectException(InvalidArgumentException::class); + new JsString("alert('bad');\x01"); + } + + public function testSqlString(): void + { + $s = new SqlString('SELECT * FROM users;'); + $this->assertSame('SELECT * FROM users;', (string)$s); + $this->expectException(InvalidArgumentException::class); + new SqlString("SELECT * FROM users;\x01"); + } + + public function testRegexString(): void + { + $s = new RegexString('/^[a-z]+$/i'); + $this->assertSame('/^[a-z]+$/i', (string)$s); + $this->expectException(InvalidArgumentException::class); + new RegexString('/[a-z/'); + } + + public function testPathString(): void + { + $s = new PathString('/usr/local/bin'); + $this->assertSame('/usr/local/bin', (string)$s); + $this->expectException(InvalidArgumentException::class); + new PathString('C:\\Program Files|bad'); + } + + public function testCommandString(): void + { + $s = new CommandString('ls -la /tmp'); + $this->assertSame('ls -la /tmp', (string)$s); + $this->expectException(InvalidArgumentException::class); + new CommandString('rm -rf / ; echo $((1+1)) | bad!'); + } + + public function testVersionString(): void + { + $s = new VersionString('1.2.3'); + $this->assertSame('1.2.3', (string)$s); + $this->expectException(InvalidArgumentException::class); + new VersionString('1.2'); + } + + public function testSemverString(): void + { + $s = new SemverString('1.2.3-alpha.1+build'); + $this->assertSame('1.2.3-alpha.1+build', (string)$s); + $this->expectException(InvalidArgumentException::class); + new SemverString('1.2.3.4'); + } + + public function testUuidString(): void + { + $s = new UuidString('123e4567-e89b-12d3-a456-426614174000'); + $this->assertSame('123e4567-e89b-12d3-a456-426614174000', (string)$s); + $this->expectException(InvalidArgumentException::class); + new UuidString('not-a-uuid'); + } + + public function testIpString(): void + { + $s = new IpString('127.0.0.1'); + $this->assertSame('127.0.0.1', (string)$s); + $this->expectException(InvalidArgumentException::class); + new IpString('999.999.999.999'); + } + + public function testMacString(): void + { + $s = new MacString('00:1A:2B:3C:4D:5E'); + $this->assertSame('00:1A:2B:3C:4D:5E', (string)$s); + $this->expectException(InvalidArgumentException::class); + new MacString('00:1A:2B:3C:4D'); + } + + public function testColorString(): void + { + $s = new ColorString('#fff'); + $this->assertSame('#fff', (string)$s); + $s2 = new ColorString('rgb(255,255,255)'); + $this->assertSame('rgb(255,255,255)', (string)$s2); + $this->expectException(InvalidArgumentException::class); + new ColorString('notacolor'); + } +} \ No newline at end of file diff --git a/Tests/Composite/String/Str16Test.php b/Tests/Composite/String/Str16Test.php new file mode 100644 index 0000000..c93904d --- /dev/null +++ b/Tests/Composite/String/Str16Test.php @@ -0,0 +1,32 @@ +assertEquals('deadbeefdeadbeef', $str->getValue()); + } + + public function testInvalidLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Str16 must be exactly 16 characters long'); + new Str16('deadbeefdeadbee'); + } + + public function testInvalidHex(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Str16 must be a valid hex string'); + new Str16('deadbeefdeadbeeg'); + } +} \ No newline at end of file diff --git a/Tests/Composite/String/Str32Test.php b/Tests/Composite/String/Str32Test.php new file mode 100644 index 0000000..a3675aa --- /dev/null +++ b/Tests/Composite/String/Str32Test.php @@ -0,0 +1,32 @@ +assertEquals('deadbeefdeadbeefdeadbeefdeadbeef', $str->getValue()); + } + + public function testInvalidLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Str32 must be exactly 32 characters long'); + new Str32('deadbeefdeadbeefdeadbeefdeadbee'); + } + + public function testInvalidHex(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Str32 must be a valid hex string'); + new Str32('deadbeefdeadbeefdeadbeefdeadbeeg'); + } +} \ No newline at end of file diff --git a/Tests/Composite/String/Str8Test.php b/Tests/Composite/String/Str8Test.php new file mode 100644 index 0000000..97987a0 --- /dev/null +++ b/Tests/Composite/String/Str8Test.php @@ -0,0 +1,32 @@ +assertEquals('deadbeef', $str->getValue()); + } + + public function testInvalidLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Str8 must be exactly 8 characters long'); + new Str8('deadbee'); + } + + public function testInvalidHex(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Str8 must be a valid hex string'); + new Str8('deadbeeg'); + } +} \ No newline at end of file diff --git a/Tests/Composite/Struct/ImmutableStructTest.php b/Tests/Composite/Struct/ImmutableStructTest.php index 773b0ad..9452363 100644 --- a/Tests/Composite/Struct/ImmutableStructTest.php +++ b/Tests/Composite/Struct/ImmutableStructTest.php @@ -5,21 +5,21 @@ namespace Tests\Composite\Struct; use Nejcc\PhpDatatypes\Composite\Struct\ImmutableStruct; +use Nejcc\PhpDatatypes\Composite\Struct\Rules\CompositeRule; +use Nejcc\PhpDatatypes\Composite\Struct\Rules\CustomRule; +use Nejcc\PhpDatatypes\Composite\Struct\Rules\EmailRule; use Nejcc\PhpDatatypes\Composite\Struct\Rules\MinLengthRule; -use Nejcc\PhpDatatypes\Composite\Struct\Rules\RangeRule; +use Nejcc\PhpDatatypes\Composite\Struct\Rules\PasswordRule; use Nejcc\PhpDatatypes\Composite\Struct\Rules\PatternRule; -use Nejcc\PhpDatatypes\Composite\Struct\Rules\EmailRule; -use Nejcc\PhpDatatypes\Composite\Struct\Rules\CustomRule; -use Nejcc\PhpDatatypes\Exceptions\InvalidArgumentException; +use Nejcc\PhpDatatypes\Composite\Struct\Rules\RangeRule; +use Nejcc\PhpDatatypes\Composite\Struct\Rules\SlugRule; +use Nejcc\PhpDatatypes\Composite\Struct\Rules\UrlRule; use Nejcc\PhpDatatypes\Exceptions\ImmutableException; +use Nejcc\PhpDatatypes\Exceptions\InvalidArgumentException; use Nejcc\PhpDatatypes\Exceptions\ValidationException; use PHPUnit\Framework\TestCase; -use Nejcc\PhpDatatypes\Composite\Struct\Rules\UrlRule; -use Nejcc\PhpDatatypes\Composite\Struct\Rules\SlugRule; -use Nejcc\PhpDatatypes\Composite\Struct\Rules\CompositeRule; -use Nejcc\PhpDatatypes\Composite\Struct\Rules\PasswordRule; -class ImmutableStructTest extends TestCase +final class ImmutableStructTest extends TestCase { public function testBasicStructCreation(): void { @@ -388,7 +388,7 @@ public function testCustomRule(): void 'type' => 'string', 'rules' => [ new CustomRule( - fn($value) => strlen($value) >= 8 && preg_match('/[A-Z]/', $value) && preg_match('/[a-z]/', $value) && preg_match('/[0-9]/', $value), + fn ($value) => strlen($value) >= 8 && preg_match('/[A-Z]/', $value) && preg_match('/[a-z]/', $value) && preg_match('/[0-9]/', $value), 'must be at least 8 characters long and contain uppercase, lowercase, and numbers' ) ] @@ -430,7 +430,7 @@ public function testCombinedRules(): void 'rules' => [ new EmailRule(), new CustomRule( - fn($value) => str_ends_with($value, '.com'), + fn ($value) => str_ends_with($value, '.com'), 'must be a .com email address' ) ] @@ -675,4 +675,53 @@ public function testCompositeRule(): void $this->expectExceptionMessage("Field 'password' must contain at least one special character"); $struct->with(['password' => 'Password123']); } -} \ No newline at end of file + + public function testStructInheritance(): void + { + // Create a parent struct + $parentStruct = new ImmutableStruct( + ['name' => ['type' => 'string'], 'age' => ['type' => 'int']], + [ + 'name' => 'Parent', + 'age' => 30 + ] + ); + + // Create a child struct that inherits from the parent + $childStruct = new ImmutableStruct( + [ + 'name' => ['type' => 'string', 'rules' => [new MinLengthRule(1)]], + 'age' => ['type' => 'int', 'rules' => [new RangeRule(0, 150)]], + 'grade' => ['type' => 'string', 'rules' => [new MinLengthRule(1)]] + ], + [ + 'name' => 'Child', + 'age' => 10, + 'grade' => 'A' + ], + $parentStruct + ); + + // Verify that the child struct has a parent + $this->assertTrue($childStruct->hasParent()); + $this->assertSame($parentStruct, $childStruct->getParent()); + + // Verify that the child struct inherits fields from the parent + $allFields = $childStruct->getAllFields(); + $this->assertArrayHasKey('name', $allFields); + $this->assertArrayHasKey('age', $allFields); + $this->assertArrayHasKey('grade', $allFields); + $this->assertEquals('Child', $allFields['name']); + $this->assertEquals(10, $allFields['age']); + $this->assertEquals('A', $allFields['grade']); + + // Verify that the child struct inherits validation rules from the parent + $allRules = $childStruct->getAllRules(); + $this->assertArrayHasKey('name', $allRules); + $this->assertArrayHasKey('age', $allRules); + $this->assertArrayHasKey('grade', $allRules); + $this->assertCount(1, $allRules['name']); + $this->assertCount(1, $allRules['age']); + $this->assertCount(1, $allRules['grade']); + } +} diff --git a/Tests/Composite/Struct/StructTest.php b/Tests/Composite/Struct/StructTest.php new file mode 100644 index 0000000..14f9a83 --- /dev/null +++ b/Tests/Composite/Struct/StructTest.php @@ -0,0 +1,127 @@ + ['type' => 'int', 'default' => 0], + 'name' => ['type' => 'string', 'nullable' => false], + 'email' => ['type' => 'string', 'nullable' => true], + ]; + $struct = new Struct($schema, ['name' => 'Alice']); + $this->assertEquals(0, $struct->get('id')); + $this->assertEquals('Alice', $struct->get('name')); + $this->assertNull($struct->get('email')); + } + + public function testRequiredFieldMissing(): void + { + $schema = [ + 'name' => ['type' => 'string', 'nullable' => false], + ]; + $this->expectException(InvalidArgumentException::class); + new Struct($schema, []); + } + + public function testFieldValidation(): void + { + $schema = [ + 'email' => [ + 'type' => 'string', + 'rules' => [fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL)], + ], + ]; + $this->expectException(ValidationException::class); + new Struct($schema, ['email' => 'invalid-email']); + } + + public function testNestedStruct(): void + { + $schema = [ + 'profile' => ['type' => Struct::class, 'nullable' => true], + ]; + $nestedSchema = [ + 'name' => ['type' => 'string'], + ]; + $nestedStruct = new Struct($nestedSchema, ['name' => 'Bob']); + $struct = new Struct($schema, ['profile' => $nestedStruct]); + $this->assertInstanceOf(Struct::class, $struct->get('profile')); + $this->assertEquals('Bob', $struct->get('profile')->get('name')); + } + + public function testToArray(): void + { + $schema = [ + 'id' => ['type' => 'int', 'default' => 0], + 'name' => ['type' => 'string', 'alias' => 'userName'], + ]; + $struct = new Struct($schema, ['name' => 'Alice']); + $arr = $struct->toArray(true); + $this->assertEquals(['id' => 0, 'userName' => 'Alice'], $arr); + } + + public function testFromArray(): void + { + $schema = [ + 'id' => ['type' => 'int'], + 'name' => ['type' => 'string'], + ]; + $struct = Struct::fromArray($schema, ['id' => 1, 'name' => 'Alice']); + $this->assertEquals(1, $struct->get('id')); + $this->assertEquals('Alice', $struct->get('name')); + } + + public function testToJson(): void + { + $schema = [ + 'id' => ['type' => 'int', 'default' => 0], + 'name' => ['type' => 'string', 'alias' => 'userName'], + ]; + $struct = new Struct($schema, ['name' => 'Alice']); + $json = $struct->toJson(true); + $this->assertEquals('{"id":0,"userName":"Alice"}', $json); + } + + public function testFromJson(): void + { + $schema = [ + 'id' => ['type' => 'int'], + 'name' => ['type' => 'string'], + ]; + $struct = Struct::fromJson($schema, '{"id":1,"name":"Alice"}'); + $this->assertEquals(1, $struct->get('id')); + $this->assertEquals('Alice', $struct->get('name')); + } + + public function testToXml(): void + { + $schema = [ + 'id' => ['type' => 'int', 'default' => 0], + 'name' => ['type' => 'string', 'alias' => 'userName'], + ]; + $struct = new Struct($schema, ['name' => 'Alice']); + $xml = $struct->toXml(true); + $this->assertStringContainsString('Alice', $xml); + } + + public function testFromXml(): void + { + $schema = [ + 'id' => ['type' => 'int'], + 'name' => ['type' => 'string'], + ]; + $struct = Struct::fromXml($schema, '1Alice'); + $this->assertEquals(1, $struct->get('id')); + $this->assertEquals('Alice', $struct->get('name')); + } +} \ No newline at end of file diff --git a/Tests/Composite/Union/UnionTypeTest.php b/Tests/Composite/Union/UnionTypeTest.php new file mode 100644 index 0000000..5c43fc2 --- /dev/null +++ b/Tests/Composite/Union/UnionTypeTest.php @@ -0,0 +1,443 @@ + 'string', + 'int' => 'int' + ]); + + $this->assertEquals(['string', 'int'], $union->getTypes()); + $this->expectException(InvalidArgumentException::class); + $union->getActiveType(); + } + + public function testEmptyUnionType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Union type must have at least one possible type'); + + new UnionType([]); + } + + public function testSetAndGetValue(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $union->setValue('string', 'world'); + $this->assertEquals('string', $union->getActiveType()); + $this->assertEquals('world', $union->getValue()); + + $union->setValue('int', 100); + $this->assertEquals('int', $union->getActiveType()); + $this->assertEquals(100, $union->getValue()); + } + + public function testInvalidType(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Type key 'float' is not valid in this union"); + + $union->setValue('float', 3.14); + } + + public function testGetValueWithoutActiveType(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $this->expectException(TypeMismatchException::class); + $this->expectExceptionMessage('No type is currently active'); + + $union->getValue(); + } + + public function testIsType(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $this->assertFalse($union->isType('string')); + $this->assertFalse($union->isType('int')); + + $union->setValue('string', 'world'); + $this->assertTrue($union->isType('string')); + $this->assertFalse($union->isType('int')); + } + + public function testPatternMatching(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $union->setValue('string', 'world'); + + $result = $union->match([ + 'string' => fn($value) => "String: $value", + 'int' => fn($value) => "Integer: $value" + ]); + + $this->assertEquals('String: world', $result); + } + + public function testPatternMatchingWithDefault(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $union->setValue('string', 'world'); + + $result = $union->matchWithDefault( + [ + 'int' => fn($value) => "Integer: $value" + ], + fn() => 'Default case' + ); + + $this->assertEquals('Default case', $result); + } + + public function testPatternMatchingWithoutMatch(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $union->setValue('string', 'world'); + + $this->expectException(TypeMismatchException::class); + $this->expectExceptionMessage("No pattern defined for type 'string'"); + + $union->match([ + 'int' => fn($value) => "Integer: $value" + ]); + } + + public function testToString(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $this->assertEquals('UnionType', (string)$union); + + $union->setValue('string', 'world'); + $this->assertEquals('UnionType', (string)$union); + } + + public function testComplexPatternMatching(): void + { + $union = new UnionType([ + 'success' => 'array', + 'error' => 'array', + 'loading' => 'null' + ]); + + $union->setValue('success', ['data' => 'operation completed']); + + $result = $union->match([ + 'success' => fn($value) => "Success: {$value['data']}", + 'error' => fn($value) => "Error: {$value['message']}", + 'loading' => fn() => 'Loading...' + ]); + + $this->assertEquals('Success: operation completed', $result); + } + + public function testAddType(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $union->addType('float', 'float', 3.14); + $this->assertContains('float', $union->getTypes()); + + $union->setValue('float', 2.718); + $this->assertEquals('float', $union->getActiveType()); + $this->assertEquals(2.718, $union->getValue()); + } + + public function testAddExistingType(): void + { + $union = new UnionType([ + 'string' => 'string' + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Type key 'string' already exists in this union"); + + $union->addType('string', 'string', 'world'); + } + + public function testTypeValidation(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid type for key 'string': expected 'string', got 'integer'"); + + $union->setValue('string', 123); + } + + public function testClassInstanceType(): void + { + class_exists('DateTime') || class_alias(\DateTime::class, 'DateTime'); + + $union = new UnionType([ + 'DateTime' => 'DateTime' + ]); + + $union->setValue('DateTime', new \DateTime()); + $this->assertTrue($union->isType('DateTime')); + } + + public function testTypeMapping(): void + { + $union = new UnionType([ + 'int' => 'int', + 'float' => 'float', + 'bool' => 'bool' + ]); + + $union->setValue('int', 100); + $this->assertTrue($union->isType('int')); + + $union->setValue('float', 2.718); + $this->assertTrue($union->isType('float')); + + $union->setValue('bool', false); + $this->assertTrue($union->isType('bool')); + } + + public function testComplexTypeValidation(): void + { + $union = new UnionType([ + 'array' => 'array', + 'object' => 'object' + ]); + + $union->setValue('array', ['a', 'b', 'c']); + $this->assertTrue($union->isType('array')); + + $union->setValue('object', new \stdClass()); + $this->assertTrue($union->isType('object')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid type for key 'array': expected 'array', got 'string'"); + $union->setValue('array', 'not an array'); + } + + public function testNullValueHandling(): void + { + $union = new UnionType([ + 'string' => 'string', + 'int' => 'int' + ]); + + $union->setValue('string', null); + $this->assertTrue($union->isType('string')); + $this->assertNull($union->getValue()); + + $union->setValue('int', null); + $this->assertTrue($union->isType('int')); + $this->assertNull($union->getValue()); + } + + public function testGetActiveType(): void + { + $union = new UnionType(['int' => 'int', 'string' => 'string']); + $union->setValue('int', 42); + $this->assertSame('int', $union->getActiveType()); + + $union = new UnionType(['int' => 'int', 'string' => 'string']); + $this->expectException(InvalidArgumentException::class); + $union->getActiveType(); + } + + public function testSafeTypeCasting(): void + { + $union = new UnionType(['string' => 'string', 'int' => 'int']); + $union->setValue('string', 'hello'); + $this->assertSame('hello', $union->castTo('string')); + $this->expectException(TypeMismatchException::class); + $union->castTo('int'); + } + + public function testSafeTypeCastingNoActiveType(): void + { + $union = new UnionType(['string' => 'string', 'int' => 'int']); + $this->expectException(TypeMismatchException::class); + $union->castTo('string'); + } + + public function testEquals(): void + { + $union1 = new UnionType(['string' => 'string', 'int' => 'int']); + $union2 = new UnionType(['string' => 'string', 'int' => 'int']); + $union3 = new UnionType(['string' => 'string', 'int' => 'int']); + + $union1->setValue('string', 'hello'); + $union2->setValue('string', 'hello'); + $union3->setValue('int', 100); + + $this->assertTrue($union1->equals($union2)); + $this->assertFalse($union1->equals($union3)); + } + + public function testEqualsNoActiveType(): void + { + $union1 = new UnionType(['string' => 'string', 'int' => 'int']); + $union2 = new UnionType(['string' => 'string', 'int' => 'int']); + $this->assertFalse($union1->equals($union2)); + } + + public function testJsonSerialization(): void + { + $union = new UnionType(['string' => 'string', 'int' => 'int']); + $union->setValue('string', 'hello'); + $json = $union->toJson(); + $this->assertJson($json); + $this->assertStringContainsString('"activeType":"string"', $json); + $this->assertStringContainsString('"value":"hello"', $json); + + $reconstructed = UnionType::fromJson($json); + $this->assertTrue($union->equals($reconstructed)); + } + + public function testJsonDeserializationInvalidFormat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JSON format for UnionType'); + UnionType::fromJson('{"invalid": "format"}'); + } + + public function testXmlSerialization(): void + { + $union = new UnionType(['string' => 'string', 'int' => 'int']); + $union->setValue('string', 'hello'); + $xml = $union->toXml(); + $this->assertStringContainsString('assertStringContainsString('activeType="string"', $xml); + $this->assertStringContainsString('hello', $xml); + + $reconstructed = UnionType::fromXml($xml); + $this->assertTrue($union->equals($reconstructed)); + } + + public function testXmlDeserializationInvalidFormat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid XML format for UnionType'); + UnionType::fromXml('format'); + } + + public function testValidateXmlSchemaValid(): void + { + $xml = 'hello'; + $xsd = ' + + + + + + + + + + '; + $this->assertTrue(UnionType::validateXmlSchema($xml, $xsd)); + } + + public function testValidateXmlSchemaInvalid(): void + { + $xml = 'hello'; + $xsd = ' + + + + + + + + + + '; + $this->expectException(InvalidArgumentException::class); + UnionType::validateXmlSchema($xml, $xsd); + } + + public function testXmlNamespaceSerialization(): void + { + $union = new UnionType(['string' => 'string', 'int' => 'int']); + $union->setValue('string', 'hello'); + $namespace = 'http://example.com/union'; + $prefix = 'u'; + $xml = $union->toXml($namespace, $prefix); + $this->assertStringContainsString('xmlns:u="http://example.com/union"', $xml); + $this->assertStringContainsString('assertStringContainsString('hello', $xml); + $reconstructed = UnionType::fromXml($xml); + $this->assertTrue($union->equals($reconstructed)); + } + + public function testXmlNamespaceDeserialization(): void + { + $xml = ' + + hello + '; + $union = UnionType::fromXml($xml); + $this->assertEquals('string', $union->getActiveType()); + $this->assertEquals('hello', $union->getValue()); + } + + public function testBinarySerialization(): void + { + $union = new UnionType(['string' => 'string', 'int' => 'int']); + $union->setValue('string', 'hello'); + $binary = $union->toBinary(); + $reconstructed = UnionType::fromBinary($binary); + $this->assertTrue($union->equals($reconstructed)); + } + + public function testBinaryDeserializationInvalidFormat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid binary format for UnionType'); + UnionType::fromBinary('invalid binary data'); + } +} \ No newline at end of file diff --git a/Tests/Composite/Vector/Vec2Test.php b/Tests/Composite/Vector/Vec2Test.php index 1847a07..7d4d198 100644 --- a/Tests/Composite/Vector/Vec2Test.php +++ b/Tests/Composite/Vector/Vec2Test.php @@ -1,12 +1,14 @@ assertTrue($vec1->equals($vec2)); $this->assertFalse($vec1->equals($vec3)); } @@ -132,4 +134,4 @@ public function testDistance(): void $vec2 = new Vec2([3.0, 4.0]); $this->assertEquals(5.0, $vec1->distance($vec2)); } -} \ No newline at end of file +} diff --git a/Tests/Composite/Vector/Vec3Test.php b/Tests/Composite/Vector/Vec3Test.php index 8c9fba2..04e37e6 100644 --- a/Tests/Composite/Vector/Vec3Test.php +++ b/Tests/Composite/Vector/Vec3Test.php @@ -1,12 +1,14 @@ normalize(); $this->assertEquals(1.0, $normalized->magnitude()); - $this->assertEquals(1/3, $normalized->getX()); - $this->assertEquals(2/3, $normalized->getY()); - $this->assertEquals(2/3, $normalized->getZ()); + $this->assertEquals(1 / 3, $normalized->getX()); + $this->assertEquals(2 / 3, $normalized->getY()); + $this->assertEquals(2 / 3, $normalized->getZ()); } public function testNormalizeZeroVector(): void @@ -140,7 +142,7 @@ public function testEquals(): void $vec1 = new Vec3([1.0, 2.0, 3.0]); $vec2 = new Vec3([1.0, 2.0, 3.0]); $vec3 = new Vec3([3.0, 2.0, 1.0]); - + $this->assertTrue($vec1->equals($vec2)); $this->assertFalse($vec1->equals($vec3)); } @@ -151,4 +153,4 @@ public function testDistance(): void $vec2 = new Vec3([1.0, 2.0, 2.0]); $this->assertEquals(3.0, $vec1->distance($vec2)); } -} \ No newline at end of file +} diff --git a/Tests/Composite/Vector/Vec4Test.php b/Tests/Composite/Vector/Vec4Test.php index 1224233..e1e8610 100644 --- a/Tests/Composite/Vector/Vec4Test.php +++ b/Tests/Composite/Vector/Vec4Test.php @@ -1,12 +1,14 @@ normalize(); $this->assertEquals(1.0, $normalized->magnitude()); - $this->assertEquals(1/sqrt(13), $normalized->getX()); - $this->assertEquals(2/sqrt(13), $normalized->getY()); - $this->assertEquals(2/sqrt(13), $normalized->getZ()); - $this->assertEquals(2/sqrt(13), $normalized->getW()); + $this->assertEquals(1 / sqrt(13), $normalized->getX()); + $this->assertEquals(2 / sqrt(13), $normalized->getY()); + $this->assertEquals(2 / sqrt(13), $normalized->getZ()); + $this->assertEquals(2 / sqrt(13), $normalized->getW()); } public function testNormalizeZeroVector(): void @@ -148,7 +150,7 @@ public function testEquals(): void $vec1 = new Vec4([1.0, 2.0, 3.0, 4.0]); $vec2 = new Vec4([1.0, 2.0, 3.0, 4.0]); $vec3 = new Vec4([4.0, 3.0, 2.0, 1.0]); - + $this->assertTrue($vec1->equals($vec2)); $this->assertFalse($vec1->equals($vec3)); } @@ -159,4 +161,4 @@ public function testDistance(): void $vec2 = new Vec4([1.0, 2.0, 2.0, 2.0]); $this->assertEquals(sqrt(13), $vec1->distance($vec2)); } -} \ No newline at end of file +} diff --git a/Tests/StructTest.php b/Tests/StructTest.php index dc555a1..43152a3 100644 --- a/Tests/StructTest.php +++ b/Tests/StructTest.php @@ -13,8 +13,8 @@ final class StructTest extends TestCase public function testConstructionAndFieldRegistration(): void { $struct = new Struct([ - 'id' => 'int', - 'name' => 'string', + 'id' => ['type' => 'int', 'nullable' => true], + 'name' => ['type' => 'string', 'nullable' => true], ]); $fields = $struct->getFields(); $this->assertArrayHasKey('id', $fields); @@ -28,8 +28,8 @@ public function testConstructionAndFieldRegistration(): void public function testSetAndGet(): void { $struct = new Struct([ - 'id' => 'int', - 'name' => 'string', + 'id' => ['type' => 'int', 'nullable' => true], + 'name' => ['type' => 'string', 'nullable' => true], ]); $struct->set('id', 42); $struct->set('name', 'Alice'); @@ -40,7 +40,7 @@ public function testSetAndGet(): void public function testSetWrongTypeThrows(): void { $struct = new Struct([ - 'id' => 'int', + 'id' => ['type' => 'int', 'nullable' => true], ]); $this->expectException(InvalidArgumentException::class); $struct->set('id', 'not an int'); @@ -49,7 +49,7 @@ public function testSetWrongTypeThrows(): void public function testSetNullableField(): void { $struct = new Struct([ - 'desc' => '?string', + 'desc' => ['type' => 'string', 'nullable' => true], ]); $struct->set('desc', null); $this->assertNull($struct->get('desc')); @@ -59,17 +59,17 @@ public function testSetNullableField(): void public function testSetNonNullableFieldNullThrows(): void { - $struct = new Struct([ - 'id' => 'int', - ]); $this->expectException(InvalidArgumentException::class); - $struct->set('id', null); + $this->expectExceptionMessage("Field 'id' is required and has no value"); + new Struct([ + 'id' => ['type' => 'int', 'nullable' => false], + ]); } public function testSetSubclass(): void { $struct = new Struct([ - 'obj' => 'stdClass', + 'obj' => ['type' => 'stdClass', 'nullable' => true], ]); $obj = new class () extends \stdClass {}; $struct->set('obj', $obj); @@ -79,7 +79,7 @@ public function testSetSubclass(): void public function testGetNonexistentFieldThrows(): void { $struct = new Struct([ - 'id' => 'int', + 'id' => ['type' => 'int', 'nullable' => true], ]); $this->expectException(InvalidArgumentException::class); $struct->get('missing'); @@ -88,7 +88,7 @@ public function testGetNonexistentFieldThrows(): void public function testSetNonexistentFieldThrows(): void { $struct = new Struct([ - 'id' => 'int', + 'id' => ['type' => 'int', 'nullable' => true], ]); $this->expectException(InvalidArgumentException::class); $struct->set('missing', 123); @@ -98,7 +98,7 @@ public function testDuplicateFieldThrows(): void { $this->expectException(InvalidArgumentException::class); // Simulate duplicate by calling addField directly via reflection - $struct = new Struct(['id' => 'int']); + $struct = new Struct(['id' => ['type' => 'int', 'nullable' => true]]); $ref = new \ReflectionClass($struct); $method = $ref->getMethod('addField'); $method->setAccessible(true); @@ -108,7 +108,7 @@ public function testDuplicateFieldThrows(): void public function testMagicGetSet(): void { $struct = new Struct([ - 'foo' => 'int', + 'foo' => ['type' => 'int', 'nullable' => true], ]); $struct->foo = 123; $this->assertSame(123, $struct->foo); diff --git a/build/logs/junit.xml b/build/logs/junit.xml index 2f9c80b..72533f1 100644 --- a/build/logs/junit.xml +++ b/build/logs/junit.xml @@ -1,469 +1,688 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - + + - + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - + + + - - - + + + - - - + + + - + - - - - - - - - - + + + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - + - - - - + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + + + - - - - + + + + - + - - - - + + + + - - - - + + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - + + - - - - - - - + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - - - + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Abstract/AbstractVector.php b/src/Abstract/AbstractVector.php index 07d82f0..f995107 100644 --- a/src/Abstract/AbstractVector.php +++ b/src/Abstract/AbstractVector.php @@ -1,9 +1,11 @@ components = $components; } - abstract protected function validateComponents(array $components): void; + public function __toString(): string + { + return '(' . implode(', ', $this->components) . ')'; + } public function getComponents(): array { @@ -24,7 +29,7 @@ public function getComponents(): array public function magnitude(): float { - return sqrt(array_sum(array_map(fn($component) => $component ** 2, $this->components))); + return sqrt(array_sum(array_map(fn ($component) => $component ** 2, $this->components))); } public function normalize(): self @@ -33,8 +38,8 @@ public function normalize(): self if ($magnitude === 0.0) { throw new InvalidArgumentException("Cannot normalize a zero vector"); } - - $normalized = array_map(fn($component) => $component / $magnitude, $this->components); + + $normalized = array_map(fn ($component) => $component / $magnitude, $this->components); return new static($normalized); } @@ -45,7 +50,7 @@ public function dot(self $other): float } return array_sum(array_map( - fn($a, $b) => $a * $b, + fn ($a, $b) => $a * $b, $this->components, $other->components )); @@ -58,7 +63,7 @@ public function add(self $other): self } $result = array_map( - fn($a, $b) => $a + $b, + fn ($a, $b) => $a + $b, $this->components, $other->components ); @@ -73,7 +78,7 @@ public function subtract(self $other): self } $result = array_map( - fn($a, $b) => $a - $b, + fn ($a, $b) => $a - $b, $this->components, $other->components ); @@ -84,37 +89,13 @@ public function subtract(self $other): self public function scale(float $scalar): self { $result = array_map( - fn($component) => $component * $scalar, + fn ($component) => $component * $scalar, $this->components ); return new static($result); } - public function __toString(): string - { - return '(' . implode(', ', $this->components) . ')'; - } - - protected function validateNumericComponents(array $components): void - { - foreach ($components as $component) { - if (!is_numeric($component)) { - throw new InvalidArgumentException("All components must be numeric"); - } - } - } - - protected function validateComponentCount(array $components, int $expectedCount): void - { - if (count($components) !== $expectedCount) { - throw new InvalidArgumentException(sprintf( - "Vector must have exactly %d components", - $expectedCount - )); - } - } - public function getComponent(int $index): float { if (!isset($this->components[$index])) { @@ -139,11 +120,32 @@ public function distance(self $other): float } $squaredDiff = array_map( - fn($a, $b) => ($a - $b) ** 2, + fn ($a, $b) => ($a - $b) ** 2, $this->components, $other->components ); return sqrt(array_sum($squaredDiff)); } -} \ No newline at end of file + + abstract protected function validateComponents(array $components): void; + + protected function validateNumericComponents(array $components): void + { + foreach ($components as $component) { + if (!is_numeric($component)) { + throw new InvalidArgumentException("All components must be numeric"); + } + } + } + + protected function validateComponentCount(array $components, int $expectedCount): void + { + if (count($components) !== $expectedCount) { + throw new InvalidArgumentException(sprintf( + "Vector must have exactly %d components", + $expectedCount + )); + } + } +} diff --git a/src/Composite/String/AsciiString.php b/src/Composite/String/AsciiString.php new file mode 100644 index 0000000..25906f9 --- /dev/null +++ b/src/Composite/String/AsciiString.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/Base64String.php b/src/Composite/String/Base64String.php new file mode 100644 index 0000000..c98e366 --- /dev/null +++ b/src/Composite/String/Base64String.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/ColorString.php b/src/Composite/String/ColorString.php new file mode 100644 index 0000000..ed4f581 --- /dev/null +++ b/src/Composite/String/ColorString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/CommandString.php b/src/Composite/String/CommandString.php new file mode 100644 index 0000000..31225a4 --- /dev/null +++ b/src/Composite/String/CommandString.php @@ -0,0 +1,50 @@ +()\'"`\s]+$/', $value)) { + throw new InvalidArgumentException('Invalid command string format'); + } + $this->value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/CssString.php b/src/Composite/String/CssString.php new file mode 100644 index 0000000..9652cea --- /dev/null +++ b/src/Composite/String/CssString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/EmailString.php b/src/Composite/String/EmailString.php new file mode 100644 index 0000000..2b99142 --- /dev/null +++ b/src/Composite/String/EmailString.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/HexString.php b/src/Composite/String/HexString.php new file mode 100644 index 0000000..ac3280f --- /dev/null +++ b/src/Composite/String/HexString.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/HtmlString.php b/src/Composite/String/HtmlString.php new file mode 100644 index 0000000..b6abbb7 --- /dev/null +++ b/src/Composite/String/HtmlString.php @@ -0,0 +1,56 @@ +loadHTML($value, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if (!empty($errors)) { + throw new InvalidArgumentException('Invalid HTML string format'); + } + $this->value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/IpString.php b/src/Composite/String/IpString.php new file mode 100644 index 0000000..8944323 --- /dev/null +++ b/src/Composite/String/IpString.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/JsString.php b/src/Composite/String/JsString.php new file mode 100644 index 0000000..ab3e9a0 --- /dev/null +++ b/src/Composite/String/JsString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/JsonString.php b/src/Composite/String/JsonString.php new file mode 100644 index 0000000..bb3173a --- /dev/null +++ b/src/Composite/String/JsonString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/MacString.php b/src/Composite/String/MacString.php new file mode 100644 index 0000000..5d224ba --- /dev/null +++ b/src/Composite/String/MacString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/PasswordString.php b/src/Composite/String/PasswordString.php new file mode 100644 index 0000000..1cd9660 --- /dev/null +++ b/src/Composite/String/PasswordString.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/PathString.php b/src/Composite/String/PathString.php new file mode 100644 index 0000000..5b57640 --- /dev/null +++ b/src/Composite/String/PathString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/RegexString.php b/src/Composite/String/RegexString.php new file mode 100644 index 0000000..02d5bc8 --- /dev/null +++ b/src/Composite/String/RegexString.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/SemverString.php b/src/Composite/String/SemverString.php new file mode 100644 index 0000000..1c63df5 --- /dev/null +++ b/src/Composite/String/SemverString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/SlugString.php b/src/Composite/String/SlugString.php new file mode 100644 index 0000000..d29ee1b --- /dev/null +++ b/src/Composite/String/SlugString.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/SqlString.php b/src/Composite/String/SqlString.php new file mode 100644 index 0000000..f45ab98 --- /dev/null +++ b/src/Composite/String/SqlString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/Str16.php b/src/Composite/String/Str16.php new file mode 100644 index 0000000..778b765 --- /dev/null +++ b/src/Composite/String/Str16.php @@ -0,0 +1,52 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/Str32.php b/src/Composite/String/Str32.php new file mode 100644 index 0000000..98f31d2 --- /dev/null +++ b/src/Composite/String/Str32.php @@ -0,0 +1,52 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/Str36.php b/src/Composite/String/Str36.php new file mode 100644 index 0000000..177a6ad --- /dev/null +++ b/src/Composite/String/Str36.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/Str64.php b/src/Composite/String/Str64.php new file mode 100644 index 0000000..bfcd148 --- /dev/null +++ b/src/Composite/String/Str64.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/Str8.php b/src/Composite/String/Str8.php new file mode 100644 index 0000000..b4abf6c --- /dev/null +++ b/src/Composite/String/Str8.php @@ -0,0 +1,52 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/TrimmedString.php b/src/Composite/String/TrimmedString.php new file mode 100644 index 0000000..e1b5f69 --- /dev/null +++ b/src/Composite/String/TrimmedString.php @@ -0,0 +1,50 @@ +value = $trimmed; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/UrlString.php b/src/Composite/String/UrlString.php new file mode 100644 index 0000000..e43822b --- /dev/null +++ b/src/Composite/String/UrlString.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/Utf8String.php b/src/Composite/String/Utf8String.php new file mode 100644 index 0000000..b02032c --- /dev/null +++ b/src/Composite/String/Utf8String.php @@ -0,0 +1,49 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/UuidString.php b/src/Composite/String/UuidString.php new file mode 100644 index 0000000..c16a4b7 --- /dev/null +++ b/src/Composite/String/UuidString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/VersionString.php b/src/Composite/String/VersionString.php new file mode 100644 index 0000000..289c812 --- /dev/null +++ b/src/Composite/String/VersionString.php @@ -0,0 +1,50 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/String/XmlString.php b/src/Composite/String/XmlString.php new file mode 100644 index 0000000..4ddca25 --- /dev/null +++ b/src/Composite/String/XmlString.php @@ -0,0 +1,55 @@ +value = $value; + } + + /** + * Get the string value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Composite/Struct/AdvancedStruct.php b/src/Composite/Struct/AdvancedStruct.php new file mode 100644 index 0000000..fc46a79 --- /dev/null +++ b/src/Composite/Struct/AdvancedStruct.php @@ -0,0 +1,122 @@ +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"); + } + if ($value !== null) { + $this->validateField($field, $value, $type, $rules, $nullable); + } + $this->data[$field] = $value; + } + } + + protected function validateField(string $field, $value, $type, array $rules, bool $nullable): void + { + if ($value === null && $nullable) { + return; + } + // Type check + if ($type !== 'mixed' && !$this->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)) { + throw new ValidationException("Validation failed for field '$field'"); + } + } + } + } + + protected function isValidType($value, $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; + } + + public function get(string $field) + { + return $this->data[$field] ?? null; + } + + public function toArray(bool $useAliases = false): array + { + $result = []; + foreach ($this->schema as $field => $def) { + $alias = $def['alias'] ?? $field; + $value = $this->data[$field]; + if ($value instanceof self) { + $value = $value->toArray($useAliases); + } + $result[$useAliases ? $alias : $field] = $value; + } + return $result; + } + + public static function fromArray(array $schema, array $data): self + { + return new self($schema, $data); + } + + public function toJson(bool $useAliases = false): string + { + return json_encode($this->toArray($useAliases)); + } + + public static function fromJson(array $schema, string $json): self + { + $data = json_decode($json, true); + return new self($schema, $data); + } + + public function toXml(bool $useAliases = false): string + { + $arr = $this->toArray($useAliases); + $xml = new \SimpleXMLElement(''); + foreach ($arr as $k => $v) { + $xml->addChild($k, htmlspecialchars((string)$v)); + } + return $xml->asXML(); + } + + public static function fromXml(array $schema, string $xml): self + { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false) { + foreach ($data as $k => $v) { + $arr[$k] = (string)$v; + } + } + return new self($schema, $arr); + } +} \ No newline at end of file diff --git a/src/Composite/Struct/ImmutableStruct.php b/src/Composite/Struct/ImmutableStruct.php index dea5d74..d33c0e7 100644 --- a/src/Composite/Struct/ImmutableStruct.php +++ b/src/Composite/Struct/ImmutableStruct.php @@ -4,16 +4,16 @@ namespace Nejcc\PhpDatatypes\Composite\Struct; -use Nejcc\PhpDatatypes\Interfaces\StructInterface; -use Nejcc\PhpDatatypes\Exceptions\InvalidArgumentException; use Nejcc\PhpDatatypes\Exceptions\ImmutableException; +use Nejcc\PhpDatatypes\Exceptions\InvalidArgumentException; use Nejcc\PhpDatatypes\Exceptions\ValidationException; +use Nejcc\PhpDatatypes\Interfaces\StructInterface; /** * ImmutableStruct - An immutable struct implementation with field validation * and nested struct support. */ -class ImmutableStruct implements StructInterface +final class ImmutableStruct implements StructInterface { /** * @var array The struct fields */ - private array $fields; + private array $fields = []; /** * @var bool Whether the struct is frozen (immutable) */ private bool $frozen = false; + /** + * @var array The struct data + */ + private array $data; + + /** + * @var array The validation rules for each field + */ + private array $rules; + + /** + * @var ImmutableStruct|null The parent struct for inheritance + */ + private ?ImmutableStruct $parent = null; + /** * Create a new ImmutableStruct instance * @@ -41,190 +56,341 @@ class ImmutableStruct implements StructInterface * rules?: ValidationRule[] * }> $fieldDefinitions Field definitions * @param array $initialValues Initial values for fields + * @param ImmutableStruct|null $parent Optional parent struct for inheritance * @throws InvalidArgumentException If field definitions are invalid or initial values don't match * @throws ValidationException If validation rules fail */ - public function __construct(array $fieldDefinitions, array $initialValues = []) + public function __construct(array $fieldDefinitions, array $initialValues = [], ?ImmutableStruct $parent = null) { + $this->parent = $parent; $this->fields = []; + + // Initialize fields from parent if present + if ($parent !== null) { + foreach ($parent->getFields() as $name => $field) { + $this->fields[$name] = [ + 'type' => $field['type'], + 'value' => $field['value'], + 'required' => $field['required'], + 'default' => $field['default'], + 'rules' => $field['rules'] + ]; + } + } + + // Initialize child fields, overriding parent fields if they exist $this->initializeFields($fieldDefinitions); $this->setInitialValues($initialValues); $this->frozen = true; } /** - * Initialize the struct fields from definitions + * Validate the struct data * - * @param array $fieldDefinitions - * @throws InvalidArgumentException If field definitions are invalid + * @throws ValidationException If validation fails */ - private function initializeFields(array $fieldDefinitions): void + private function validate(): void { - foreach ($fieldDefinitions as $name => $definition) { - if (!isset($definition['type'])) { - throw new InvalidArgumentException("Field '$name' must have a type definition"); - } + // Validate parent struct if it exists + if ($this->parent !== null) { + $this->parent->validate(); + } - $this->fields[$name] = [ - 'type' => $definition['type'], - 'value' => $definition['default'] ?? null, - 'required' => $definition['required'] ?? false, - 'default' => $definition['default'] ?? null, - 'rules' => $definition['rules'] ?? [] - ]; + // Validate current struct + foreach ($this->rules as $field => $fieldRules) { + if (!isset($this->data[$field])) { + throw new ValidationException("Field '{$field}' is required"); + } + foreach ($fieldRules as $rule) { + $rule->validate($this->data[$field]); + } } } /** - * Set initial values for fields + * Get the parent struct * - * @param array $initialValues - * @throws InvalidArgumentException If initial values don't match field definitions - * @throws ValidationException If validation rules fail + * @return ImmutableStruct|null */ - private function setInitialValues(array $initialValues): void + public function getParent(): ?ImmutableStruct { - foreach ($initialValues as $name => $value) { - if (!isset($this->fields[$name])) { - throw new InvalidArgumentException("Field '$name' is not defined in the struct"); - } - $this->set($name, $value); - } + return $this->parent; + } - // Validate required fields + /** + * Check if this struct has a parent + * + * @return bool + */ + public function hasParent(): bool + { + return $this->parent !== null; + } + + /** + * Get all fields including inherited fields + * + * @return array + */ + public function getAllFields(): array + { + $result = []; foreach ($this->fields as $name => $field) { - if ($field['required'] && $field['value'] === null) { - throw new InvalidArgumentException("Required field '$name' has no value"); + $value = $field['value']; + if ($value instanceof StructInterface) { + $result[$name] = $value->toArray(); + } else { + $result[$name] = $value; } } + return $result; } /** - * Create a new struct with updated values + * Get all validation rules including inherited rules * - * @param array $values New values to set - * @return self A new struct instance with the updated values - * @throws InvalidArgumentException If values don't match field definitions - * @throws ValidationException If validation rules fail + * @return array */ - public function with(array $values): self + public function getAllRules(): array { - $newFields = []; + $rules = []; foreach ($this->fields as $name => $field) { - $newFields[$name] = [ - 'type' => $field['type'], - 'required' => $field['required'], - 'default' => $field['default'], - 'rules' => $field['rules'] - ]; + $rules[$name] = $field['rules']; } - - $newStruct = new self($newFields, $values); - return $newStruct; + return $rules; } /** - * Get a new struct with a single field updated + * Get a field value * - * @param string $name Field name - * @param mixed $value New value - * @return self A new struct instance with the updated field - * @throws InvalidArgumentException If the field doesn't exist or value doesn't match type - * @throws ValidationException If validation rules fail + * @param string $field The field name + * @return mixed The field value + * @throws InvalidArgumentException If the field does not exist */ - public function withField(string $name, mixed $value): self + public function getField(string $field): mixed { - return $this->with([$name => $value]); + if (!isset($this->data[$field])) { + throw new InvalidArgumentException("Field '{$field}' does not exist in the struct"); + } + return $this->data[$field]; } /** - * {@inheritDoc} + * Set a field value + * + * @param string $field The field name + * @param mixed $value The field value + * @throws InvalidArgumentException If the field does not exist + * @throws ImmutableException If the struct is immutable */ - public function set(string $name, mixed $value): void + public function setField(string $field, mixed $value): void { + if (!isset($this->data[$field])) { + throw new InvalidArgumentException("Field '{$field}' does not exist in the struct"); + } if ($this->frozen) { - throw new ImmutableException("Cannot modify a frozen struct"); + throw new ImmutableException("Cannot modify an immutable struct"); } + $this->data[$field] = $value; + } - if (!isset($this->fields[$name])) { - throw new InvalidArgumentException("Field '$name' does not exist in the struct"); - } + /** + * Check if a field exists + * + * @param string $field The field name + * @return bool True if the field exists, false otherwise + */ + public function hasField(string $field): bool + { + return isset($this->data[$field]); + } - $this->validateValue($name, $value); - $this->fields[$name]['value'] = $value; + /** + * Get all field names + * + * @return array The field names + */ + public function getFieldNames(): array + { + return array_keys($this->data); } /** - * {@inheritDoc} + * Get all field values + * + * @return array The field values */ - public function get(string $name): mixed + public function getFieldValues(): array { - if (!isset($this->fields[$name])) { - throw new InvalidArgumentException("Field '$name' does not exist in the struct"); - } + return $this->data; + } - return $this->fields[$name]['value']; + /** + * Get the validation rules for a field + * + * @param string $field The field name + * @return ValidationRule[] The validation rules + * @throws InvalidArgumentException If the field does not exist + */ + public function getFieldRules(string $field): array + { + if (!isset($this->fields[$field])) { + throw new InvalidArgumentException("Field '$field' does not exist in the struct"); + } + return $this->fields[$field]['rules']; } /** - * {@inheritDoc} + * Check if a field is required + * + * @param string $field The field name + * @return bool True if the field is required, false otherwise + * @throws InvalidArgumentException If the field does not exist */ - public function getFields(): array + public function isFieldRequired(string $field): bool { - return $this->fields; + if (!isset($this->fields[$field])) { + throw new InvalidArgumentException("Field '$field' does not exist in the struct"); + } + return $this->fields[$field]['required']; } /** * Get the type of a field * - * @param string $name Field name + * @param string $field The field name * @return string The field type - * @throws InvalidArgumentException If the field doesn't exist + * @throws InvalidArgumentException If the field does not exist */ - public function getFieldType(string $name): string + public function getFieldType(string $field): string { - if (!isset($this->fields[$name])) { - throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + if (!isset($this->fields[$field])) { + throw new InvalidArgumentException("Field '$field' does not exist in the struct"); } + return $this->fields[$field]['type']; + } - return $this->fields[$name]['type']; + /** + * Convert the struct to an array + * + * @return array The struct data + */ + public function toArray(): array + { + $result = []; + foreach ($this->fields as $name => $field) { + $value = $field['value']; + if ($value instanceof StructInterface) { + $result[$name] = $value->toArray(); + } else { + $result[$name] = $value; + } + } + return $result; } /** - * Check if a field is required + * Convert the struct to a string * - * @param string $name Field name - * @return bool True if the field is required - * @throws InvalidArgumentException If the field doesn't exist + * @return string The struct data as a string */ - public function isFieldRequired(string $name): bool + public function __toString(): string { - if (!isset($this->fields[$name])) { - throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + return json_encode($this->toArray()); + } + + /** + * Create a new struct with updated values + * + * @param array $values New values to set + * + * @return self A new struct instance with the updated values + * + * @throws InvalidArgumentException If values don't match field definitions + * @throws ValidationException If validation rules fail + */ + public function with(array $values): self + { + $newFields = []; + foreach ($this->fields as $name => $field) { + $newFields[$name] = [ + 'type' => $field['type'], + 'required' => $field['required'], + 'default' => $field['default'], + 'rules' => $field['rules'] + ]; } - return $this->fields[$name]['required']; + $newStruct = new self($newFields, $values); + return $newStruct; } /** - * Get the validation rules for a field + * Get a new struct with a single field updated * * @param string $name Field name - * @return ValidationRule[] The field's validation rules - * @throws InvalidArgumentException If the field doesn't exist + * @param mixed $value New value + * + * @return self A new struct instance with the updated field + * + * @throws InvalidArgumentException If the field doesn't exist or value doesn't match type + * @throws ValidationException If validation rules fail */ - public function getFieldRules(string $name): array + public function withField(string $name, mixed $value): self { - if (!isset($this->fields[$name])) { - throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + return $this->with([$name => $value]); + } + + /** + * Initialize the struct fields from definitions + * + * @param array $fieldDefinitions + * + * @throws InvalidArgumentException If field definitions are invalid + */ + private function initializeFields(array $fieldDefinitions): void + { + foreach ($fieldDefinitions as $name => $definition) { + if (!isset($definition['type'])) { + throw new InvalidArgumentException("Field '$name' must have a type definition"); + } + $this->fields[$name] = [ + 'type' => $definition['type'], + 'value' => $definition['default'] ?? null, + 'required' => $definition['required'] ?? false, + 'default' => $definition['default'] ?? null, + 'rules' => $definition['rules'] ?? [] + ]; } + } - return $this->fields[$name]['rules']; + /** + * Set initial values for fields + * + * @param array $initialValues + * + * @throws InvalidArgumentException If initial values don't match field definitions + * @throws ValidationException If validation rules fail + */ + private function setInitialValues(array $initialValues): void + { + foreach ($initialValues as $name => $value) { + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' is not defined in the struct"); + } + $this->set($name, $value); + } + // Validate required fields + foreach ($this->fields as $name => $field) { + if ($field['required'] && $field['value'] === null) { + throw new InvalidArgumentException("Required field '$name' has no value"); + } + } } /** @@ -232,6 +398,7 @@ public function getFieldRules(string $name): array * * @param string $name Field name * @param mixed $value Value to validate + * * @throws InvalidArgumentException If the value doesn't match the field type * @throws ValidationException If validation rules fail */ @@ -239,14 +406,11 @@ private function validateValue(string $name, mixed $value): void { $type = $this->fields[$name]['type']; $actualType = get_debug_type($value); - // Handle nullable types if ($this->isNullable($type) && $value === null) { return; } - $baseType = $this->stripNullable($type); - // Handle nested structs if (is_subclass_of($baseType, StructInterface::class)) { if (!($value instanceof $baseType)) { @@ -256,14 +420,12 @@ private function validateValue(string $name, mixed $value): void } return; } - // Handle primitive types if ($actualType !== $baseType && !is_subclass_of($value, $baseType)) { throw new InvalidArgumentException( "Field '$name' expects type '$type', but got '$actualType'" ); } - // Apply validation rules foreach ($this->fields[$name]['rules'] as $rule) { $rule->validate($value, $name); @@ -274,6 +436,7 @@ private function validateValue(string $name, mixed $value): void * Check if a type is nullable * * @param string $type Type to check + * * @return bool True if the type is nullable */ private function isNullable(string $type): bool @@ -285,6 +448,7 @@ private function isNullable(string $type): bool * Strip nullable prefix from a type * * @param string $type Type to strip + * * @return string Type without nullable prefix */ private function stripNullable(string $type): string @@ -292,32 +456,29 @@ private function stripNullable(string $type): string return ltrim($type, '?'); } - /** - * Convert the struct to an array - * - * @return array The struct data - */ - public function toArray(): array + // Implement StructInterface methods + public function set(string $name, mixed $value): void { - $result = []; - foreach ($this->fields as $name => $field) { - $value = $field['value']; - if ($value instanceof StructInterface) { - $result[$name] = $value->toArray(); - } else { - $result[$name] = $value; - } + if ($this->frozen) { + throw new ImmutableException("Cannot modify a frozen struct"); } - return $result; + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + } + $this->validateValue($name, $value); + $this->fields[$name]['value'] = $value; } - /** - * String representation of the struct - * - * @return string - */ - public function __toString(): string + public function get(string $name): mixed { - return json_encode($this->toArray()); + if (!isset($this->fields[$name])) { + throw new InvalidArgumentException("Field '$name' does not exist in the struct"); + } + return $this->fields[$name]['value']; + } + + public function getFields(): array + { + return $this->fields; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/CompositeRule.php b/src/Composite/Struct/Rules/CompositeRule.php index a27c7a1..1dbe35c 100644 --- a/src/Composite/Struct/Rules/CompositeRule.php +++ b/src/Composite/Struct/Rules/CompositeRule.php @@ -5,9 +5,8 @@ namespace Nejcc\PhpDatatypes\Composite\Struct\Rules; use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; -use Nejcc\PhpDatatypes\Exceptions\ValidationException; -class CompositeRule implements ValidationRule +final class CompositeRule implements ValidationRule { /** * @var ValidationRule[] @@ -37,6 +36,7 @@ public function validate(mixed $value, string $fieldName): bool * Create a new composite rule from an array of rules * * @param ValidationRule[] $rules + * * @return self */ public static function fromArray(array $rules): self @@ -48,10 +48,11 @@ public static function fromArray(array $rules): self * Add a rule to the composite * * @param ValidationRule $rule + * * @return self A new composite rule with the added rule */ public function withRule(ValidationRule $rule): self { return new self(...array_merge($this->rules, [$rule])); } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/CustomRule.php b/src/Composite/Struct/Rules/CustomRule.php index 9706d79..22725b9 100644 --- a/src/Composite/Struct/Rules/CustomRule.php +++ b/src/Composite/Struct/Rules/CustomRule.php @@ -4,11 +4,11 @@ namespace Nejcc\PhpDatatypes\Composite\Struct\Rules; +use Closure; use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; use Nejcc\PhpDatatypes\Exceptions\ValidationException; -use Closure; -class CustomRule implements ValidationRule +final class CustomRule implements ValidationRule { private Closure $validator; private string $errorMessage; @@ -28,7 +28,7 @@ public function __construct(Closure $validator, string $errorMessage) public function validate(mixed $value, string $fieldName): bool { $isValid = ($this->validator)($value); - + if (!$isValid) { throw new ValidationException( "Field '$fieldName': {$this->errorMessage}" @@ -37,4 +37,4 @@ public function validate(mixed $value, string $fieldName): bool return true; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/EmailRule.php b/src/Composite/Struct/Rules/EmailRule.php index b3deeeb..64fb0e6 100644 --- a/src/Composite/Struct/Rules/EmailRule.php +++ b/src/Composite/Struct/Rules/EmailRule.php @@ -7,7 +7,7 @@ use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; use Nejcc\PhpDatatypes\Exceptions\ValidationException; -class EmailRule implements ValidationRule +final class EmailRule implements ValidationRule { public function validate(mixed $value, string $fieldName): bool { @@ -25,4 +25,4 @@ public function validate(mixed $value, string $fieldName): bool return true; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/MinLengthRule.php b/src/Composite/Struct/Rules/MinLengthRule.php index ad4c114..4eb85c3 100644 --- a/src/Composite/Struct/Rules/MinLengthRule.php +++ b/src/Composite/Struct/Rules/MinLengthRule.php @@ -7,7 +7,7 @@ use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; use Nejcc\PhpDatatypes\Exceptions\ValidationException; -class MinLengthRule implements ValidationRule +final class MinLengthRule implements ValidationRule { private int $minLength; @@ -32,4 +32,4 @@ public function validate(mixed $value, string $fieldName): bool return true; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/PasswordRule.php b/src/Composite/Struct/Rules/PasswordRule.php index bfeb299..8fc38dc 100644 --- a/src/Composite/Struct/Rules/PasswordRule.php +++ b/src/Composite/Struct/Rules/PasswordRule.php @@ -7,7 +7,7 @@ use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; use Nejcc\PhpDatatypes\Exceptions\ValidationException; -class PasswordRule implements ValidationRule +final class PasswordRule implements ValidationRule { private int $minLength; private bool $requireUppercase; @@ -79,4 +79,4 @@ public function validate(mixed $value, string $fieldName): bool return true; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/PatternRule.php b/src/Composite/Struct/Rules/PatternRule.php index 1881df9..9d2484d 100644 --- a/src/Composite/Struct/Rules/PatternRule.php +++ b/src/Composite/Struct/Rules/PatternRule.php @@ -7,7 +7,7 @@ use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; use Nejcc\PhpDatatypes\Exceptions\ValidationException; -class PatternRule implements ValidationRule +final class PatternRule implements ValidationRule { private string $pattern; @@ -32,4 +32,4 @@ public function validate(mixed $value, string $fieldName): bool return true; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/RangeRule.php b/src/Composite/Struct/Rules/RangeRule.php index 3ad7a44..554c76c 100644 --- a/src/Composite/Struct/Rules/RangeRule.php +++ b/src/Composite/Struct/Rules/RangeRule.php @@ -7,7 +7,7 @@ use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; use Nejcc\PhpDatatypes\Exceptions\ValidationException; -class RangeRule implements ValidationRule +final class RangeRule implements ValidationRule { private float $min; private float $max; @@ -35,4 +35,4 @@ public function validate(mixed $value, string $fieldName): bool return true; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/SlugRule.php b/src/Composite/Struct/Rules/SlugRule.php index 3b1d6fb..798202d 100644 --- a/src/Composite/Struct/Rules/SlugRule.php +++ b/src/Composite/Struct/Rules/SlugRule.php @@ -7,7 +7,7 @@ use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; use Nejcc\PhpDatatypes\Exceptions\ValidationException; -class SlugRule implements ValidationRule +final class SlugRule implements ValidationRule { private int $minLength; private int $maxLength; @@ -65,4 +65,4 @@ public function validate(mixed $value, string $fieldName): bool return true; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Rules/UrlRule.php b/src/Composite/Struct/Rules/UrlRule.php index bc2b06a..2e515c7 100644 --- a/src/Composite/Struct/Rules/UrlRule.php +++ b/src/Composite/Struct/Rules/UrlRule.php @@ -7,7 +7,7 @@ use Nejcc\PhpDatatypes\Composite\Struct\ValidationRule; use Nejcc\PhpDatatypes\Exceptions\ValidationException; -class UrlRule implements ValidationRule +final class UrlRule implements ValidationRule { private bool $requireHttps; @@ -38,4 +38,4 @@ public function validate(mixed $value, string $fieldName): bool return true; } -} \ No newline at end of file +} diff --git a/src/Composite/Struct/Struct.php b/src/Composite/Struct/Struct.php index c43308f..02abca4 100644 --- a/src/Composite/Struct/Struct.php +++ b/src/Composite/Struct/Struct.php @@ -4,96 +4,188 @@ namespace Nejcc\PhpDatatypes\Composite\Struct; -use InvalidArgumentException; -use Nejcc\PhpDatatypes\Abstract\BaseStruct; +use Nejcc\PhpDatatypes\Exceptions\InvalidArgumentException; +use Nejcc\PhpDatatypes\Exceptions\ValidationException; -final class Struct extends BaseStruct +class Struct { - /** - * Struct constructor. - * - * @param array $fields Array of field names and their expected types. - */ - public function __construct(array $fields) + protected array $data = []; + protected array $schema = []; + + public function __construct(array $schema, array $values = []) { - foreach ($fields as $name => $type) { - $this->addField($name, $type); + // Backward compatibility: convert old format ['id' => 'int', ...] to new format + $first = reset($schema); + if (is_string($first)) { + $newSchema = []; + foreach ($schema as $field => $type) { + $newSchema[$field] = ['type' => $type, 'nullable' => true]; + } + $schema = $newSchema; + } + $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"); + } + if ($value !== null) { + $this->validateField($field, $value, $type, $rules, $nullable); + } + $this->data[$field] = $value; } } - /** - * Magic method for accessing fields like object properties. - * - * @param string $name The field name. - * - * @return mixed The field value. - * - * @throws InvalidArgumentException if the field doesn't exist. - */ - public function __get(string $name): mixed + protected function validateField(string $field, $value, $type, array $rules, bool $nullable): void { - return $this->get($name); + if ($value === null && $nullable) { + return; + } + // Type check + if ($type !== 'mixed' && !$this->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)) { + throw new ValidationException("Validation failed for field '$field'"); + } + } + } } - /** - * Magic method for setting fields like object properties. - * - * @param string $name The field name. - * @param mixed $value The field value. - * - * @return void - * - * @throws InvalidArgumentException if the field doesn't exist or the value type doesn't match. - */ - public function __set(string $name, mixed $value): void + protected function isValidType($value, $type): bool { - $this->set($name, $value); + 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; } - /** - * {@inheritDoc} - */ - public function set(string $name, mixed $value): void + public function get(string $field) { - if (!isset($this->fields[$name])) { - throw new InvalidArgumentException("Field '$name' does not exist in the struct."); + if (!array_key_exists($field, $this->schema)) { + throw new InvalidArgumentException("Field '$field' does not exist in the struct."); } + return $this->data[$field] ?? null; + } - $expectedType = $this->fields[$name]['type']; - $actualType = get_debug_type($value); - - // Handle nullable types (e.g., "?string") - if ($this->isNullable($expectedType) && $value === null) { - $this->fields[$name]['value'] = $value; - return; + public function toArray(bool $useAliases = false): array + { + $result = []; + foreach ($this->schema as $field => $def) { + $alias = $def['alias'] ?? $field; + $value = $this->data[$field]; + if ($value instanceof self) { + $value = $value->toArray($useAliases); + } + $result[$useAliases ? $alias : $field] = $value; } + return $result; + } + + public static function fromArray(array $schema, array $data): self + { + return new self($schema, $data); + } + + public function toJson(bool $useAliases = false): string + { + return json_encode($this->toArray($useAliases)); + } - $baseType = $this->stripNullable($expectedType); + public static function fromJson(array $schema, string $json): self + { + $data = json_decode($json, true); + return new self($schema, $data); + } - if ($actualType !== $baseType && !is_subclass_of($value, $baseType)) { - throw new InvalidArgumentException("Field '$name' expects type '$expectedType', but got '$actualType'."); + public function toXml(bool $useAliases = false): string + { + $arr = $this->toArray($useAliases); + $xml = new \SimpleXMLElement(''); + foreach ($arr as $k => $v) { + $xml->addChild($k, htmlspecialchars((string)$v)); } + return $xml->asXML(); + } - $this->fields[$name]['value'] = $value; + public static function fromXml(array $schema, string $xml): self + { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false) { + foreach ($data as $k => $v) { + $type = $schema[$k]['type'] ?? 'mixed'; + $value = (string)$v; + // Cast to appropriate type + if ($type === 'int' || $type === 'integer') { + $value = (int)$value; + } elseif ($type === 'float' || $type === 'double') { + $value = (float)$value; + } elseif ($type === 'bool' || $type === 'boolean') { + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + $arr[$k] = $value; + } + } + return new self($schema, $arr); } - /** - * {@inheritDoc} - */ - public function get(string $name): mixed + public function set(string $field, $value): void { - if (!isset($this->fields[$name])) { - throw new InvalidArgumentException("Field '$name' does not exist in the struct."); + if (!array_key_exists($field, $this->schema)) { + throw new InvalidArgumentException("Field '$field' does not exist in the struct."); + } + $def = $this->schema[$field]; + $type = $def['type'] ?? 'mixed'; + $nullable = $def['nullable'] ?? false; + $rules = $def['rules'] ?? []; + if ($value === null && !$nullable) { + throw new InvalidArgumentException("Field '$field' cannot be null"); } + $this->validateField($field, $value, $type, $rules, $nullable); + $this->data[$field] = $value; + } - return $this->fields[$name]['value']; + public function __set($field, $value): void + { + $this->set($field, $value); + } + + public function __get($field) + { + return $this->get($field); } - /** - * {@inheritDoc} - */ public function getFields(): array { - return $this->fields; + $fields = []; + foreach ($this->schema as $field => $def) { + $fields[$field] = [ + 'type' => $def['type'] ?? 'mixed', + 'value' => $this->data[$field] ?? null, + ]; + } + return $fields; + } + + public function addField(string $field, string $type): void + { + if (array_key_exists($field, $this->schema)) { + throw new InvalidArgumentException("Field '$field' already exists in the struct."); + } + $this->schema[$field] = ['type' => $type, 'nullable' => true]; + $this->data[$field] = null; } } diff --git a/src/Composite/Struct/ValidationRule.php b/src/Composite/Struct/ValidationRule.php index 1fc2562..2e01c92 100644 --- a/src/Composite/Struct/ValidationRule.php +++ b/src/Composite/Struct/ValidationRule.php @@ -14,8 +14,10 @@ interface ValidationRule * * @param mixed $value The value to validate * @param string $fieldName The name of the field being validated + * * @return bool True if the value passes validation + * * @throws \Nejcc\PhpDatatypes\Exceptions\ValidationException If validation fails */ public function validate(mixed $value, string $fieldName): bool; -} \ No newline at end of file +} diff --git a/src/Composite/Union/UnionType.php b/src/Composite/Union/UnionType.php new file mode 100644 index 0000000..66f7ca2 --- /dev/null +++ b/src/Composite/Union/UnionType.php @@ -0,0 +1,452 @@ + The values for each type key + */ + private array $values = []; + + /** + * @var array The expected type for each key + */ + private array $typeMap = []; + + /** + * @var string|null The current active type key + */ + private ?string $activeType = null; + + /** + * A mapping of PHP shorthand types to their gettype() equivalents + */ + private static array $phpTypeMap = [ + 'int' => 'integer', + 'float' => 'double', + 'bool' => 'boolean', + ]; + + /** + * Create a new UnionType instance + * + * @param array $typeMap The expected type for each key (e.g. ['string' => 'string', 'int' => 'int']) + * @param array $initialValues Optional initial values for each key + * @throws InvalidArgumentException If no types are provided + */ + public function __construct(array $typeMap, array $initialValues = []) + { + if (empty($typeMap)) { + throw new InvalidArgumentException('Union type must have at least one possible type'); + } + $this->typeMap = $typeMap; + foreach ($typeMap as $key => $expectedType) { + $this->values[$key] = $initialValues[$key] ?? null; + } + } + + /** + * Get the currently active type + * + * @return string + * @throws InvalidArgumentException if no active type is set + */ + public function getActiveType(): string + { + if ($this->activeType === null) { + throw new InvalidArgumentException('No active type set'); + } + return $this->activeType; + } + + /** + * Check if a type key is active + * + * @param string $key + * @return bool + */ + public function isType(string $key): bool + { + return $this->activeType === $key; + } + + /** + * Get the value of the current active type + * + * @return mixed + * @throws TypeMismatchException + */ + public function getValue(): mixed + { + if ($this->activeType === null) { + throw new TypeMismatchException('No type is currently active'); + } + return $this->values[$this->activeType]; + } + + /** + * Set the value for a specific type key + * + * @param string $key + * @param mixed $value + * @throws InvalidArgumentException + */ + public function setValue(string $key, mixed $value): void + { + if (!isset($this->typeMap[$key])) { + throw new InvalidArgumentException("Type key '$key' is not valid in this union"); + } + $this->validateType($value, $this->typeMap[$key], $key); + $this->values[$key] = $value; + $this->activeType = $key; + } + + /** + * Get all possible type keys + * + * @return array + */ + public function getTypes(): array + { + return array_keys($this->typeMap); + } + + /** + * Add a new type to the union + * + * @param string $key + * @param string $expectedType + * @param mixed $initialValue + * @throws InvalidArgumentException + */ + public function addType(string $key, string $expectedType, mixed $initialValue = null): void + { + if (isset($this->typeMap[$key])) { + throw new InvalidArgumentException("Type key '$key' already exists in this union"); + } + $this->validateType($initialValue, $expectedType, $key); + $this->typeMap[$key] = $expectedType; + $this->values[$key] = $initialValue; + } + + /** + * Pattern match on the active type + * + * @param array $patterns + * @return mixed + * @throws TypeMismatchException + */ + public function match(array $patterns): mixed + { + if ($this->activeType === null) { + throw new TypeMismatchException('No type is currently active'); + } + if (!isset($patterns[$this->activeType])) { + throw new TypeMismatchException("No pattern defined for type '{$this->activeType}'"); + } + return $patterns[$this->activeType]($this->values[$this->activeType]); + } + + /** + * Pattern match with a default case + * + * @param array $patterns + * @param callable $default + * @return mixed + */ + public function matchWithDefault(array $patterns, callable $default): mixed + { + if ($this->activeType === null) { + return $default(); + } + if (!isset($patterns[$this->activeType])) { + return $default(); + } + return $patterns[$this->activeType]($this->values[$this->activeType]); + } + + /** + * String representation + * + * @return string + */ + public function __toString(): string + { + if ($this->activeType === null) { + return 'UnionType'; + } + return "UnionType<{$this->activeType}>"; + } + + /** + * Validate a value against an expected type + * + * @param mixed $value + * @param string $expectedType + * @param string $key + * @throws InvalidArgumentException + */ + private function validateType(mixed $value, string $expectedType, string $key): void + { + if ($value === null) { + return; + } + // Handle class instances + if (class_exists($expectedType) && $value instanceof $expectedType) { + return; + } + // Handle arrays + if ($expectedType === 'array' && is_array($value)) { + return; + } + // Handle objects + if ($expectedType === 'object' && is_object($value)) { + return; + } + // Handle primitive types + $actualType = $this->canonicalTypeName($value); + $expectedTypeName = $this->canonicalTypeName($expectedType); + if ($actualType !== $expectedTypeName) { + throw new InvalidArgumentException( + "Invalid type for key '$key': expected '$expectedTypeName', got '$actualType'" + ); + } + } + + /** + * Canonical PHP type name for error messages + * + * @param mixed|string $valueOrType + * @return string + */ + private function canonicalTypeName($valueOrType): string + { + if (is_object($valueOrType)) { + return get_class($valueOrType); + } + if (is_string($valueOrType) && class_exists($valueOrType)) { + return $valueOrType; + } + // If this is a type name, return the mapped type + if (is_string($valueOrType) && in_array($valueOrType, ['int', 'integer', 'float', 'double', 'bool', 'boolean', 'string', 'array', 'object', 'null'])) { + return self::$phpTypeMap[$valueOrType] ?? $valueOrType; + } + // Otherwise, return the type of the value + $type = gettype($valueOrType); + return self::$phpTypeMap[$type] ?? $type; + } + + /** + * Safely cast the current value to the specified type + * + * @param string $type + * @return mixed + * @throws TypeMismatchException + */ + public function castTo(string $type): mixed + { + if ($this->activeType === null) { + throw new TypeMismatchException('No type is currently active'); + } + if ($this->typeMap[$this->activeType] !== $type && $this->activeType !== $type) { + throw new TypeMismatchException("Cannot cast active type '{$this->activeType}' to '{$type}'"); + } + return $this->values[$this->activeType]; + } + + /** + * Check if this union equals another union + * + * @param UnionType $other + * @return bool + */ + public function equals(UnionType $other): bool + { + if ($this->activeType === null || $other->activeType === null) { + return false; + } + return $this->activeType === $other->activeType && $this->values[$this->activeType] === $other->values[$other->activeType]; + } + + /** + * Convert the union to a JSON string + * + * @return string + */ + public function toJson(): string + { + $data = [ + 'activeType' => $this->activeType, + 'value' => $this->activeType !== null ? $this->values[$this->activeType] : null + ]; + return json_encode($data); + } + + /** + * Create a UnionType instance from a JSON string + * + * @param string $json + * @return UnionType + * @throws InvalidArgumentException + */ + public static function fromJson(string $json): UnionType + { + $data = json_decode($json, true); + if (!is_array($data) || !isset($data['activeType']) || !isset($data['value'])) { + throw new InvalidArgumentException('Invalid JSON format for UnionType'); + } + $union = new UnionType([$data['activeType'] => $data['activeType']]); + if ($data['activeType'] !== null) { + $union->setValue($data['activeType'], $data['value']); + } + return $union; + } + + /** + * Convert the union to an XML string, with optional namespace support + * + * @param string|null $namespaceUri + * @param string|null $prefix + * @return string + */ + public function toXml(?string $namespaceUri = null, ?string $prefix = null): string + { + if ($namespaceUri && $prefix) { + $rootName = $prefix . ':union'; + $xml = new \SimpleXMLElement("<{$rootName} xmlns:{$prefix}='{$namespaceUri}'>"); + $xml->addAttribute('activeType', $this->activeType ?? ''); + if ($this->activeType !== null) { + $xml->addChild($prefix . ':value', (string)$this->values[$this->activeType], $namespaceUri); + } + } else if ($namespaceUri) { + $xml = new \SimpleXMLElement(""); + $xml->addAttribute('activeType', $this->activeType ?? ''); + if ($this->activeType !== null) { + $xml->addChild('value', (string)$this->values[$this->activeType], $namespaceUri); + } + } else { + $xml = new \SimpleXMLElement(''); + $xml->addAttribute('activeType', $this->activeType ?? ''); + if ($this->activeType !== null) { + $xml->addChild('value', (string)$this->values[$this->activeType]); + } + } + return $xml->asXML(); + } + + /** + * Create a UnionType instance from an XML string + * + * @param string $xml + * @return UnionType + * @throws InvalidArgumentException + */ + public static function fromXml(string $xml): UnionType + { + $data = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOERROR | LIBXML_NOWARNING); + if ($data === false || !($data instanceof \SimpleXMLElement) || $data->getName() !== 'union' || !isset($data['activeType'])) { + throw new InvalidArgumentException('Invalid XML format for UnionType'); + } + $activeType = (string)$data['activeType']; + if ($activeType === '') { + $activeType = null; + } + $union = new UnionType([$activeType => $activeType]); + if ($activeType !== null) { + // Try to get the namespace URI from the root element + $namespaces = $data->getNamespaces(true); + $value = ''; + if (!empty($namespaces)) { + foreach ($namespaces as $prefix => $uri) { + $children = $data->children($uri); + if (isset($children->value)) { + $value = (string)$children->value; + break; + } + } + } + if ($value === '') { + // Fallback to non-namespaced value + $value = (string)($data->value ?? $data->children()->value ?? ''); + if ($value === '' && count($data->children()) > 0) { + foreach ($data->children() as $child) { + if ($child->getName() === 'value') { + $value = (string)$child; + break; + } + } + } + } + $union->setValue($activeType, $value); + } + return $union; + } + + /** + * Validate an XML string against an XSD schema + * + * @param string $xml + * @param string $xsd + * @return bool + * @throws InvalidArgumentException + */ + public static function validateXmlSchema(string $xml, string $xsd): bool + { + $dom = new \DOMDocument(); + libxml_use_internal_errors(true); + if (!$dom->loadXML($xml)) { + throw new InvalidArgumentException('Invalid XML provided for schema validation'); + } + $result = $dom->schemaValidateSource($xsd); + if (!$result) { + $errors = libxml_get_errors(); + libxml_clear_errors(); + $errorMsg = isset($errors[0]) ? $errors[0]->message : 'Unknown schema validation error'; + throw new InvalidArgumentException('XML does not validate against schema: ' . $errorMsg); + } + return true; + } + + /** + * Convert the union to a binary string using PHP's serialize + * + * @return string + */ + public function toBinary(): string + { + $data = [ + 'activeType' => $this->activeType, + 'value' => $this->activeType !== null ? $this->values[$this->activeType] : null + ]; + return serialize($data); + } + + /** + * Create a UnionType instance from a binary string + * + * @param string $binary + * @return UnionType + * @throws InvalidArgumentException + */ + public static function fromBinary(string $binary): UnionType + { + $data = @unserialize($binary); + if ($data === false || !is_array($data) || !isset($data['activeType']) || !isset($data['value'])) { + throw new InvalidArgumentException('Invalid binary format for UnionType'); + } + $union = new UnionType([$data['activeType'] => $data['activeType']]); + if ($data['activeType'] !== null) { + $union->setValue($data['activeType'], $data['value']); + } + return $union; + } +} \ No newline at end of file diff --git a/src/Composite/Vector/Vec2.php b/src/Composite/Vector/Vec2.php index 922dcb7..db61b60 100644 --- a/src/Composite/Vector/Vec2.php +++ b/src/Composite/Vector/Vec2.php @@ -1,18 +1,14 @@ validateComponentCount($components, 2); - $this->validateNumericComponents($components); - } - public function getX(): float { return $this->getComponent(0); @@ -56,4 +52,9 @@ public function setValue(mixed $value): void $this->validateComponents($value); $this->components = $value; } -} \ No newline at end of file + protected function validateComponents(array $components): void + { + $this->validateComponentCount($components, 2); + $this->validateNumericComponents($components); + } +} diff --git a/src/Composite/Vector/Vec3.php b/src/Composite/Vector/Vec3.php index 90b5a45..3276890 100644 --- a/src/Composite/Vector/Vec3.php +++ b/src/Composite/Vector/Vec3.php @@ -1,18 +1,14 @@ validateComponentCount($components, 3); - $this->validateNumericComponents($components); - } - public function getX(): float { return $this->getComponent(0); @@ -70,4 +66,9 @@ public function setValue(mixed $value): void $this->validateComponents($value); $this->components = $value; } -} \ No newline at end of file + protected function validateComponents(array $components): void + { + $this->validateComponentCount($components, 3); + $this->validateNumericComponents($components); + } +} diff --git a/src/Composite/Vector/Vec4.php b/src/Composite/Vector/Vec4.php index f79ba5b..596ddd8 100644 --- a/src/Composite/Vector/Vec4.php +++ b/src/Composite/Vector/Vec4.php @@ -1,18 +1,14 @@ validateComponentCount($components, 4); - $this->validateNumericComponents($components); - } - public function getX(): float { return $this->getComponent(0); @@ -71,4 +67,9 @@ public function setValue(mixed $value): void $this->validateComponents($value); $this->components = $value; } -} \ No newline at end of file + protected function validateComponents(array $components): void + { + $this->validateComponentCount($components, 4); + $this->validateNumericComponents($components); + } +} diff --git a/src/Exceptions/ImmutableException.php b/src/Exceptions/ImmutableException.php index acbb13d..5604d64 100644 --- a/src/Exceptions/ImmutableException.php +++ b/src/Exceptions/ImmutableException.php @@ -1,5 +1,9 @@ $union->getActiveType(), + 'value' => $union->getValue() + ]; + } +} + +if (!function_exists('fromUnion')) { + function fromUnion(array $data): UnionType + { + if (!isset($data['activeType']) || !isset($data['value'])) { + throw new InvalidArgumentException('Invalid union data format'); + } + $union = new UnionType([$data['activeType'] => $data['activeType']]); + if ($data['activeType'] !== null) { + $union->setValue($data['activeType'], $data['value']); + } + return $union; + } +} + +// --- Serialization/Deserialization Helpers --- + +// StringArray +if (!function_exists('toJsonStringArray')) { + function toJsonStringArray(StringArray $arr): string { return json_encode($arr->toArray()); } +} +if (!function_exists('fromJsonStringArray')) { + function fromJsonStringArray(string $json): StringArray { return new StringArray(json_decode($json, true)); } +} + +// IntArray +if (!function_exists('toJsonIntArray')) { + function toJsonIntArray(IntArray $arr): string { return json_encode($arr->toArray()); } +} +if (!function_exists('fromJsonIntArray')) { + function fromJsonIntArray(string $json): IntArray { return new IntArray(json_decode($json, true)); } +} + +// FloatArray +if (!function_exists('toJsonFloatArray')) { + function toJsonFloatArray(FloatArray $arr): string { return json_encode($arr->toArray()); } +} +if (!function_exists('fromJsonFloatArray')) { + function fromJsonFloatArray(string $json): FloatArray { return new FloatArray(json_decode($json, true)); } +} + +// ByteSlice +if (!function_exists('toJsonByteSlice')) { + function toJsonByteSlice(ByteSlice $arr): string { return json_encode($arr->toArray()); } +} +if (!function_exists('fromJsonByteSlice')) { + function fromJsonByteSlice(string $json): ByteSlice { return new ByteSlice(json_decode($json, true)); } +} + +// Struct +if (!function_exists('toJsonStruct')) { + function toJsonStruct(Struct $struct): string { return json_encode($struct->toArray()); } +} +if (!function_exists('fromJsonStruct')) { + function fromJsonStruct(string $json): Struct { return new Struct(json_decode($json, true)); } +} + +// Dictionary +if (!function_exists('toJsonDictionary')) { + function toJsonDictionary(Dictionary $dict): string { return json_encode($dict->toArray()); } +} +if (!function_exists('fromJsonDictionary')) { + function fromJsonDictionary(string $json): Dictionary { return new Dictionary(json_decode($json, true)); } +} + +// ListData +if (!function_exists('toJsonListData')) { + function toJsonListData(ListData $list): string { return json_encode($list->toArray()); } +} +if (!function_exists('fromJsonListData')) { + function fromJsonListData(string $json): ListData { return new ListData(json_decode($json, true)); } +} + +// UnionType (already present for JSON, XML, Binary) +if (!function_exists('unionToJson')) { + function unionToJson(UnionType $union): string { return $union->toJson(); } +} +if (!function_exists('unionFromJson')) { + function unionFromJson(string $json): UnionType { return UnionType::fromJson($json); } +} +if (!function_exists('unionToXml')) { + function unionToXml(UnionType $union, ?string $namespaceUri = null, ?string $prefix = null): string { return $union->toXml($namespaceUri, $prefix); } +} +if (!function_exists('unionFromXml')) { + function unionFromXml(string $xml): UnionType { return UnionType::fromXml($xml); } +} +if (!function_exists('unionToBinary')) { + function unionToBinary(UnionType $union): string { return $union->toBinary(); } +} +if (!function_exists('unionFromBinary')) { + function unionFromBinary(string $binary): UnionType { return UnionType::fromBinary($binary); } +} + +// --- XML and Binary Serialization/Deserialization Helpers --- + +// StringArray +if (!function_exists('toXmlStringArray')) { + function toXmlStringArray(StringArray $arr): string { + $xml = new SimpleXMLElement(''); + foreach ($arr->toArray() as $item) { + $xml->addChild('item', htmlspecialchars((string)$item)); + } + return $xml->asXML(); + } +} +if (!function_exists('fromXmlStringArray')) { + function fromXmlStringArray(string $xml): StringArray { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false && isset($data->item)) { + foreach ($data->item as $item) { + $arr[] = (string)$item; + } + } + return new StringArray($arr); + } +} +if (!function_exists('toBinaryStringArray')) { + function toBinaryStringArray(StringArray $arr): string { return serialize($arr->toArray()); } +} +if (!function_exists('fromBinaryStringArray')) { + function fromBinaryStringArray(string $bin): StringArray { return new StringArray(unserialize($bin)); } +} + +// IntArray +if (!function_exists('toXmlIntArray')) { + function toXmlIntArray(IntArray $arr): string { + $xml = new SimpleXMLElement(''); + foreach ($arr->toArray() as $item) { + $xml->addChild('item', (string)$item); + } + return $xml->asXML(); + } +} +if (!function_exists('fromXmlIntArray')) { + function fromXmlIntArray(string $xml): IntArray { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false && isset($data->item)) { + foreach ($data->item as $item) { + $arr[] = (int)$item; + } + } + return new IntArray($arr); + } +} +if (!function_exists('toBinaryIntArray')) { + function toBinaryIntArray(IntArray $arr): string { return serialize($arr->toArray()); } +} +if (!function_exists('fromBinaryIntArray')) { + function fromBinaryIntArray(string $bin): IntArray { return new IntArray(unserialize($bin)); } +} + +// FloatArray +if (!function_exists('toXmlFloatArray')) { + function toXmlFloatArray(FloatArray $arr): string { + $xml = new SimpleXMLElement(''); + foreach ($arr->toArray() as $item) { + $xml->addChild('item', (string)$item); + } + return $xml->asXML(); + } +} +if (!function_exists('fromXmlFloatArray')) { + function fromXmlFloatArray(string $xml): FloatArray { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false && isset($data->item)) { + foreach ($data->item as $item) { + $arr[] = (float)$item; + } + } + return new FloatArray($arr); + } +} +if (!function_exists('toBinaryFloatArray')) { + function toBinaryFloatArray(FloatArray $arr): string { return serialize($arr->toArray()); } +} +if (!function_exists('fromBinaryFloatArray')) { + function fromBinaryFloatArray(string $bin): FloatArray { return new FloatArray(unserialize($bin)); } +} + +// ByteSlice +if (!function_exists('toXmlByteSlice')) { + function toXmlByteSlice(ByteSlice $arr): string { + $xml = new SimpleXMLElement(''); + foreach ($arr->toArray() as $item) { + $xml->addChild('item', (string)$item); + } + return $xml->asXML(); + } +} +if (!function_exists('fromXmlByteSlice')) { + function fromXmlByteSlice(string $xml): ByteSlice { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false && isset($data->item)) { + foreach ($data->item as $item) { + $arr[] = (int)$item; + } + } + return new ByteSlice($arr); + } +} +if (!function_exists('toBinaryByteSlice')) { + function toBinaryByteSlice(ByteSlice $arr): string { return serialize($arr->toArray()); } +} +if (!function_exists('fromBinaryByteSlice')) { + function fromBinaryByteSlice(string $bin): ByteSlice { return new ByteSlice(unserialize($bin)); } +} + +// Struct +if (!function_exists('toXmlStruct')) { + function toXmlStruct(Struct $struct): string { + $xml = new SimpleXMLElement(''); + foreach ($struct->toArray() as $key => $value) { + $xml->addChild($key, htmlspecialchars((string)$value)); + } + return $xml->asXML(); + } +} +if (!function_exists('fromXmlStruct')) { + function fromXmlStruct(string $xml): Struct { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false) { + foreach ($data as $key => $value) { + $arr[$key] = (string)$value; + } + } + return new Struct($arr); + } +} +if (!function_exists('toBinaryStruct')) { + function toBinaryStruct(Struct $struct): string { return serialize($struct->toArray()); } +} +if (!function_exists('fromBinaryStruct')) { + function fromBinaryStruct(string $bin): Struct { return new Struct(unserialize($bin)); } +} + +// Dictionary +if (!function_exists('toXmlDictionary')) { + function toXmlDictionary(Dictionary $dict): string { + $xml = new SimpleXMLElement(''); + foreach ($dict->toArray() as $key => $value) { + $item = $xml->addChild('item'); + $item->addChild('key', htmlspecialchars((string)$key)); + $item->addChild('value', htmlspecialchars((string)$value)); + } + return $xml->asXML(); + } +} +if (!function_exists('fromXmlDictionary')) { + function fromXmlDictionary(string $xml): Dictionary { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false && isset($data->item)) { + foreach ($data->item as $item) { + $k = isset($item->key) ? (string)$item->key : null; + $v = isset($item->value) ? (string)$item->value : null; + if ($k !== null) $arr[$k] = $v; + } + } + return new Dictionary($arr); + } +} +if (!function_exists('toBinaryDictionary')) { + function toBinaryDictionary(Dictionary $dict): string { return serialize($dict->toArray()); } +} +if (!function_exists('fromBinaryDictionary')) { + function fromBinaryDictionary(string $bin): Dictionary { return new Dictionary(unserialize($bin)); } +} + +// ListData +if (!function_exists('toXmlListData')) { + function toXmlListData(ListData $list): string { + $xml = new SimpleXMLElement(''); + foreach ($list->toArray() as $item) { + $xml->addChild('item', htmlspecialchars((string)$item)); + } + return $xml->asXML(); + } +} +if (!function_exists('fromXmlListData')) { + function fromXmlListData(string $xml): ListData { + $data = @simplexml_load_string($xml); + $arr = []; + if ($data !== false && isset($data->item)) { + foreach ($data->item as $item) { + $arr[] = (string)$item; + } + } + return new ListData($arr); + } +} +if (!function_exists('toBinaryListData')) { + function toBinaryListData(ListData $list): string { return serialize($list->toArray()); } +} +if (!function_exists('fromBinaryListData')) { + function fromBinaryListData(string $bin): ListData { return new ListData(unserialize($bin)); } +}