Implement datetime filter

[MAILPOET-5000]
This commit is contained in:
Jan Jakes
2023-04-14 15:23:46 +02:00
committed by Aschepikov
parent 607e193c0d
commit 2a38e639db
4 changed files with 450 additions and 1 deletions

View File

@ -2,6 +2,7 @@
namespace MailPoet\Automation\Engine;
use DateTimeZone;
use WP_User;
class WordPress {
@ -23,6 +24,10 @@ class WordPress {
return apply_filters($hookName, $value, ...$args);
}
public function wpTimezone(): DateTimeZone {
return wp_timezone();
}
public function wpGetCurrentUser(): WP_User {
return wp_get_current_user();
}

View File

@ -4,16 +4,22 @@ namespace MailPoet\Automation\Integrations\Core;
use MailPoet\Automation\Engine\Integration;
use MailPoet\Automation\Engine\Registry;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\Core\Actions\DelayAction;
class CoreIntegration implements Integration {
/** @var DelayAction */
private $delayAction;
/** @var WordPress */
private $wordPress;
public function __construct(
DelayAction $delayAction
DelayAction $delayAction,
WordPress $wordPress
) {
$this->delayAction = $delayAction;
$this->wordPress = $wordPress;
}
public function register(Registry $registry): void {
@ -23,6 +29,7 @@ class CoreIntegration implements Integration {
$registry->addFilter(new Filters\NumberFilter());
$registry->addFilter(new Filters\IntegerFilter());
$registry->addFilter(new Filters\StringFilter());
$registry->addFilter(new Filters\DateTimeFilter($this->wordPress->wpTimezone()));
$registry->addFilter(new Filters\EnumFilter());
$registry->addFilter(new Filters\EnumArrayFilter());
}

View File

@ -0,0 +1,173 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\Core\Filters;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use MailPoet\Automation\Engine\Data\Field;
use MailPoet\Automation\Engine\Data\Filter as FilterData;
use MailPoet\Automation\Engine\Exceptions\InvalidStateException;
use MailPoet\Automation\Engine\Integration\Filter;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class DateTimeFilter implements Filter {
public const CONDITION_BEFORE = 'before';
public const CONDITION_AFTER = 'after';
public const CONDITION_ON = 'on';
public const CONDITION_NOT_ON = 'not-on';
public const CONDITION_IN_THE_LAST = 'in-the-last';
public const CONDITION_NOT_IN_THE_LAST = 'not-in-the-last';
public const CONDITION_IS_SET = 'is-set';
public const CONDITION_IS_NOT_SET = 'is-not-set';
public const CONDITION_ON_THE_DAYS_OF_THE_WEEK = 'on-the-days-of-the-week';
public const FORMAT_DATETIME = 'Y-m-d\TH:i:s';
public const FORMAT_DATE = 'Y-m-d';
public const REGEX_DATETIME = '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$';
public const REGEX_DATE = '^\d{4}-\d{2}-\d{2}$';
/** @var DateTimeZone */
private $timezone;
public function __construct(
DateTimeZone $timeZone
) {
$this->timezone = $timeZone;
}
public function getFieldType(): string {
return Field::TYPE_DATETIME;
}
public function getConditions(): array {
return [
self::CONDITION_BEFORE => __('before', 'mailpoet'),
self::CONDITION_AFTER => __('after', 'mailpoet'),
self::CONDITION_ON => __('on', 'mailpoet'),
self::CONDITION_NOT_ON => __('not on', 'mailpoet'),
self::CONDITION_IN_THE_LAST => __('in the last', 'mailpoet'),
self::CONDITION_NOT_IN_THE_LAST => __('not in the last', 'mailpoet'),
self::CONDITION_IS_SET => __('is set', 'mailpoet'),
self::CONDITION_IS_NOT_SET => __('is not set', 'mailpoet'),
self::CONDITION_ON_THE_DAYS_OF_THE_WEEK => __('on the day(s) of the week', 'mailpoet'),
];
}
public function getArgsSchema(): ObjectSchema {
return Builder::object([
'value' => Builder::oneOf([
Builder::string()->pattern(self::REGEX_DATETIME),
Builder::string()->pattern(self::REGEX_DATE),
Builder::array(Builder::integer()->minimum(0)->maximum(6))->minItems(1),
Builder::object([
'number' => Builder::integer()->minimum(1)->required(),
'unit' => Builder::string()->pattern('^days|weeks|months$')->required(),
]),
]),
]);
}
public function matches(FilterData $data, $value): bool {
$filterValue = $data->getArgs()['value'] ?? null;
$condition = $data->getCondition();
// is set/is not set
if (in_array($condition, [self::CONDITION_IS_SET, self::CONDITION_IS_NOT_SET], true)) {
return $this->matchesSet($condition, $value);
}
// in the last/not in the last
if (in_array($condition, [self::CONDITION_IN_THE_LAST, self::CONDITION_NOT_IN_THE_LAST], true)) {
return $this->matchesInTheLast($condition, $filterValue, $value);
}
// on the day(s) of the week
if ($condition === self::CONDITION_ON_THE_DAYS_OF_THE_WEEK) {
return $this->matchesOnTheDaysOfTheWeek($filterValue, $value);
}
// other conditions
if (!is_string($filterValue) || !$value instanceof DateTimeInterface) {
return false;
}
$datetime = $this->convertToWpTimezone($value);
switch ($condition) {
case 'before':
$ref = DateTimeImmutable::createFromFormat(self::FORMAT_DATETIME, $filterValue, $this->timezone);
return $ref && $datetime < $ref;
case 'after':
$ref = DateTimeImmutable::createFromFormat(self::FORMAT_DATETIME, $filterValue, $this->timezone);
return $ref && $datetime > $ref;
case 'on':
return $datetime->format(self::FORMAT_DATE) === $filterValue;
case 'not-on':
return $datetime->format(self::FORMAT_DATE) !== $filterValue;
default:
return false;
}
}
/** @param mixed $value */
private function matchesSet(string $condition, $value): bool {
switch ($condition) {
case self::CONDITION_IS_SET:
return $value !== null;
case self::CONDITION_IS_NOT_SET:
return $value === null;
default:
return false;
}
}
/**
* @param mixed $filterValue
* @param mixed $value
*/
private function matchesInTheLast(string $condition, $filterValue, $value): bool {
if (!is_array($filterValue) || !isset($filterValue['number']) || !isset($filterValue['unit']) || !$value instanceof DateTimeInterface) {
return false;
}
$number = $filterValue['number'];
$unit = $filterValue['unit'];
if (!is_integer($number) || !in_array($unit, ['days', 'weeks', 'months'], true)) {
return false;
}
$now = new DateTimeImmutable('now', $this->timezone);
$ref = $now->modify("-$number $unit");
$matches = $ref <= $value && $value <= $now;
return $condition === self::CONDITION_IN_THE_LAST ? $matches : !$matches;
}
/**
* @param mixed $filterValue
* @param mixed $value
*/
private function matchesOnTheDaysOfTheWeek($filterValue, $value): bool {
if (!is_array($filterValue) || !$value instanceof DateTimeInterface) {
return false;
}
foreach ($filterValue as $day) {
if (!is_integer($day) || $day < 0 || $day > 6) {
return false;
}
}
$date = $this->convertToWpTimezone($value);
$day = (int)$date->format('w');
return in_array($day, $filterValue, true);
}
private function convertToWpTimezone(DateTimeInterface $datetime): DateTimeImmutable {
$value = DateTimeImmutable::createFromFormat('U', (string)$datetime->getTimestamp(), $this->timezone);
if (!$value) {
throw new InvalidStateException('Failed to convert datetime to WP timezone');
}
return $value;
}
}

View File

@ -0,0 +1,264 @@
<?php declare(strict_types = 1);
namespace MailPoet\Test\Automation\Integrations\Core\Filters;
use DateTimeImmutable;
use DateTimeZone;
use MailPoet\Automation\Engine\Data\Filter;
use MailPoet\Automation\Integrations\Core\Filters\DateTimeFilter;
use MailPoet\InvalidStateException;
use MailPoetUnitTest;
use stdClass;
class DateTimeFilterTest extends MailPoetUnitTest {
/** @var DateTimeZone */
private $timezone;
public function _before() {
// let's test with a timezone far from UTC
$this->timezone = new DateTimeZone('America/Los_Angeles');
}
public function testItReturnsCorrectConfiguration(): void {
$filter = new DateTimeFilter($this->timezone);
$this->assertSame('datetime', $filter->getFieldType());
$this->assertSame([
'before' => 'before',
'after' => 'after',
'on' => 'on',
'not-on' => 'not on',
'in-the-last' => 'in the last',
'not-in-the-last' => 'not in the last',
'is-set' => 'is set',
'is-not-set' => 'is not set',
'on-the-days-of-the-week' => 'on the day(s) of the week',
], $filter->getConditions());
$this->assertSame([
'type' => 'object',
'properties' => [
'value' => [
'oneOf' => [
[
'type' => 'string',
'pattern' => '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$',
],
[
'type' => 'string',
'pattern' => '^\d{4}-\d{2}-\d{2}$',
],
[
'type' => 'array',
'items' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => 6,
],
'minItems' => 1,
],
[
'type' => 'object',
'properties' => [
'number' => [
'type' => 'integer',
'minimum' => 1,
'required' => true,
],
'unit' => [
'type' => 'string',
'pattern' => '^days|weeks|months$',
'required' => true,
],
],
],
],
],
],
], $filter->getArgsSchema()->toArray());
}
public function testInvalidValues(): void {
$this->assertNotMatches('before', '2023-04-26T16:42', null);
$this->assertNotMatches('before', '2023-04-26T16:42', true);
$this->assertNotMatches('before', '2023-04-26T16:42', 123);
$this->assertNotMatches('before', '2023-04-26T16:42', 'abc');
$this->assertNotMatches('before', '2023-04-26T16:42', []);
$this->assertNotMatches('before', '2023-04-26T16:42', [1, 2, 3, 'a', 'b', 'c']);
$this->assertNotMatches('before', '2023-04-26T16:42', new stdClass());
$this->assertNotMatches('before', null, '2023-04-26T16:42+02:00');
$this->assertNotMatches('before', true, '2023-04-26T16:42+02:00');
$this->assertNotMatches('before', 123, '2023-04-26T16:42+02:00');
$this->assertNotMatches('before', 'abc', '2023-04-26T16:42+02:00');
$this->assertNotMatches('before', [], '2023-04-26T16:42+02:00');
$this->assertNotMatches('before', [1, 2, 3, 'a', 'b', 'c'], '2023-04-26T16:42+02:00');
$this->assertNotMatches('before', new stdClass(), '2023-04-26T16:42+02:00');
}
public function testBeforeCondition(): void {
$filterValue = '2023-04-26T16:42:01';
$this->assertMatches('before', $filterValue, $this->getDateTime('1900-01-01'));
$this->assertMatches('before', $filterValue, $this->getDateTime('2023-04-25'));
$this->assertMatches('before', $filterValue, $this->getDateTime('2023-04-26', '16:42:00', '+02:00'));
$this->assertNotMatches('before', $filterValue, $this->getDateTime('2100-01-01'));
$this->assertNotMatches('before', $filterValue, $this->getDateTime('2023-04-27'));
$this->assertNotMatches('before', $filterValue, $this->getDateTime('2023-04-27', '16:42:00', '+01:00'));
}
public function testAfterCondition(): void {
$filterValue = '2023-04-26T16:42:01';
$this->assertMatches('after', $filterValue, $this->getDateTime('2100-01-01'));
$this->assertMatches('after', $filterValue, $this->getDateTime('2023-04-27'));
$this->assertMatches('after', $filterValue, $this->getDateTime('2023-04-27', '16:42:00', '+01:00'));
$this->assertNotMatches('after', $filterValue, $this->getDateTime('1900-01-01'));
$this->assertNotMatches('after', $filterValue, $this->getDateTime('2023-04-25'));
$this->assertNotMatches('after', $filterValue, $this->getDateTime('2023-04-26', '16:42:00', '+02:00'));
}
public function testOnCondition(): void {
$filterValue = '2023-04-26';
$this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26'));
$this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00'));
$this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59'));
$this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '16:42:01', '+02:00'));
$this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00', '-05:00'));
$this->assertMatches('on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59', '+05:00'));
$this->assertNotMatches('on', $filterValue, $this->getDateTime('1900-01-01'));
$this->assertNotMatches('on', $filterValue, $this->getDateTime('2023-04-25'));
$this->assertNotMatches('on', $filterValue, $this->getDateTime('2023-04-27'));
$this->assertNotMatches('on', $filterValue, $this->getDateTime('2100-01-01'));
$this->assertNotMatches('on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00', '+05:00'));
$this->assertNotMatches('on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59', '-05:00'));
}
public function testNotOnCondition(): void {
$filterValue = '2023-04-26';
$this->assertMatches('not-on', $filterValue, $this->getDateTime('1900-01-01'));
$this->assertMatches('not-on', $filterValue, $this->getDateTime('2023-04-25'));
$this->assertMatches('not-on', $filterValue, $this->getDateTime('2023-04-27'));
$this->assertMatches('not-on', $filterValue, $this->getDateTime('2100-01-01'));
$this->assertMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00', '+05:00'));
$this->assertMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59', '-05:00'));
$this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26'));
$this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00'));
$this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59'));
$this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '16:42:01', '+02:00'));
$this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '00:00:00', '-05:00'));
$this->assertNotMatches('not-on', $filterValue, $this->getDateTime('2023-04-26', '23:59:59', '+05:00'));
}
public function testInTheLast(): void {
$filterDays = ['unit' => 'days', 'number' => 3];
$filterWeeks = ['unit' => 'weeks', 'number' => 3];
$filterMonths = ['unit' => 'months', 'number' => 3];
$now = new DateTimeImmutable('now', $this->timezone);
$this->assertMatches('in-the-last', $filterDays, $now);
$this->assertMatches('in-the-last', $filterDays, $now->modify('-1 day'));
$this->assertMatches('in-the-last', $filterWeeks, $now->modify('-1 day'));
$this->assertMatches('in-the-last', $filterMonths, $now->modify('-1 day'));
$this->assertMatches('in-the-last', $filterWeeks, $now->modify('-1 week'));
$this->assertMatches('in-the-last', $filterMonths, $now->modify('-1 week'));
$this->assertMatches('in-the-last', $filterMonths, $now->modify('-1 month'));
$this->assertNotMatches('in-the-last', $filterDays, $now->modify('+1 day'));
$this->assertNotMatches('in-the-last', $filterWeeks, $now->modify('+1 day'));
$this->assertNotMatches('in-the-last', $filterMonths, $now->modify('+1 day'));
$this->assertNotMatches('in-the-last', $filterDays, $now->modify('-4 days'));
$this->assertNotMatches('in-the-last', $filterDays, $now->modify('-1 week'));
$this->assertNotMatches('in-the-last', $filterWeeks, $now->modify('-1 month'));
$this->assertNotMatches('in-the-last', $filterMonths, $now->modify('-4 months'));
}
public function testNotInTheLast(): void {
$filterDays = ['unit' => 'days', 'number' => 3];
$filterWeeks = ['unit' => 'weeks', 'number' => 3];
$filterMonths = ['unit' => 'months', 'number' => 3];
$now = new DateTimeImmutable('now', $this->timezone);
$this->assertMatches('not-in-the-last', $filterDays, $now->modify('+1 day'));
$this->assertMatches('not-in-the-last', $filterWeeks, $now->modify('+1 day'));
$this->assertMatches('not-in-the-last', $filterMonths, $now->modify('+1 day'));
$this->assertMatches('not-in-the-last', $filterDays, $now->modify('-4 days'));
$this->assertMatches('not-in-the-last', $filterDays, $now->modify('-1 week'));
$this->assertMatches('not-in-the-last', $filterWeeks, $now->modify('-1 month'));
$this->assertMatches('not-in-the-last', $filterMonths, $now->modify('-4 months'));
$this->assertNotMatches('not-in-the-last', $filterDays, $now);
$this->assertNotMatches('not-in-the-last', $filterDays, $now->modify('-1 day'));
$this->assertNotMatches('not-in-the-last', $filterWeeks, $now->modify('-1 day'));
$this->assertNotMatches('not-in-the-last', $filterMonths, $now->modify('-1 day'));
$this->assertNotMatches('not-in-the-last', $filterWeeks, $now->modify('-1 week'));
$this->assertNotMatches('not-in-the-last', $filterMonths, $now->modify('-1 week'));
$this->assertNotMatches('not-in-the-last', $filterMonths, $now->modify('-1 month'));
}
public function testIsSet(): void {
$now = new DateTimeImmutable('now', $this->timezone);
$this->assertMatches('is-set', null, $now);
$this->assertNotMatches('is-set', null, null);
}
public function testIsNotSet(): void {
$now = new DateTimeImmutable('now', $this->timezone);
$this->assertMatches('is-not-set', null, null);
$this->assertNotMatches('is-not-set', null, $now);
}
public function testOnTheDaysOfTheWeek(): void {
$filterValue = [1, 3, 5]; // Monday, Wednesday, Friday
$this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24')); // Monday
$this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-26')); // Wednesday
$this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-28')); // Friday
$this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24', '00:00:00', '-05:00'));
$this->assertMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24', '23:59:59', '+05:00'));
$this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-23')); // Sunday
$this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-25')); // Tuesday
$this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-27')); // Thursday
$this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-29')); // Saturday
$this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-30')); // Sunday
$this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24', '00:00:00', '+05:00'));
$this->assertNotMatches('on-the-days-of-the-week', $filterValue, $this->getDateTime('2023-04-24', '23:59:59', '-05:00'));
}
public function testUnknownCondition(): void {
$value = DateTimeImmutable::createFromFormat(DateTimeImmutable::W3C, '2023-04-26T16:42+02:00');
$this->assertNotMatches('unknown', '2023-04-26T16:42', $value);
$this->assertNotMatches('unknown', ['2023-04-26T16:42', '2023-04-26T17:42'], $value);
}
private function getDateTime(string $date, string $time = '12:00:00', string $tzOffset = '+00:00'): DateTimeImmutable {
$datetime = DateTimeImmutable::createFromFormat(DateTimeImmutable::W3C, "{$date}T{$time}{$tzOffset}");
if ($datetime === false) {
throw new InvalidStateException('Invalid date format');
}
return $datetime;
}
private function assertMatches(string $condition, $filterValue, $value): void {
$this->assertTrue($this->matchesFilter($condition, $filterValue, $value));
}
private function assertNotMatches(string $condition, $filterValue, $value): void {
$this->assertFalse($this->matchesFilter($condition, $filterValue, $value));
}
private function matchesFilter(string $condition, $filterValue, $value): bool {
$filter = new DateTimeFilter($this->timezone);
return $filter->matches(new Filter('f1', 'datetime', '', $condition, ['value' => $filterValue]), $value);
}
}