Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Introduce RRule class
Browse files Browse the repository at this point in the history
yhabteab committed Jan 27, 2023
1 parent 3072c31 commit e3f46cc
Showing 2 changed files with 272 additions and 0 deletions.
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -10,11 +10,13 @@
},
"require": {
"php": ">=7.2",
"ext-json": "*",
"dragonmantank/cron-expression": "^3",
"psr/log": "^1",
"ramsey/uuid": "^3",
"react/event-loop": "^1",
"react/promise": "^2",
"simshaun/recurr": "^5",
"ipl/stdlib": ">=0.12.0"
},
"autoload": {
270 changes: 270 additions & 0 deletions src/RRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<?php

namespace ipl\Scheduler;

use BadMethodCallException;
use DateTime;
use DateTimeInterface;
use Generator;
use InvalidArgumentException;
use ipl\Scheduler\Contract\Frequency;
use JsonSerializable;
use Recurr\Rule as RecurrRule;
use Recurr\Transformer\ArrayTransformer;
use Recurr\Transformer\ArrayTransformerConfig;
use Recurr\Transformer\Constraint\AfterConstraint;

use function ipl\Stdlib\get_php_type;

/**
* Support scheduling a task based on expressions in an iCalendar format
*/
class RRule implements Frequency, JsonSerializable
{
/** @var string Run once a year */
public const YEARLY = 'YEARLY';

/** @var string Run every 3 month starting from the given start time */
public const QUARTERLY = 'QUARTERLY';

/** @var string Run once a month */
public const MONTHLY = 'MONTHLY';

/** @var string Run once a week based on the specified start time */
public const WEEKLY = 'WEEKLY';

/** @var string Run once a day at the specified start time */
public const DAILY = 'DAILY';

/** @var string Run once an hour */
public const HOURLY = 'HOURLY';

/** @var string Run once a minute */
public const MINUTELY = 'MINUTELY';

/** @var int Default limit of the recurrences to be generated by the transformer */
protected const DEFAULT_LIMIT = 1;

/** @var RecurrRule */
protected $rrule;

/** @var ArrayTransformer */
protected $transformer;

/** @var ArrayTransformerConfig */
protected $transformerConfig;

/** @var string */
protected $frequency;

public function __construct(string $rule)
{
$this->rrule = new RecurrRule($rule);
$this->frequency = $this->rrule->getFreqAsText();
$this->transformerConfig = new ArrayTransformerConfig();
$this->transformerConfig->setVirtualLimit(static::DEFAULT_LIMIT);

// If the run day isn't set explicitly, we can enable the last day of month
// fix, so that it doesn't skip some months which doesn't have e.g. 29,30,31 days.
if (
$this->getFrequency() === static::MONTHLY
&& ! $this->rrule->getByDay()
&& ! $this->rrule->getByMonthDay()
) {
$this->transformerConfig->enableLastDayOfMonthFix();
}

$this->transformer = new ArrayTransformer($this->transformerConfig);
}

/**
* Get an RRule instance from the provided frequency
*
* @param string $frequency
*
* @return $this
*/
public static function fromFrequency(string $frequency): self
{
$frequencies = array_flip([
static::MINUTELY,
static::HOURLY,
static::DAILY,
static::WEEKLY,
static::MONTHLY,
static::QUARTERLY,
static::YEARLY
]);

if (! isset($frequencies[$frequency])) {
throw new InvalidArgumentException(sprintf('Unknown frequency provided: %s', $frequency));
}

$rule = "FREQ=$frequency";
if ($frequency === RRule::QUARTERLY) {
$repeat = RRule::MONTHLY;
$rule = "FREQ=$repeat;INTERVAL=3";
}

$self = new static($rule);
$self->frequency = $frequency;

return $self;
}

public function isDue(DateTimeInterface $dateTime): bool
{
$start = $this->rrule->getStartDate();
if (($start && $dateTime < $start) || $this->isExpired($dateTime)) {
return false;
}

$nextDue = $this->getNextRecurrences($dateTime);
if (! $nextDue->valid()) {
return false;
}

return $nextDue->current() == $dateTime;
}

public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface
{
if ($this->isExpired($dateTime)) {
return ($this->rrule->getEndDate() ?? $this->rrule->getUntil());
}

$nextDue = $this->getNextRecurrences($dateTime, 1, false);
if (! $nextDue->valid()) {
return $dateTime;
}

return $nextDue->current();
}

public function isExpired(DateTimeInterface $dateTime): bool
{
if ($this->rrule->repeatsIndefinitely()) {
return false;
}

$end = $this->rrule->getEndDate() ?? $this->rrule->getUntil();

return $end !== null && $end < $dateTime;
}

/**
* Set the start time of this frequency
*
* @param DateTimeInterface $start
*
* @return $this
*/
public function startAt(DateTimeInterface $start): self
{
$startDate = clone $start;
$now = new DateTime();
if ($startDate > $now) {
// When the start time contains microseconds, the first recurrence will always be skipped, as
// the transformer operates only up to seconds level. See also the upstream issue #155
$startDate->setTime($start->format('H'), $start->format('i'), $start->format('s'));

$this->rrule->setStartDate($startDate);
}

return $this;
}

/**
* Set the end time of this frequency
*
* @param DateTimeInterface $end
*
* @return $this
*/
public function endAt(DateTimeInterface $end): self
{
$this->rrule->setEndDate($end);

return $this;
}

/**
* Get the frequency of this rule
*
* @return string
*/
public function getFrequency(): string
{
return $this->frequency;
}

/**
* Get a set of recurrences relative to the given time and bounded to the configured generator's limit
*
* @param DateTimeInterface $dateTime
* @param int $limit Limit the recurrences to be generated to the given value
* @param bool $include Whether to include the passed time in the result set
*
* @return Generator
*/
public function getNextRecurrences(
DateTimeInterface $dateTime,
int $limit = self::DEFAULT_LIMIT,
bool $include = true
): Generator {
$resetTransformerConfig = function (int $limit = self::DEFAULT_LIMIT): void {
$this->transformerConfig->setVirtualLimit($limit);
$this->transformer->setConfig($this->transformerConfig);
};

if ($limit > static::DEFAULT_LIMIT) {
$resetTransformerConfig($limit);
}

// Setting the start date to a date time smaller than now causes the underlying library
// not to generate any recurrences when using the regular frequencies such as `MINUTELY` etc.
// and the `$countConstraintFailures` is set to true. We need also to tell the transformer
// not to count the recurrences that fail the constraint's test!
$recurrences = $this->transformer->transform($this->rrule, new AfterConstraint($dateTime, $include), false);
foreach ($recurrences as $recurrence) {
yield $recurrence->getStart();
}

if ($limit > static::DEFAULT_LIMIT) {
$resetTransformerConfig();
}
}

#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->rrule->getString();
}

/**
* Redirect all public method calls to the underlying rrule object
*
* @param string $methodName
* @param array $args
*
* @return mixed
*
* @throws BadMethodCallException If the given method doesn't exist or when setter method is called
*/
public function __call(string $methodName, array $args)
{
if (! method_exists($this->rrule, $methodName)) {
throw new BadMethodCallException(
sprintf('Call to undefined method %s::%s()', get_php_type($this->rrule), $methodName)
);
}

if (substr($methodName, 0, 3) === 'set') {
throw new BadMethodCallException(
sprintf('Dynamic method %s is not supported. Only getters (get*) are', $methodName)
);
}

return call_user_func_array([$this->rrule, $methodName], $args);
}
}

0 comments on commit e3f46cc

Please sign in to comment.