diff --git a/docs/book/v3/migration/v2-to-v3.md b/docs/book/v3/migration/v2-to-v3.md index 224e2a5f..9e9023f5 100644 --- a/docs/book/v3/migration/v2-to-v3.md +++ b/docs/book/v3/migration/v2-to-v3.md @@ -123,6 +123,24 @@ RuntimeException are no longer thrown when the filter receives an array with the All invalid values passed to the filter, invalid calendar dates, will now return the original value. Validators should be used to ensure the input has been filtered as expected, and to enforce any additional constraints. +#### `DateTimeSelect` + +The following methods have been removed: + +- `setOptions` +- `getOptions` +- `setNullOnAllEmpty` +- `isNullOnAllEmpty` +- `setNullOnEmpty` +- `isNullOnEmpty` + +The constructor now only accepts an associative array of [documented options](../standard-filters.md#datetimeselect). + +RuntimeException are no longer thrown when the filter receives an array with the incorrect number of elements. + +All invalid values passed to the filter, invalid calendar dates or times, will now return the original value. +Validators should be used to ensure the input has been filtered as expected, and to enforce any additional constraints. + #### `DenyList` The following methods have been removed: diff --git a/docs/book/v3/standard-filters.md b/docs/book/v3/standard-filters.md index 5dc3ae83..7050ea65 100644 --- a/docs/book/v3/standard-filters.md +++ b/docs/book/v3/standard-filters.md @@ -477,6 +477,30 @@ $filter = new \Laminas\Filter\DateTimeFormatter([ echo $filter->filter('2024-01-01'); // => 2024-01-01T00:00:00+01:00 ``` +## DateTimeSelect + +`Laminas\Filter\DateTimeSelect` allows you to filter second, minute, hour, day, month, and year values into a string of format `Y-m-d H:i:s`. +If not in the input array, second will default to 0. + +### Supported Options + +The following options are supported for `Laminas\Filter\DateTimeSelect`: + +- `null_on_empty` => This defaults to `false`. + If set to `true`, the filter will return `null` if minute, hour, day, month, or year are empty. +- `null_on_all_empty` => This defaults to `false`. + If set to `true`, the filter will return `null` if minute, hour, day, month, and year are empty. + +### Basic Usage + +```php +$filter = new Laminas\Filter\DateTimeSelect(); + +print $filter->filter(['second' => '1', 'month' => '2', 'hour' => '3', 'day' => '4', 'month' => '5', 'year' => '2012']); +```` + +This will return '2012-05-04 03:02:01'. + ## DecompressArchive This filter accepts an archive in the form of a file path, a PHP uploaded file array or a PSR-7 uploaded file and de-compresses the file to a configured target directory returning the location where the files are expanded. diff --git a/psalm-baseline.xml b/psalm-baseline.xml index da102fc6..0d76c2be 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,14 +1,5 @@ - - - - - - - - - @@ -31,11 +22,6 @@ - - - - - diff --git a/src/AbstractDateDropdown.php b/src/AbstractDateDropdown.php deleted file mode 100644 index aa7acf0a..00000000 --- a/src/AbstractDateDropdown.php +++ /dev/null @@ -1,147 +0,0 @@ - - * @template TOptions of Options - * @template-extends AbstractFilter - * @template TInput of array - */ -abstract class AbstractDateDropdown extends AbstractFilter -{ - /** - * If true, the filter will return null if any date field is empty - */ - protected bool $nullOnEmpty = false; - - /** - * If true, the filter will return null if all date fields are empty - */ - protected bool $nullOnAllEmpty = false; - - /** - * Sprintf format string to use for formatting the date, fields will be used in alphabetical order. - */ - protected string $format = ''; - - protected int $expectedInputs = 0; - - /** - * @param mixed $options If array or Traversable, passes value to - * setOptions(). - */ - public function __construct(mixed $options = null) - { - if (is_iterable($options)) { - $this->setOptions($options); - } - } - - /** - * @return $this - */ - public function setNullOnAllEmpty(bool $nullOnAllEmpty): self - { - $this->nullOnAllEmpty = $nullOnAllEmpty; - return $this; - } - - public function isNullOnAllEmpty(): bool - { - return $this->nullOnAllEmpty; - } - - /** - * @return $this - */ - public function setNullOnEmpty(bool $nullOnEmpty): self - { - $this->nullOnEmpty = $nullOnEmpty; - return $this; - } - - public function isNullOnEmpty(): bool - { - return $this->nullOnEmpty; - } - - /** - * Attempts to filter an array of date/time information to a formatted - * string. - * - * @throws Exception\RuntimeException If filtering $value is impossible. - * @psalm-return ($value is InputArray ? string : mixed|null) - */ - public function filter(mixed $value): mixed - { - if (! is_array($value)) { - // nothing to do - return $value; - } - - // Convert the date to a specific format - if ( - $this->isNullOnEmpty() - && array_reduce($value, self::reduce(...), false) - ) { - return null; - } - - if ( - $this->isNullOnAllEmpty() - && array_reduce($value, self::reduce(...), true) - ) { - return null; - } - - ksort($value); - $this->filterable($value); - - /** @psalm-var array $value Forcing the type here because it has already been asserted */ - - return vsprintf($this->format, $value); - } - - /** - * Ensures there are enough inputs in the array to properly format the date. - * - * @throws Exception\RuntimeException - * @psalm-assert TInput $value - */ - protected function filterable(array $value): void - { - if (count($value) !== $this->expectedInputs) { - throw new Exception\RuntimeException( - sprintf( - 'There are not enough values in the array to filter this date (Required: %d, Received: %d)', - $this->expectedInputs, - count($value) - ) - ); - } - } - - /** - * Reduce to a single value - */ - private static function reduce(bool $soFar, string|null $value): bool - { - return $soFar || ($value === null || $value === ''); - } -} diff --git a/src/DateTimeSelect.php b/src/DateTimeSelect.php index baba318c..f32e6946 100644 --- a/src/DateTimeSelect.php +++ b/src/DateTimeSelect.php @@ -4,80 +4,149 @@ namespace Laminas\Filter; +use DateTime; + use function is_array; -use function ksort; -use function vsprintf; +use function is_numeric; +use function sprintf; /** * @psalm-type Options = array{ * null_on_empty?: bool, * null_on_all_empty?: bool, - * ... - * } - * @psalm-type InputArray = array{ - * year: numeric, - * month: numeric, - * day: numeric, - * hour: numeric, - * minute: numeric, - * second: numeric, * } - * @template TOptions of Options - * @template-extends AbstractDateDropdown + * @implements FilterInterface */ -final class DateTimeSelect extends AbstractDateDropdown +final class DateTimeSelect implements FilterInterface { + private readonly bool $returnNullIfAnyFieldEmpty; + private readonly bool $returnNullIfAllFieldsEmpty; + + /** @param Options $options */ + public function __construct(array $options = []) + { + $this->returnNullIfAnyFieldEmpty = $options['null_on_empty'] ?? false; + $this->returnNullIfAllFieldsEmpty = $options['null_on_all_empty'] ?? false; + } + + public function __invoke(mixed $value): mixed + { + return $this->filter($value); + } + /** - * Year-Month-Day Hour:Min:Sec + * Returns the result of filtering $value + * + * @template T + * @param T $value + * @return string|null|T */ - protected string $format = '%6$s-%4$s-%1$s %2$s:%3$s:%5$s'; - protected int $expectedInputs = 6; - - /** @inheritDoc */ public function filter(mixed $value): mixed { if (! is_array($value)) { - // nothing to do return $value; } + $second = $this->getValue($value, 'second', 0); + $minute = $this->getValue($value, 'minute'); + $hour = $this->getValue($value, 'hour'); + $day = $this->getValue($value, 'day'); + $month = $this->getValue($value, 'month'); + $year = $this->getValue($value, 'year'); + if ( - $this->isNullOnEmpty() + $this->returnNullIfAnyFieldEmpty && ( - empty($value['year']) - || empty($value['month']) - || empty($value['day']) - || empty($value['hour']) - || empty($value['minute']) - || (isset($value['second']) && empty($value['second'])) + $day === null + || $month === null + || $year === null + || $hour === null + || $minute === null ) ) { return null; } if ( - $this->isNullOnAllEmpty() + $this->returnNullIfAllFieldsEmpty && ( - empty($value['year']) - && empty($value['month']) - && empty($value['day']) - && empty($value['hour']) - && empty($value['minute']) - && empty($value['second']) + $day === null + && $month === null + && $year === null + && $hour === null + && $minute === null ) ) { - // Cannot handle this value return null; } - if (! isset($value['second'])) { - $value['second'] = '00'; + if ($day === null || $month === null || $year === null || $hour === null || $minute === null) { + return $value; + } + + if (! $this->isParsableAsDateTimeValue($second, $minute, $hour, $day, $month, $year)) { + /** @psalm-var T */ + return $value; + } + + return sprintf('%d-%02d-%02d %02d:%02d:%02d', $year, $month, $day, $hour, $minute, $second); + } + + /** + * @psalm-assert-if-true int $second + * @psalm-assert-if-true int $minute + * @psalm-assert-if-true int $hour + * @psalm-assert-if-true int $day + * @psalm-assert-if-true int $month + * @psalm-assert-if-true int $year + */ + private function isParsableAsDateTimeValue( + mixed $second, + mixed $minute, + mixed $hour, + mixed $day, + mixed $month, + mixed $year + ): bool { + if ( + ! is_numeric($second) + || ! is_numeric($minute) + || ! is_numeric($hour) + || ! is_numeric($day) + || ! is_numeric($month) + || ! is_numeric($year) + ) { + return false; } - $this->filterable($value); + $date = DateTime::createFromFormat( + 'Y-m-d H:i:s', + sprintf('%d-%02d-%02d %02d:%02d:%02d', $year, $month, $day, $hour, $minute, $second) + ); - ksort($value); + if ( + ! $date + || $date->format('YmdHis') !== sprintf( + '%d%02d%02d%02d%02d%02d', + $year, + $month, + $day, + $hour, + $minute, + $second + ) + ) { + return false; + } - return vsprintf($this->format, $value); + return true; + } + + /** @param mixed[] $value */ + private function getValue(array $value, string $string, ?int $defult = null): mixed + { + /** @var mixed $result */ + $result = $value[$string] ?? $defult; + return $result === '' ? $defult : $result; } } diff --git a/test/DateTimeSelectTest.php b/test/DateTimeSelectTest.php index 870dd816..f55a26e6 100644 --- a/test/DateTimeSelectTest.php +++ b/test/DateTimeSelectTest.php @@ -5,17 +5,17 @@ namespace LaminasTest\Filter; use Laminas\Filter\DateTimeSelect as DateTimeSelectFilter; -use Laminas\Filter\Exception\RuntimeException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +/** @psalm-import-type Options from DateTimeSelectFilter */ class DateTimeSelectTest extends TestCase { + /** @param Options $options */ #[DataProvider('provideFilter')] public function testFilter(array $options, array $input, ?string $expected): void { - $sut = new DateTimeSelectFilter(); - $sut->setOptions($options); + $sut = new DateTimeSelectFilter($options); self::assertSame($expected, $sut->filter($input)); } @@ -29,18 +29,38 @@ public static function provideFilter(): array '2014-10-26 12:35:00', ], [ - ['nullOnEmpty' => true], + [], + ['year' => '2014', 'month' => '1', 'day' => '2', 'hour' => '3', 'minute' => '4', 'second' => '5'], + '2014-01-02 03:04:05', + ], + [ + [], + ['year' => 2014, 'month' => 1, 'day' => 2, 'hour' => 3, 'minute' => 4, 'second' => 5], + '2014-01-02 03:04:05', + ], + [ + ['null_on_empty' => true], ['year' => null, 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35'], null, ], [ ['null_on_empty' => true], - ['year' => null, 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35'], + ['year' => '2014', 'month' => null, 'day' => '26', 'hour' => '12', 'minute' => '35'], null, ], [ - ['nullOnAllEmpty' => true], - ['year' => null, 'month' => null, 'day' => null, 'hour' => null, 'minute' => null], + ['null_on_empty' => true], + ['year' => '2014', 'month' => '10', 'day' => null, 'hour' => '12', 'minute' => '35'], + null, + ], + [ + ['null_on_empty' => true], + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => null, 'minute' => '35'], + null, + ], + [ + ['null_on_empty' => true], + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => null], null, ], [ @@ -48,13 +68,123 @@ public static function provideFilter(): array ['year' => null, 'month' => null, 'day' => null, 'hour' => null, 'minute' => null], null, ], + [ + ['null_on_all_empty' => true], + [], + null, + ], + [ + ['null_on_all_empty' => true], + ['year' => '', 'month' => '', 'day' => '', 'hour' => '', 'minute' => ''], + null, + ], ]; } - public function testInvalidInput(): void + #[DataProvider('provideInvalidFilterValues')] + public function testInvalidInput(mixed $value): void { - $this->expectException(RuntimeException::class); $sut = new DateTimeSelectFilter(); - $sut->filter(['year' => '2120', 'month' => '10', 'day' => '26', 'hour' => '12']); + + self::assertSame($value, $sut->filter($value)); + } + + /** @return array */ + public static function provideInvalidFilterValues(): array + { + return [ + 'empty array' => [[]], + 'missing year' => [['month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35']], + 'missing month' => [['year' => '2014', 'day' => '26', 'hour' => '12', 'minute' => '35']], + 'missing day' => [['year' => '2014', 'month' => '10', 'hour' => '12', 'minute' => '35']], + 'missing hour' => [['year' => '2014', 'month' => '10', 'day' => '26', 'minute' => '35']], + 'missing minute' => [['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '12']], + 'passed bool' => [true], + 'passed string' => ['string'], + 'passed int' => [10], + 'passed float' => [10.5], + 'invalid keys' => [ + ['not year' => '2014', 'not month' => '10', 'not day' => '2', 'not hour' => '2', 'not minute' => '2'], + ], + 'year is invalid type' => [ + ['year' => true, 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35'], + ], + 'invalid year' => [ + ['year' => 'not a year', 'month' => '10', 'day' => '2', 'hour' => '12', 'minute' => '35'], + ], + 'year is float' => [ + ['year' => '1.5', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35'], + ], + 'year out of bounds' => [ + ['year' => '-1', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35'], + ], + 'invalid month' => [ + ['year' => '2023', 'month' => 'not a month', 'day' => '2', 'hour' => '12', 'minute' => '35'], + ], + 'month is too high' => [ + ['year' => '2014', 'month' => '13', 'day' => '2', 'hour' => '12', 'minute' => '35'], + ], + 'month is low' => [ + ['year' => '2014', 'month' => '0', 'day' => '2', 'hour' => '12', 'minute' => '35'], + ], + 'month is invalid type' => [ + ['year' => '2014', 'month' => true, 'day' => '2', 'hour' => '12', 'minute' => '35'], + ], + 'invalid day' => [ + ['year' => '2023', 'month' => '10', 'day' => 'not a day', 'hour' => '12', 'minute' => '35'], + ], + 'day is too high' => [ + ['year' => '2014', 'month' => '2', 'day' => '30', 'hour' => '12', 'minute' => '35'], + ], + 'day is low' => [ + ['year' => '2014', 'month' => '0', 'day' => '2', 'hour' => '12', 'minute' => '35'], + ], + 'day is invalid type' => [ + ['year' => '2014', 'month' => '09', 'day' => true, 'hour' => '12', 'minute' => '35'], + ], + 'invalid hour' => [ + ['year' => '2023', 'month' => '10', 'day' => '26', 'hour' => 'not an hour', 'minute' => '35'], + ], + 'hour is too high' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '24', 'minute' => '35'], + ], + 'hour is low' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '-1', 'minute' => '35'], + ], + 'hour is invalid type' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => true, 'minute' => '35'], + ], + 'invalid minute' => [ + ['year' => '2023', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => 'not a minute'], + ], + 'minute is too high' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '60'], + ], + 'minute is low' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '-1'], + ], + 'minute is invalid type' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => true], + ], + 'invalid second' => [ + [ + 'year' => '2023', + 'month' => '10', + 'day' => '26', + 'hour' => '12', + 'minute' => '35', + 'second' => 'not a second', + ], + ], + 'second is too high' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35', 'second' => '60'], + ], + 'second is low' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35', 'second' => '-1'], + ], + 'second is invalid type' => [ + ['year' => '2014', 'month' => '10', 'day' => '26', 'hour' => '12', 'minute' => '35', 'second' => true], + ], + ]; } }