Skip to content

Commit

Permalink
Support "last weekday of the month" expressions (#245)
Browse files Browse the repository at this point in the history
* Implement "L" in weekday

* Improve implementation and add more tests

* Remove updted lockfile from PR

* Apply suggestions from code review

Co-authored-by: Harri Siirak <harri@siirak.ee>

* Rename method

* Rename crondate

* Style fixes

* Ensure cron expression strigifies correctly

* Document weekday expressions

* Add test case for multiple L weekdays sequences

* Style fix

Co-authored-by: Harri Siirak <harri@siirak.ee>

Co-authored-by: Harri Siirak <harri@siirak.ee>
  • Loading branch information
albertorestifo and harrisiirak committed Oct 12, 2021
1 parent 45b4a6c commit d18ad74
Show file tree
Hide file tree
Showing 7 changed files with 243 additions and 16 deletions.
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ Supported format
* * * * * *
┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ |
│ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
│ │ │ │ │ └ day of week (0 - 7, 1L - 7L) (0 or 7 is Sun)
│ │ │ │ └───── month (1 - 12)
│ │ │ └────────── day of month (1 - 31, L)
│ │ └─────────────── hour (0 - 23)
│ └──────────────────── minute (0 - 59)
└───────────────────────── second (0 - 59, optional)
```

Supports mixed use of ranges and range increments (L and W characters are not supported currently). See tests for examples.
Supports mixed use of ranges and range increments (W character not supported currently). See tests for examples.

Usage
========
Expand Down Expand Up @@ -146,3 +146,25 @@ will be expecting. Using one of the supported `string` formats will solve the is
* *iterator* - Return ES6 compatible iterator object
* *utc* - Enable UTC
* *tz* - Timezone string. It won't be used in case `utc` is enabled

Last weekday of the month
=========================

This library supports parsing the range `0L - 7L` in the `weekday` position of
the cron expression, where the `L` means "last occurrence of this weekday for
the month in progress".

For example, the following expression will run on the last monday of the month
at midnight:

```
0 0 * * * 1L
```

The library also supports combining `L` expressions with other weekday
expressions. For example, the following cron will run every Monday as well
as the last Wednesday of the month:

```
0 0 * * * 1,3L
```
11 changes: 11 additions & 0 deletions lib/date.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ CronDate.prototype.isLastDayOfMonth = function() {
return this._date.month !== newDate.month;
};

/**
* Returns true when the current weekday is the last occurrence of this weekday
* for the present month.
*/
CronDate.prototype.isLastWeekdayOfMonth = function() {
// Check this by adding 7 days to the current date and seeing if it's
// a different month
var newDate = this._date.plus({ days: 7 }).startOf('day');
return this._date.month !== newDate.month;
};

function CronDate (timestamp, tz) {
var dateOpts = { zone: tz };
if (!timestamp) {
Expand Down
56 changes: 46 additions & 10 deletions lib/expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ CronExpression.constraints = [
{ min: 0, max: 23, chars: [] }, // Hour
{ min: 1, max: 31, chars: ['L'] }, // Day of month
{ min: 1, max: 12, chars: [] }, // Month
{ min: 0, max: 7, chars: [] }, // Day of week
{ min: 0, max: 7, chars: ['L'] }, // Day of week
];

/**
Expand Down Expand Up @@ -123,7 +123,7 @@ CronExpression.aliases = {
CronExpression.parseDefaults = [ '0', '*', '*', '*', '*', '*' ];

CronExpression.standardValidCharacters = /^[\d|/|*|\-|,]+$/;
CronExpression.dayOfWeekValidCharacters = /^[\d|/|*|\-|,|\?]+$/;
CronExpression.dayOfWeekValidCharacters = /^[\d|\dL|/|*|\-|,|\?]+$/;
CronExpression.dayOfMonthValidCharacters = /^[\d|L|/|*|\-|,|\?]+$/;
CronExpression.validCharacters = {
second: CronExpression.standardValidCharacters,
Expand All @@ -134,6 +134,16 @@ CronExpression.validCharacters = {
dayOfWeek: CronExpression.dayOfWeekValidCharacters,
};

CronExpression._isValidConstraintChar = function _isValidConstraintChar(constraints, value) {
if (typeof value !== 'string') {
return false;
}

return constraints.chars.some(function(char) {
return value.indexOf(char) > -1;
});
};

/**
* Parse input interval
*
Expand All @@ -150,7 +160,7 @@ CronExpression._parseField = function _parseField (field, value, constraints) {
case 'dayOfWeek':
var aliases = CronExpression.aliases[field];

value = value.replace(/[a-z]{1,3}/gi, function(match) {
value = value.replace(/[a-z]{3}/gi, function(match) {
match = match.toLowerCase();

if (typeof aliases[match] !== 'undefined') {
Expand Down Expand Up @@ -197,7 +207,7 @@ CronExpression._parseField = function _parseField (field, value, constraints) {
for (var i = 0, c = result.length; i < c; i++) {
var value = result[i];

if (typeof value === 'string' && constraints.chars.indexOf(value) > -1) {
if (CronExpression._isValidConstraintChar(constraints, value)) {
stack.push(value);
continue;
}
Expand All @@ -213,7 +223,7 @@ CronExpression._parseField = function _parseField (field, value, constraints) {
}
} else { // Scalar value

if (typeof result === 'string' && constraints.chars.indexOf(result) > -1) {
if (CronExpression._isValidConstraintChar(constraints, result)) {
stack.push(result);
return;
}
Expand Down Expand Up @@ -484,12 +494,15 @@ CronExpression.prototype._findSchedule = function _findSchedule (reverse) {
/**
* Helper function that checks if 'L' is in the array
*
* @param {Array} dayOfMonth
* @param {Array} expressions
*/
function isLInDayOfMonth(dayOfMonth) {
return dayOfMonth.length > 0 && dayOfMonth.indexOf('L') >= 0;
function isLInExpressions(expressions) {
return expressions.length > 0 && expressions.some(function(expression) {
return typeof expression === 'string' && expression.indexOf('L') >= 0;
});
}


// Whether to use backwards directionality when searching
reverse = reverse || false;
var dateMathVerb = reverse ? 'subtract' : 'add';
Expand All @@ -502,6 +515,25 @@ CronExpression.prototype._findSchedule = function _findSchedule (reverse) {
var startTimestamp = currentDate.getTime();
var stepCount = 0;

function isLastWeekdayOfMonthMatch(expressions) {
return expressions.some(function(expression) {
// There might be multiple expressions and not all of them will contain
// the "L".
if (!isLInExpressions([expression])) {
return false;
}

// The first character represents the weekday
var weekday = Number.parseInt(expression[0]);

if (Number.isNaN(weekday)) {
throw new Error('Invalid last weekday of the month expression: ' + expression);
}

return currentDate.getDay() === weekday && currentDate.isLastWeekdayOfMonth();
});
}

while (stepCount < LOOP_LIMIT) {
stepCount++;

Expand All @@ -528,10 +560,13 @@ CronExpression.prototype._findSchedule = function _findSchedule (reverse) {
//

var dayOfMonthMatch = matchSchedule(currentDate.getDate(), this.fields.dayOfMonth);
if (isLInDayOfMonth(this.fields.dayOfMonth)) {
if (isLInExpressions(this.fields.dayOfMonth)) {
dayOfMonthMatch = dayOfMonthMatch || currentDate.isLastDayOfMonth();
}
var dayOfWeekMatch = matchSchedule(currentDate.getDay(), this.fields.dayOfWeek);
if (isLInExpressions(this.fields.dayOfWeek)) {
dayOfWeekMatch = dayOfWeekMatch || isLastWeekdayOfMonthMatch(this.fields.dayOfWeek);
}
var isDayOfMonthWildcardMatch = this.fields.dayOfMonth.length >= CronExpression.daysInMonth[currentDate.getMonth()];
var isDayOfWeekWildcardMatch = this.fields.dayOfWeek.length === CronExpression.constraints[5].max - CronExpression.constraints[5].min + 1;
var currentHour = currentDate.getHours();
Expand Down Expand Up @@ -903,9 +938,10 @@ CronExpression.fieldsToExpression = function fieldsToExpression(fields, options)
for (var i = 0, c = values.length; i < c; i++) {
var value = values[i];

if (typeof value === 'string' && constraints.chars.indexOf(value) > -1) {
if (CronExpression._isValidConstraintChar(constraints, value)) {
continue;
}

// Check constraints
if (typeof value !== 'number' || Number.isNaN(value) || value < constraints.min || value > constraints.max) {
throw new Error(
Expand Down
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions test/crondate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// empty around comma

var test = require('tap').test;
var CronDate = require('../lib/date');

test('is the last weekday of the month', function (t) {
// Last monday of septhember
var date = new CronDate(new Date(2021, 8, 27));
t.equal(date.isLastWeekdayOfMonth(), true);

// Second-to-last monday of septhember
var date = new CronDate(new Date(2021, 8, 20));
t.equal(date.isLastWeekdayOfMonth(), false);

t.end();
});
96 changes: 93 additions & 3 deletions test/parser_day_of_month.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
var util = require('util');
var test = require('tap').test;
var CronParser = require('../lib/parser');

Expand All @@ -16,7 +15,6 @@ test('parse cron with last day in a month', function(t) {
var next = interval.next();
t.ok(next, 'has a date');
}

} catch (err) {
t.error(err, 'Parse read error');
}
Expand Down Expand Up @@ -44,7 +42,6 @@ test('parse cron with last day in feb', function(t) {
//leap year
t.equal(next.getDate(), 29);
t.equal(i, items);

} catch (err) {
t.error(err, 'Parse read error');
}
Expand All @@ -68,11 +65,104 @@ test('parse cron with last day in feb', function(t) {
}
//common year
t.equal(next.getDate(), 28);
} catch (err) {
t.error(err, 'Parse read error');
}

t.end();
});

test('parse cron with last weekday of the month', function(t) {
var options = {
currentDate: new Date(2021, 8, 1),
endDate: new Date(2021, 11, 1)
};

var testCases = [
{ expression: '0 0 0 * * 1L', expectedDate: 27 },
{ expression: '0 0 0 * * 2L', expectedDate: 28 },
{ expression: '0 0 0 * * 3L', expectedDate: 29 },
{ expression: '0 0 0 * * 4L', expectedDate: 30 },
{ expression: '0 0 0 * * 5L', expectedDate: 24 },
{ expression: '0 0 0 * * 6L', expectedDate: 25 },
{ expression: '0 0 0 * * 0L', expectedDate: 26 }
];

testCases.forEach(function({ expression, expectedDate }) {
t.test(expression, function(t) {
try {
var interval = CronParser.parseExpression(expression, options);

t.equal(interval.hasNext(), true);

var next = interval.next();

t.equal(next.getDate(), expectedDate);
} catch (err) {
t.error(err, 'Parse read error');
}

t.end();
});
});

t.end();
});

test('parses expression that runs on both last monday and friday of the month', function(t) {
var options = {
currentDate: new Date(2021, 8, 1),
endDate: new Date(2021, 11, 1)
};

try {
var interval = CronParser.parseExpression('0 0 0 * * 1L,5L', options);

t.equal(interval.next().getDate(), 24);
t.equal(interval.next().getDate(), 27);
} catch (err) {
t.error(err, 'Parse read error');
}

t.end();
});

test('parses expression that runs on both every monday and last friday of mont', function(t) {
var options = {
currentDate: new Date(2021, 8, 1),
endDate: new Date(2021, 8, 30)
};

try {
var interval = CronParser.parseExpression('0 0 0 * * 1,5L', options);

var dates = [];

while(true) {
try {
dates.push(interval.next().getDate());
} catch (e) {
if (e.message !== 'Out of the timespan range') {
throw e;
}

break;
}
}

t.same(dates, [6, 13, 20, 24, 27]);
} catch (err) {
t.error(err, 'Parse read error');
}

t.end();
});

test('fails to parse for invalid last weekday of month expression', function(t) {
t.throws(function() {
var interval = CronParser.parseExpression('0 0 0 * * L');
interval.next();
});

t.end();
});
Loading

0 comments on commit d18ad74

Please sign in to comment.