diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffe8dc..21dd572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `DateTimeFromStringDecoder` now supports strict mode configuration to enforce strict date parsing (default: true). In strict mode, invalid dates like "2025-04-31" will be rejected instead of being automatically adjusted. + ### Removed - Support for PHP < 7.4 - `Codec` and `Encoder` interfaces. Removed `Codecs` entrypoint. diff --git a/src/Decoder.php b/src/Decoder.php index 9b9dfa5..90b1963 100644 --- a/src/Decoder.php +++ b/src/Decoder.php @@ -14,21 +14,21 @@ interface Decoder { /** + * @param mixed $i + * * @psalm-param I $i * @psalm-param Context $context * * @psalm-return Validation - * - * @param mixed $i */ public function validate($i, Context $context): Validation; /** + * @param mixed $i + * * @psalm-param I $i * * @psalm-return Validation - * - * @param mixed $i */ public function decode($i): Validation; diff --git a/src/Decoders.php b/src/Decoders.php index d0d41ca..df74117 100644 --- a/src/Decoders.php +++ b/src/Decoders.php @@ -200,11 +200,11 @@ public static function transformValidationSuccess(callable $f, Decoder $da): Dec /** * @psalm-template T of bool | string | int * + * @param mixed $l + * * @psalm-param T $l * * @psalm-return Decoder - * - * @param mixed $l */ public static function literal($l): Decoder { @@ -227,16 +227,16 @@ public static function listOf(Decoder $elementDecoder): Decoder /** * @psalm-template MapOfDecoders of non-empty-array> * - * @psalm-param MapOfDecoders $props - * - * @psalm-return Decoder> - * * @param Decoder[] $props * + * @psalm-param MapOfDecoders $props + * * @return Decoder * * Waiting for this feature to provide a better typing. I need something like mapped types from Typescript. * + * @psalm-return Decoder> + * * @see https://github.com/vimeo/psalm/issues/3589 */ public static function arrayProps(array $props): Decoder @@ -281,11 +281,11 @@ public static function classFromArrayPropsDecoder( /** * @psalm-template U * + * @param null|mixed $default + * * @psalm-param U $default * * @psalm-return Decoder - * - * @param null|mixed $default */ public static function undefined($default = null): Decoder { @@ -365,9 +365,9 @@ public static function intFromString(): Decoder /** * @psalm-return Decoder */ - public static function dateTimeFromString(string $format = \DATE_ATOM): Decoder + public static function dateTimeFromString(string $format = \DATE_ATOM, bool $strict = true): Decoder { - return new DateTimeFromStringDecoder($format); + return new DateTimeFromStringDecoder($format, $strict); } /** diff --git a/src/Internal/Combinators/ComposeDecoder.php b/src/Internal/Combinators/ComposeDecoder.php index 06cfbde..7c8320c 100644 --- a/src/Internal/Combinators/ComposeDecoder.php +++ b/src/Internal/Combinators/ComposeDecoder.php @@ -38,20 +38,20 @@ public function __construct( } /** + * @param mixed $i + * * @psalm-param IA $i * @psalm-param Context $context * * @psalm-return Validation - * - * @param mixed $i */ public function validate($i, Context $context): Validation { return Validation::bind( /** - * @psalm-param A $aValue - * * @param mixed $aValue + * + * @psalm-param A $aValue */ fn($aValue): Validation => $this->db->validate($aValue, $context), $this->da->validate($i, $context) diff --git a/src/Internal/Combinators/IntersectionDecoder.php b/src/Internal/Combinators/IntersectionDecoder.php index 53cfe01..b0da8b0 100644 --- a/src/Internal/Combinators/IntersectionDecoder.php +++ b/src/Internal/Combinators/IntersectionDecoder.php @@ -92,15 +92,15 @@ public function getName(): string * @template T1 * @template T2 * - * @psalm-param T1 $a - * @psalm-param T2 $b - * - * @psalm-return T1&T2 - * * @param mixed $a * @param mixed $b * + * @psalm-param T1 $a + * @psalm-param T2 $b + * * @return array|object + * + * @psalm-return T1&T2 */ private static function intersectResults($a, $b) { diff --git a/src/Internal/Combinators/LiteralDecoder.php b/src/Internal/Combinators/LiteralDecoder.php index 0fe5d8b..a3c4cb9 100644 --- a/src/Internal/Combinators/LiteralDecoder.php +++ b/src/Internal/Combinators/LiteralDecoder.php @@ -29,9 +29,9 @@ final class LiteralDecoder implements Decoder private $literal; /** - * @psalm-param T $literal - * * @param mixed $literal + * + * @psalm-param T $literal */ public function __construct($literal) { @@ -58,9 +58,9 @@ public function getName(): string } /** - * @psalm-param literable $x - * * @param mixed $x + * + * @psalm-param literable $x */ private static function literalName($x): string { diff --git a/src/Internal/FunctionUtils.php b/src/Internal/FunctionUtils.php index fb7d76c..fa5dcf9 100644 --- a/src/Internal/FunctionUtils.php +++ b/src/Internal/FunctionUtils.php @@ -12,9 +12,9 @@ final class FunctionUtils { /** - * @psalm-param non-empty-array $props - * * @param Decoder[] $props + * + * @psalm-param non-empty-array $props */ public static function nameFromProps(array $props): string { @@ -39,12 +39,12 @@ public static function nameFromProps(array $props): string * @psalm-template A * @psalm-template I * + * @param mixed $input + * * @psalm-param Decoder $decoder * @psalm-param I $input * * @psalm-return Validation - * - * @param mixed $input */ public static function standardDecode(Decoder $decoder, $input): Validation { diff --git a/src/Internal/Primitives/UndefinedDecoder.php b/src/Internal/Primitives/UndefinedDecoder.php index 3c9dd6b..0f142f3 100644 --- a/src/Internal/Primitives/UndefinedDecoder.php +++ b/src/Internal/Primitives/UndefinedDecoder.php @@ -23,9 +23,9 @@ final class UndefinedDecoder implements Decoder private $default; /** - * @psalm-param U $default - * * @param mixed $default + * + * @psalm-param U $default */ public function __construct($default) { diff --git a/src/Internal/Useful/DateTimeFromStringDecoder.php b/src/Internal/Useful/DateTimeFromStringDecoder.php index 525a108..f79b227 100644 --- a/src/Internal/Useful/DateTimeFromStringDecoder.php +++ b/src/Internal/Useful/DateTimeFromStringDecoder.php @@ -21,9 +21,15 @@ final class DateTimeFromStringDecoder implements Decoder */ private string $format; - public function __construct(string $format = \DATE_ATOM) + /** + * @psalm-readonly + */ + private bool $strict; + + public function __construct(string $format = \DATE_ATOM, bool $strict = true) { $this->format = $format; + $this->strict = $strict; } public function validate($i, Context $context): Validation @@ -39,6 +45,14 @@ public function validate($i, Context $context): Validation return Validation::failure($i, $context); } + // In strict mode, check if there were any parsing errors or warnings + if ($this->strict) { + $errors = \DateTime::getLastErrors(); + if ($errors !== false && ($errors['error_count'] > 0 || $errors['warning_count'] > 0)) { + return Validation::failure($i, $context); + } + } + /** @var \DateTimeInterface $r */ return Validation::success($r); } diff --git a/src/Validation/ContextEntry.php b/src/Validation/ContextEntry.php index de8296c..65172b8 100644 --- a/src/Validation/ContextEntry.php +++ b/src/Validation/ContextEntry.php @@ -14,11 +14,11 @@ final class ContextEntry private $actual; /** + * @param mixed $actual + * * @psalm-param string $key * @psalm-param Decoder $decoder * @psalm-param mixed $actual - * - * @param mixed $actual */ public function __construct( string $key, diff --git a/src/Validation/ListOfValidation.php b/src/Validation/ListOfValidation.php index 38bef12..7715fee 100644 --- a/src/Validation/ListOfValidation.php +++ b/src/Validation/ListOfValidation.php @@ -81,9 +81,9 @@ function (array $es) use (&$errors): void { $errors[] = $es; }, /** - * @psalm-param Values $x - * * @param mixed $x + * + * @psalm-param Values $x */ function ($x) use ($k, &$results): void { /** @var K $k */ diff --git a/src/Validation/VError.php b/src/Validation/VError.php index d2660cd..98c396c 100644 --- a/src/Validation/VError.php +++ b/src/Validation/VError.php @@ -12,11 +12,11 @@ final class VError private ?string $message = null; /** + * @param mixed $value + * * @psalm-param mixed $value * @psalm-param Context $context * @psalm-param string|null $message - * - * @param mixed $value */ public function __construct( $value, diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php index 965cf44..0178b89 100644 --- a/src/Validation/Validation.php +++ b/src/Validation/Validation.php @@ -12,11 +12,11 @@ abstract class Validation /** * @psalm-template T * + * @param mixed $a + * * @psalm-param T $a * * @psalm-return ValidationSuccess - * - * @param mixed $a */ public static function success($a): self { @@ -26,11 +26,11 @@ public static function success($a): self /** * @psalm-template T * + * @param VError[] $errors + * * @psalm-param list $errors * * @psalm-return ValidationFailures - * - * @param VError[] $errors */ public static function failures(array $errors): self { @@ -40,13 +40,13 @@ public static function failures(array $errors): self /** * @psalm-template T * + * @param mixed $value + * * @psalm-param mixed $value * @psalm-param Context $context * @psalm-param string|null $message * * @psalm-return ValidationFailures - * - * @param mixed $value */ public static function failure($value, Context $context, ?string $message = null): self { diff --git a/src/Validation/ValidationFailures.php b/src/Validation/ValidationFailures.php index a28ae54..a4c6c97 100644 --- a/src/Validation/ValidationFailures.php +++ b/src/Validation/ValidationFailures.php @@ -15,9 +15,9 @@ final class ValidationFailures extends Validation private array $errors; /** - * @psalm-param list $errors - * * @param VError[] $errors + * + * @psalm-param list $errors */ public function __construct(array $errors) { @@ -25,9 +25,9 @@ public function __construct(array $errors) } /** - * @psalm-return list - * * @return VError[] + * + * @psalm-return list */ public function getErrors(): array { diff --git a/src/Validation/ValidationSuccess.php b/src/Validation/ValidationSuccess.php index 8b2d1ac..98dc256 100644 --- a/src/Validation/ValidationSuccess.php +++ b/src/Validation/ValidationSuccess.php @@ -15,9 +15,9 @@ final class ValidationSuccess extends Validation private $value; /** - * @psalm-param A $a - * * @param mixed $a + * + * @psalm-param A $a */ public function __construct($a) { diff --git a/tests/unit/Internal/Useful/DateTimeFromStringDecoderTest.php b/tests/unit/Internal/Useful/DateTimeFromStringDecoderTest.php index 42c3251..13a7f30 100644 --- a/tests/unit/Internal/Useful/DateTimeFromStringDecoderTest.php +++ b/tests/unit/Internal/Useful/DateTimeFromStringDecoderTest.php @@ -7,6 +7,7 @@ use Eris\Generators; use Eris\TestTrait; use Facile\PhpCodec\Decoders; +use Facile\PhpCodec\Validation\ValidationFailures; use Tests\Facile\PhpCodec\BaseTestCase; /** @psalm-suppress PropertyNotSetInConstructor */ @@ -64,4 +65,48 @@ public function test(): void ); }); } + + public function testStrictMode(): void + { + // Test strict mode (default) - should reject invalid dates + $strictDecoder = Decoders::dateTimeFromString('Y-m-d', true); + + // Valid date should pass + self::assertSuccessInstanceOf( + \DateTimeInterface::class, + $strictDecoder->decode('2025-04-30') + ); + + // Invalid date should fail in strict mode (April 31st doesn't exist) + self::assertInstanceOf( + ValidationFailures::class, + $strictDecoder->decode('2025-04-31') + ); + + // Invalid leap year date should fail in strict mode + self::assertInstanceOf( + ValidationFailures::class, + $strictDecoder->decode('2023-02-29') + ); + + // Valid leap year date should pass in strict mode + self::assertSuccessInstanceOf( + \DateTimeInterface::class, + $strictDecoder->decode('2024-02-29') + ); + + // Test non-strict mode - should accept invalid dates and adjust them + $nonStrictDecoder = Decoders::dateTimeFromString('Y-m-d', false); + + // Valid date should pass + self::assertSuccessInstanceOf( + \DateTimeInterface::class, + $nonStrictDecoder->decode('2025-04-30') + ); + + // Invalid date should pass in non-strict mode (but be adjusted) + $result = $nonStrictDecoder->decode('2025-04-31'); + $successResult = self::assertSuccessInstanceOf(\DateTimeInterface::class, $result); + self::assertEquals('2025-05-01', $successResult->format('Y-m-d')); + } }