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'));
+ }
}