Skip to content

Commit

Permalink
feat: Extending AWS Preset wildcards and checks. (#80)
Browse files Browse the repository at this point in the history
* Ensure at least one day has a ? for AWS preset

In the AWS documentation, they say that only one of the field can be
specified, the other must be `?`. To be considered specified, you need
either a value or *. So it basically means that either dayOfWeek or
dayOfMonth must be a ? so we always expect one ?.

This checks ensure that with AWS there will always be one and only one ?

* Add last day of month/week support

* Adjust the L checks according to quartz behavior and add test matrix

* Add nearest weekday flag for day of month

* Add Nth occurrence of weekday during month (# symbol)

* Updating readme feature list
  • Loading branch information
GuillaumeRochat authored Jul 27, 2020
1 parent 177e301 commit a7baca0
Show file tree
Hide file tree
Showing 16 changed files with 551 additions and 148 deletions.
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ registerOptionPreset('YOUR-PRESET-ID', {
useYears: false,
useBlankDay: false,
allowOnlyOneBlankDayField: false,
mustHaveBlankDayField: false, // optional, default to false
useLastDayOfMonth: false, // optional, default to false
useLastDayOfWeek: false, // optional, default to false
seconds: {
minValue: 0,
maxValue: 59,
Expand Down Expand Up @@ -169,7 +172,23 @@ The preset properties explained:
- `useBlankDay: boolean`
- enables blank day notation '?' in daysOfMonth and daysOfWeek field
- `allowOnlyOneBlankDayField: boolean`
- required at least day field to not be blank (so not both day fields can be blank)
- requires a day field to not be blank (so not both day fields can be blank)
- `mustHaveBlankDayField: boolean`
- requires a day field to be blank (so not both day fields are specified)
- when mixed with `allowOnlyOneBlankDayField`, it means that there will always be either day or day of week as `?`
- `useLastDayOfMonth: boolean`
- enables the 'L' character to specify the last day of the month.
- accept negative offset after the 'L' for nth last day of the month.
- e.g.: `L-2` would me the 2nd to last day of the month.
- `useLastDayOfWeek: boolean`
- enables the 'L' character to specify the last occurrence of a weekday in a month.
- e.g.: `5L` would mean the last friday of the month.
- `useNearestWeekday: boolean`
- enables the 'W' character to specify the use of the closest weekday.
- e.g.: `15W` would mean the weekday (mon-fri) closest to the 15th when the 15th is on sat-sun.
- `useNthWeekdayOfMonth: boolean`
- enables the '#' character to specify the Nth weekday of the month.
- e.g.: `6#3` would mean the 3rd friday of the month (assuming 6 = friday).

* in cron fields (like seconds, minutes etc.):
- `minValue: number`
Expand Down Expand Up @@ -276,4 +295,8 @@ console.log(
- [x] Years field support.
- [x] Option presets (classic cron, node-cron, etc.)
- [x] Blank '?' daysOfMonth/daysOfWeek support
- [x] Last day of month.
- [x] Last specific weekday of month. (e.g. last Tuesday)
- [x] Closest weekday to a specific day of the month.
- [x] Nth specific weekday of month. (e.g. 2nd Tuesday)
- [ ] Cron alias support.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"release-next": "npm run build && env-cmd npx semantic-release --branches next --no-ci",
"release-next-major": "npm run build && env-cmd npx semantic-release --branches next-major --no-ci",
"test": "jest --coverage",
"test:watch": "jest --coverage --watchAll"
"test:watch": "jest --watchAll --verbose"
},
"repository": {
"type": "git",
Expand Down
34 changes: 33 additions & 1 deletion src/fieldCheckers/dayOfMonthChecker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CronData } from '../index'
import { err, Result } from '../result'
import checkField from '../helper'
import type { Options } from '../option'
import type { Options } from '../types'

const checkDaysOfMonth = (cronData: CronData, options: Options): Result<boolean, string[]> => {
if (!cronData.daysOfMonth) {
Expand All @@ -21,6 +21,38 @@ const checkDaysOfMonth = (cronData: CronData, options: Options): Result<boolean,
])
}

if (
options.mustHaveBlankDayField &&
cronData.daysOfMonth !== '?' &&
cronData.daysOfWeek !== '?'
) {
return err([
`Cannot specify both daysOfMonth and daysOfWeek field when mustHaveBlankDayField option is enabled.`,
])
}

// Based on this implementation logic:
// https://github.com/quartz-scheduler/quartz/blob/1e0ed76c5c141597eccd76e44583557729b5a7cb/quartz-core/src/main/java/org/quartz/CronExpression.java#L473
if (
options.useLastDayOfMonth &&
cronData.daysOfMonth.indexOf('L') !== -1 &&
cronData.daysOfMonth.match(/[,/]/)
) {
return err([
`Cannot specify last day of month with lists, or ranges (symbols ,/).`
])
}

if (
options.useNearestWeekday &&
cronData.daysOfMonth.indexOf('W') !== -1 &&
cronData.daysOfMonth.match(/[,/-]/)
) {
return err([
`Cannot specify nearest weekday with lists, steps or ranges (symbols ,-/).`
])
}

return checkField(daysOfMonth, 'daysOfMonth', options)
}

Expand Down
34 changes: 33 additions & 1 deletion src/fieldCheckers/dayOfWeekChecker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CronData } from '../index'
import { err, Result } from '../result'
import checkField from '../helper'
import type { Options } from '../option'
import type { Options } from '../types'

const checkDaysOfWeek = (cronData: CronData, options: Options): Result<boolean, string[]> => {
if (!cronData.daysOfWeek) {
Expand All @@ -20,6 +20,38 @@ const checkDaysOfWeek = (cronData: CronData, options: Options): Result<boolean,
])
}

if (
options.mustHaveBlankDayField &&
cronData.daysOfMonth !== '?' &&
cronData.daysOfWeek !== '?'
) {
return err([
`Cannot specify both daysOfMonth and daysOfWeek field when mustHaveBlankDayField option is enabled.`,
])
}

// Based on this implementation logic:
// https://github.com/quartz-scheduler/quartz/blob/1e0ed76c5c141597eccd76e44583557729b5a7cb/quartz-core/src/main/java/org/quartz/CronExpression.java#L477
if (
options.useLastDayOfWeek &&
cronData.daysOfWeek.indexOf('L') !== -1 &&
cronData.daysOfWeek.match(/[,/-]/)
) {
return err([
`Cannot specify last day of week with lists, steps or ranges (symbols ,-/).`
])
}

if (
options.useNthWeekdayOfMonth &&
cronData.daysOfWeek.indexOf('#') !== -1 &&
cronData.daysOfWeek.match(/[,/-]/)
) {
return err([
`Cannot specify Nth weekday of month with lists, steps or ranges (symbols ,-/).`
])
}

return checkField(daysOfWeek, 'daysOfWeek', options)
}

Expand Down
2 changes: 1 addition & 1 deletion src/fieldCheckers/hourChecker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CronData } from '../index'
import { err, Result } from '../result'
import checkField from '../helper'
import type { Options } from '../option'
import type { Options } from '../types'

const checkHours = (cronData: CronData, options: Options): Result<boolean, string[]> => {
if (!cronData.hours) {
Expand Down
2 changes: 1 addition & 1 deletion src/fieldCheckers/minuteChecker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CronData } from '../index'
import { err, Result } from '../result'
import checkField from '../helper'
import type { Options } from '../option'
import type { Options } from '../types'

const checkMinutes = (cronData: CronData, options: Options): Result<boolean, string[]> => {
if (!cronData.minutes) {
Expand Down
2 changes: 1 addition & 1 deletion src/fieldCheckers/monthChecker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CronData } from '../index'
import { err, Result } from '../result'
import checkField from '../helper'
import type { Options } from '../option'
import type { Options } from '../types'

const checkMonths = (cronData: CronData, options: Options): Result<boolean, string[]> => {
if (!cronData.months) {
Expand Down
2 changes: 1 addition & 1 deletion src/fieldCheckers/secondChecker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CronData } from '../index'
import { err, Result } from '../result'
import checkField from '../helper'
import type { Options } from '../option'
import type { Options } from '../types'

const checkSeconds = (cronData: CronData, options: Options): Result<boolean, string[]> => {
if (!cronData.seconds) {
Expand Down
2 changes: 1 addition & 1 deletion src/fieldCheckers/yearChecker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CronData } from '../index'
import { err, Result } from '../result'
import checkField from '../helper'
import type { Options } from '../option'
import type { Options } from '../types'

const checkYears = (cronData: CronData, options: Options): Result<boolean, string[]> => {
if (!cronData.years) {
Expand Down
126 changes: 87 additions & 39 deletions src/helper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CronFieldType } from './index'
import { Err, err, Result, Valid, valid } from './result'
import type { Options } from './option'
import type { Options } from './types'

const checkWildcardLimit = (cronFieldType: CronFieldType, options: Options) => {
return (
Expand All @@ -11,28 +11,11 @@ const checkWildcardLimit = (cronFieldType: CronFieldType, options: Options) => {
)
}

const checkSingleElement = (
const checkSingleElementWithinLimits = (
element: string,
cronFieldType: CronFieldType,
options: Options
): Result<boolean, string> => {
if (element === '*') {
if (!checkWildcardLimit(cronFieldType, options)) {
// console.log(
// `Field ${cronFieldType} uses wildcard '*', but is limited to ${options[cronFieldType].lowerLimit}-${options[cronFieldType].upperLimit}`
// )
return err(
`Field ${cronFieldType} uses wildcard '*', but is limited to ${options[cronFieldType].lowerLimit}-${options[cronFieldType].upperLimit}`
)
}

return valid(true)
}

if (element === '') {
return err(`One of the elements is empty in ${cronFieldType} field.`)
}

const number = Number(element)
if (isNaN(number)) {
return err(`Element '${element} of ${cronFieldType} field is invalid.`)
Expand All @@ -55,39 +38,101 @@ const checkSingleElement = (
return valid(true)
}

const checkRangeElement = (
const checkSingleElement = (
element: string,
cronFieldType: CronFieldType,
options: Options
): Result<boolean, string> => {
if (element === '*') {
return err(`'*' can't be part of a range in ${cronFieldType} field.`)
if (!checkWildcardLimit(cronFieldType, options)) {
return err(
`Field ${cronFieldType} uses wildcard '*', but is limited to ${options[cronFieldType].lowerLimit}-${options[cronFieldType].upperLimit}`
)
}

return valid(true)
}

if (element === '') {
return err(`One of the range elements is empty in ${cronFieldType} field.`)
return err(`One of the elements is empty in ${cronFieldType} field.`)
}

const number = Number(element)
if (isNaN(number)) {
return err(`Element '${element} of ${cronFieldType} field is invalid.`)
if (cronFieldType === 'daysOfMonth' && options.useLastDayOfMonth && element === 'L') {
return valid(true)
}

const { lowerLimit } = options[cronFieldType]
const { upperLimit } = options[cronFieldType]
if (lowerLimit && number < lowerLimit) {
return err(
`Number ${number} of ${cronFieldType} field is smaller than lower limit '${lowerLimit}'`
)
// We must do that check here because L is used with a number to specify the day of the week for which
// we look for the last occurrence in the month.
// We use `endsWith` here because anywhere else is not valid so it will be caught later on.
if (cronFieldType === 'daysOfWeek' && options.useLastDayOfWeek && element.endsWith('L')) {
const day = element.slice(0, -1)
if (day === '') {
// This means that element is only `L` which is the equivalent of saturdayL
return valid(true)
}

return checkSingleElementWithinLimits(day, cronFieldType, options)
}

if (upperLimit && number > upperLimit) {
return err(
`Number ${number} of ${cronFieldType} field is bigger than upper limit '${upperLimit}'`
)
// We must do that check here because W is used with a number to specify the day of the month for which
// we must run over a weekday instead.
// We use `endsWith` here because anywhere else is not valid so it will be caught later on.
if (cronFieldType === 'daysOfMonth' && options.useNearestWeekday && element.endsWith('W')) {
const day = element.slice(0, -1)
if (day === '') {
return err(`The 'W' must be preceded by a day`)
}

// Edge case where the L can be used with W to form last weekday of month
if (options.useLastDayOfMonth && day === 'L') {
return valid(true)
}

return checkSingleElementWithinLimits(day, cronFieldType, options)
}

return valid(true)
if (cronFieldType === 'daysOfWeek' && options.useNthWeekdayOfMonth && element.indexOf('#') !== -1) {
const [day, occurrence, ...leftOvers] = element.split('#')
if (leftOvers.length !== 0) {
return err(`Unexpected number of '#' in ${element}, can only be used once.`)
}

const occurrenceNum = Number(occurrence)
if (!occurrence || isNaN(occurrenceNum)) {
return err(`Unexpected value following the '#' symbol, a positive number was expected but found ${occurrence}.`)
}

return checkSingleElementWithinLimits(day, cronFieldType, options)
}

return checkSingleElementWithinLimits(element, cronFieldType, options)
}

const checkRangeElement = (
element: string,
cronFieldType: CronFieldType,
options: Options,
position: 0 | 1
): Result<boolean, string> => {
if (element === '*') {
return err(`'*' can't be part of a range in ${cronFieldType} field.`)
}

if (element === '') {
return err(`One of the range elements is empty in ${cronFieldType} field.`)
}

// We can have `L` as the first element of a range to specify an offset.
if (
options.useLastDayOfMonth &&
cronFieldType === 'daysOfMonth' &&
element === 'L' &&
position === 0
) {
return valid(true)
}

return checkSingleElementWithinLimits(element, cronFieldType, options)
}

const checkFirstStepElement = (
Expand All @@ -105,16 +150,19 @@ const checkFirstStepElement = (
if (rangeArray.length === 1) {
return checkSingleElement(rangeArray[0], cronFieldType, options)
}

if (rangeArray.length === 2) {
const firstRangeElementResult = checkRangeElement(
rangeArray[0],
cronFieldType,
options
options,
0
)
const secondRangeElementResult = checkRangeElement(
rangeArray[1],
cronFieldType,
options
options,
1
)

if (firstRangeElementResult.isError()) {
Expand Down Expand Up @@ -179,7 +227,7 @@ const checkListElement = (

if (Number(secondStepElement) === 0) {
return err(
`Second step element '${secondStepElement}' of '${listElement}' is cannot be zero.`
`Second step element '${secondStepElement}' of '${listElement}' cannot be zero.`
)
}
}
Expand Down
Loading

0 comments on commit a7baca0

Please sign in to comment.