Skip to content

Commit 39b776e

Browse files
committed
Introduce DateTimeBasic and FloatBasic types; refactor exception handling and enhance typed values library
- Added `DateTimeBasic` type for PHP `DateTimeImmutable` representations with strict validation and ISO-8601 handling. - Introduced `FloatBasic` type to represent float values with validations. - Created `DateTimeType` and `FloatType` abstract classes with respective interfaces for consistent base behavior. - Refactored exception classes, replacing `TypeException` with more specific exceptions (`NumericTypeException`, `StringTypeException`, `DateTimeTypeException`) for granular error handling. - Updated `Assert` class to include numeric float validation and enhanced `nonEmptyString` verification. - Migrated integer and string types (`Integer`, `Str`) to new exception classes; adjusted tests accordingly. - Expanded documentation and examples in `README.md` and `USAGE.md` to include `Float` and `DateTime` types. - Maintained full test coverage, including mutation testing.
1 parent 5540dce commit 39b776e

34 files changed

+746
-137
lines changed

AIPROMT.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Main project instructions
2+
3+
## Events
4+
5+
### On new type generation
6+
7+
- add new type to `src/"type name"` folder
8+
- add new type assertions to `src/Code/Assert/Assert.php` if needed
9+
- add new type tests to `tests/Unit/"type name"/"TypeName"Test.php`
10+
- add new Exception class to `src/Exception` folder and use it
11+
12+
## Coding style
13+
14+
- PHP 8.2 used
15+
- use strict typing
16+
- follow PSR-12 coding standards
17+
- use meaningful variable and function names
18+
- keep code clean and readable
19+
- PSALM v6 static analysis is used
20+
21+
## Folder structure
22+
23+
- `src/Code` folder contains the framework internal code
24+
- other folders like `src/"type"` contains specific types
25+
- `src/psalmTest.php` contains types usage to avoid Psalm issues like `unused method`
26+
- `src/Code/Assert/Assert.php` contains `assert` methods, add new methods instead of checking conditions directly in classes. Prefer to use Assert::"methos()".
27+
28+
## Tests
29+
30+
- are in `tests` folder
31+
- used PhpUnit v11 and PEST v3
32+
- use PEST syntax `it` instead of `test`
33+
- keep the folder structure as tested src files
34+
- test coverage should be 100%
35+
- mutation tests used, avoid fails
36+
37+
## Documentation
38+
39+
- keep it simple
40+
- use Markdown format
41+
- include project overview in README.md, short examples of how to install, use, and extend
42+
- installation instructions, usage examples, develop instructions in `docs` folder

README.md

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,35 +24,57 @@ composer require georgii-web/php-typed-values
2424
Usage
2525
-----
2626

27-
Create and use typed integers with validation built in.
27+
Create and use typed values with validation built in.
2828

2929
```php
30-
use PhpTypedValues\\Integer\\PositiveInt;
31-
use PhpTypedValues\\Integer\\NonNegativeInt;
32-
use PhpTypedValues\\Integer\\WeekDayInt;
33-
use PhpTypedValues\\Integer\\Integer;
30+
// Integers
31+
use PhpTypedValues\Integer\IntegerBasic;
32+
use PhpTypedValues\Integer\PositiveInt;
33+
use PhpTypedValues\Integer\NonNegativeInt;
34+
use PhpTypedValues\Integer\WeekDayInt;
3435

35-
$age = new PositiveInt(27); // ok (positive-int)
36-
$retries = new NonNegativeInt(0); // ok (0 or positive)
37-
$weekday = new WeekDayInt(5); // ok (1..7)
38-
$any = new Integer(-42); // ok (any integer)
36+
$age = new PositiveInt(27); // ok (positive-int)
37+
$retries = new NonNegativeInt(0); // ok (0 or positive)
38+
$weekday = new WeekDayInt(5); // ok (1..7)
39+
$anyInt = new IntegerBasic(-42); // ok (any integer)
3940

40-
// Construct from string
4141
$fromString = PositiveInt::fromString('123');
4242

43-
// Access the underlying scalar value
44-
$ageValue = $age->value(); // 27
45-
echo $weekday->toString(); // "5"
43+
// Strings
44+
use PhpTypedValues\String\StringBasic;
45+
use PhpTypedValues\String\NonEmptyStr;
46+
47+
$greeting = StringBasic::fromString('hello');
48+
$name = new NonEmptyStr('Alice'); // throws if empty
49+
50+
// Floats
51+
use PhpTypedValues\Float\FloatBasic;
52+
use PhpTypedValues\Float\NonNegativeFloat;
53+
54+
$price = FloatBasic::fromString('19.99');
55+
$ratio = new NonNegativeFloat(0.5); // > 0 required
56+
57+
// Access the underlying scalar value / string form
58+
$ageValue = $age->value(); // 27
59+
echo $weekday->toString(); // "5"
60+
echo $price->toString(); // "19.99"
4661
```
4762

4863
All value objects are immutable; invalid input throws an exception with a helpful message.
4964

50-
Provided integer types (so far):
51-
52-
- Integer — any PHP integer
53-
- PositiveInt — positive integer (> 0)
54-
- NonNegativeInt — zero or positive integer (>= 0)
55-
- WeekDayInt — integer in range 1..7
65+
Provided types (so far):
66+
67+
- Integers
68+
- IntegerBasic — any PHP integer
69+
- PositiveInt — positive integer (> 0)
70+
- NonNegativeInt — zero or positive integer (>= 0)
71+
- WeekDayInt — integer in range 1..7
72+
- Strings
73+
- StringBasic — any PHP string
74+
- NonEmptyStr — non-empty string
75+
- Floats
76+
- FloatBasic — any PHP float
77+
- PositiveFloat — positive float (> 0)
5678

5779
Why
5880
---

docs/USAGE.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Usage
22
=====
33

4-
This page shows concise, up-to-date examples for the available integer value objects.
4+
This page shows concise, up-to-date examples for the available Integer, String, and Float value objects.
55

66
Namespaces
77
----------
@@ -15,31 +15,59 @@ PhpTypedValues
1515
Available integer types
1616
-----------------------
1717

18-
- PhpTypedValues\Integer\Integer — any PHP integer
18+
- PhpTypedValues\Integer\IntegerBasic — any PHP integer
1919
- PhpTypedValues\Integer\PositiveInt — positive integer (> 0)
2020
- PhpTypedValues\Integer\NonNegativeInt — zero or positive integer (>= 0)
2121
- PhpTypedValues\Integer\WeekDayInt — integer in range 1..7
2222

23+
Available string types
24+
----------------------
25+
26+
- PhpTypedValues\String\StringBasic — any PHP string
27+
- PhpTypedValues\String\NonEmptyStr — non-empty string
28+
29+
Available float types
30+
---------------------
31+
32+
- PhpTypedValues\Float\FloatBasic — any PHP float
33+
- PhpTypedValues\Float\PositiveFloat — positive float (> 0)
34+
2335
Quick start
2436
-----------
2537

2638
```php
27-
use PhpTypedValues\Integer\Integer;
39+
// Integers
40+
use PhpTypedValues\Integer\IntegerBasic;
2841
use PhpTypedValues\Integer\NonNegativeInt;
2942
use PhpTypedValues\Integer\PositiveInt;
3043
use PhpTypedValues\Integer\WeekDayInt;
3144

32-
$any = new Integer(-10); // ok
45+
$any = new IntegerBasic(-10); // ok
3346
$pos = new PositiveInt(1); // ok
3447
$nn = new NonNegativeInt(0); // ok
3548
$wd = new WeekDayInt(7); // ok (1..7)
3649

3750
// From string
3851
$posFromString = PositiveInt::fromString('123');
3952

53+
// Strings
54+
use PhpTypedValues\String\StringBasic;
55+
use PhpTypedValues\String\NonEmptyStr;
56+
57+
$greeting = StringBasic::fromString('hello');
58+
$name = new NonEmptyStr('Alice'); // throws if empty
59+
60+
// Floats
61+
use PhpTypedValues\Float\FloatBasic;
62+
use PhpTypedValues\Float\NonNegativeFloat;
63+
64+
$price = FloatBasic::fromString('19.99');
65+
$ratio = new NonNegativeFloat(0.5); // > 0
66+
4067
// Accessing the raw value and string form
4168
echo $pos->value(); // 1
4269
echo $wd->toString(); // "7"
70+
echo $price->toString(); // "19.99"
4371
```
4472

4573
Validation errors
@@ -49,13 +77,20 @@ Invalid input throws an exception with a helpful message.
4977

5078
```php
5179
use PhpTypedValues\Integer\PositiveInt;
80+
use PhpTypedValues\String\NonEmptyStr;
81+
use PhpTypedValues\Float\NonNegativeFloat;
5282

53-
new PositiveInt(0); // throws: Value must be a positive integer
83+
new PositiveInt(0); // throws: Value must be a positive integer
5484
PositiveInt::fromString('12.3'); // throws: String has no valid integer
85+
86+
new NonEmptyStr(''); // throws: Value must be a non-empty string
87+
88+
new NonNegativeFloat(0.0); // throws: Value must be a positive float
89+
NonNegativeFloat::fromString('abc'); // throws: String has no valid float
5590
```
5691

5792
Notes
5893
-----
5994

6095
- All value objects are immutable (readonly) and type-safe.
61-
- Utility constructors: fromInt(int) and fromString(string) are provided where applicable.
96+
- Utility constructors: fromInt(int)/fromString(string), fromFloat(float)/fromString(string) are provided where applicable.

src/Code/Assert/Assert.php

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,75 +4,68 @@
44

55
namespace PhpTypedValues\Code\Assert;
66

7-
use PhpTypedValues\Code\Exception\TypeException;
7+
use PhpTypedValues\Code\Exception\NumericTypeException;
8+
use PhpTypedValues\Code\Exception\StringTypeException;
89

910
use function is_numeric;
1011

1112
final class Assert
1213
{
1314
/**
14-
* @throws TypeException
15+
* @throws NumericTypeException
1516
*/
1617
public static function greaterThanEq(int|float $value, int|float $min, string $message = ''): void
1718
{
1819
if ($value < $min) {
19-
throw new TypeException($message !== '' ? $message : 'Expected a value greater than or equal to the minimum');
20+
throw new NumericTypeException($message !== '' ? $message : 'Expected a value greater than or equal to the minimum');
2021
}
2122
}
2223

2324
/**
24-
* @throws TypeException
25+
* @throws NumericTypeException
2526
*/
2627
public static function lessThanEq(int|float $value, int|float $max, string $message = ''): void
2728
{
2829
if ($value > $max) {
29-
throw new TypeException($message !== '' ? $message : 'Expected a value less than or equal to the maximum');
30+
throw new NumericTypeException($message !== '' ? $message : 'Expected a value less than or equal to the maximum');
3031
}
3132
}
3233

33-
// /**
34-
// * @throws TypeException
35-
// */
36-
// public static function greaterThan(int|float $value, int|float $min, string $message = ''): void
37-
// {
38-
// if ($value <= $min) {
39-
// throw new TypeException($message !== '' ? $message : 'Expected a value greater than the minimum');
40-
// }
41-
// }
42-
//
43-
// /**
44-
// * @throws TypeException
45-
// */
46-
// public static function lessThan(int|float $value, int|float $max, string $message = ''): void
47-
// {
48-
// if ($value >= $max) {
49-
// throw new TypeException($message !== '' ? $message : 'Expected a value less than the maximum');
50-
// }
51-
// }
52-
5334
/**
5435
* Assert that the given value looks like an integer ("integerish").
5536
* Accepts numeric strings that represent an integer value (e.g., '5', '5.0'),
5637
* but rejects non-numeric strings and floats with a fractional part (e.g., '5.5').
5738
*
58-
* @throws TypeException
39+
* @throws NumericTypeException
5940
*/
6041
public static function integerish(mixed $value, string $message = ''): void
6142
{
6243
if (!is_numeric($value) || $value != (int) $value) {
63-
throw new TypeException($message !== '' ? $message : 'Expected an "integerish" value');
44+
throw new NumericTypeException($message !== '' ? $message : 'Expected an "integerish" value');
45+
}
46+
}
47+
48+
/**
49+
* Assert that the given value is numeric (int/float or numeric string).
50+
*
51+
* @throws NumericTypeException
52+
*/
53+
public static function numeric(mixed $value, string $message = ''): void
54+
{
55+
if (!is_numeric($value)) {
56+
throw new NumericTypeException($message !== '' ? $message : 'Expected a numeric value');
6457
}
6558
}
6659

6760
/**
6861
* Assert that the given string is non-empty.
6962
*
70-
* @throws TypeException
63+
* @throws StringTypeException
7164
*/
7265
public static function nonEmptyString(string $value, string $message = ''): void
7366
{
7467
if ($value === '') {
75-
throw new TypeException($message !== '' ? $message : 'Value must be a non-empty string');
68+
throw new StringTypeException($message !== '' ? $message : 'Value must be a non-empty string');
7669
}
7770
}
7871
}

src/Code/DateTime/DateTimeType.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\Code\DateTime;
6+
7+
use const DATE_ATOM;
8+
9+
use DateTimeImmutable;
10+
use Exception;
11+
use PhpTypedValues\Code\Exception\DateTimeTypeException;
12+
13+
/**
14+
* @psalm-immutable
15+
*/
16+
abstract readonly class DateTimeType implements DateTimeTypeInterface
17+
{
18+
/**
19+
* @throws DateTimeTypeException
20+
*/
21+
protected static function parseString(string $value): DateTimeImmutable
22+
{
23+
// Accept a small, explicit set of formats to avoid accidentally parsing invalid strings as "now".
24+
$formats = [
25+
DATE_ATOM, // e.g., 2025-01-02T03:04:05+00:00
26+
'Y-m-d\TH:i:s\Z', // e.g., 2025-01-02T03:04:05Z
27+
'Y-m-d H:i:s', // e.g., 2025-01-02 03:04:05
28+
'!Y-m-d', // e.g., 2025-01-02 (force midnight by resetting unspecified fields)
29+
];
30+
31+
foreach ($formats as $format) {
32+
$dt = DateTimeImmutable::createFromFormat($format, $value);
33+
if ($dt instanceof DateTimeImmutable) {
34+
$errors = DateTimeImmutable::getLastErrors();
35+
$hasIssues = $errors !== false && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
36+
if (!$hasIssues) {
37+
return $dt;
38+
}
39+
}
40+
}
41+
42+
// Fallback: try the engine parser for strict ISO-8601 only using DateTimeImmutable constructor
43+
try {
44+
$fallback = new DateTimeImmutable($value);
45+
} catch (Exception) {
46+
$fallback = null;
47+
}
48+
49+
if ($fallback instanceof DateTimeImmutable) {
50+
// Heuristic: if input clearly looks like an ISO 8601 (contains 'T' and either 'Z' or timezone offset), accept it
51+
if (preg_match('/T\d{2}:\d{2}:\d{2}(?:Z|[+\-]\d{2}:?\d{2})$/', $value) === 1) {
52+
return $fallback;
53+
}
54+
}
55+
56+
throw new DateTimeTypeException('String has no valid datetime');
57+
}
58+
59+
public function toString(): string
60+
{
61+
// ISO 8601 string representation
62+
return $this->value()->format(DATE_ATOM);
63+
}
64+
}

0 commit comments

Comments
 (0)