Skip to content

Commit 2b2af27

Browse files
authored
Extended serialization for cron fields (#379)
* Refactor the CronField constructor to use a full-fledged CronFieldOptions object instead of a wildcard argument * Remove nthDayOfWeek from public CronExpressionOptions interface and rely on CronDayOfWeek exposed state * Add # serialization * Fix ? serialization * Change CronFieldOptions rawValue empty to make builder more flexible
1 parent f4f3947 commit 2b2af27

14 files changed

+158
-74
lines changed

src/CronExpression.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ export type CronExpressionOptions = {
77
endDate?: Date | string | number | CronDate;
88
startDate?: Date | string | number | CronDate;
99
tz?: string;
10-
nthDayOfWeek?: number;
1110
expression?: string;
1211
hashSeed?: string;
1312
strict?: boolean;
@@ -27,7 +26,6 @@ export class CronExpression {
2726
#currentDate: CronDate;
2827
readonly #startDate: CronDate | null;
2928
readonly #endDate: CronDate | null;
30-
readonly #nthDayOfWeek: number;
3129
readonly #fields: CronFieldCollection;
3230

3331
/**
@@ -42,7 +40,6 @@ export class CronExpression {
4240
this.#currentDate = new CronDate(options.currentDate, this.#tz);
4341
this.#startDate = options.startDate ? new CronDate(options.startDate, this.#tz) : null;
4442
this.#endDate = options.endDate ? new CronDate(options.endDate, this.#tz) : null;
45-
this.#nthDayOfWeek = options.nthDayOfWeek || 0;
4643
this.#fields = fields;
4744
}
4845

@@ -236,9 +233,9 @@ export class CronExpression {
236233
}
237234

238235
// Check nth day of week if specified
239-
if (this.#nthDayOfWeek > 0) {
236+
if (this.#fields.dayOfWeek.nthDay > 0) {
240237
const weekInMonth = Math.ceil(dt.getDate() / 7);
241-
if (weekInMonth !== this.#nthDayOfWeek) {
238+
if (weekInMonth !== this.#fields.dayOfWeek.nthDay) {
242239
return false;
243240
}
244241
}
@@ -367,7 +364,9 @@ export class CronExpression {
367364
currentDate.applyDateOperation(dateMathVerb, TimeUnit.Day, this.#fields.hour.values.length);
368365
continue;
369366
}
370-
if (!(this.#nthDayOfWeek <= 0 || Math.ceil(currentDate.getDate() / 7) === this.#nthDayOfWeek)) {
367+
if (
368+
!(this.#fields.dayOfWeek.nthDay <= 0 || Math.ceil(currentDate.getDate() / 7) === this.#fields.dayOfWeek.nthDay)
369+
) {
371370
currentDate.applyDateOperation(dateMathVerb, TimeUnit.Day, this.#fields.hour.values.length);
372371
continue;
373372
}

src/CronExpressionParser.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,14 @@ export class CronExpressionParser {
142142
) as DayOfWeekRange[];
143143

144144
const fields = new CronFieldCollection({
145-
second: new CronSecond(second, ['*', '?'].includes(rawFields.second)),
146-
minute: new CronMinute(minute, ['*', '?'].includes(rawFields.minute)),
147-
hour: new CronHour(hour, ['*', '?'].includes(rawFields.hour)),
148-
dayOfMonth: new CronDayOfMonth(dayOfMonth, ['*', '?'].includes(rawFields.dayOfMonth)),
149-
month: new CronMonth(month, ['*', '?'].includes(rawFields.month)),
150-
dayOfWeek: new CronDayOfWeek(dayOfWeek, ['*', '?'].includes(rawFields.dayOfWeek)),
145+
second: new CronSecond(second, { rawValue: rawFields.second }),
146+
minute: new CronMinute(minute, { rawValue: rawFields.minute }),
147+
hour: new CronHour(hour, { rawValue: rawFields.hour }),
148+
dayOfMonth: new CronDayOfMonth(dayOfMonth, { rawValue: rawFields.dayOfMonth }),
149+
month: new CronMonth(month, { rawValue: rawFields.month }),
150+
dayOfWeek: new CronDayOfWeek(dayOfWeek, { rawValue: rawFields.dayOfWeek, nthDayOfWeek }),
151151
});
152-
return new CronExpression(fields, { ...options, expression, currentDate, nthDayOfWeek });
152+
return new CronExpression(fields, { ...options, expression, currentDate });
153153
}
154154

155155
/**

src/CronFieldCollection.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -308,21 +308,21 @@ export class CronFieldCollection {
308308

309309
/**
310310
* Handles a single range.
311+
* @param {CronField} field - The cron field to stringify
311312
* @param {FieldRange} range {start: number, end: number, step: number, count: number} The range to handle.
312-
* @param {number} min The minimum value for the field.
313313
* @param {number} max The maximum value for the field.
314314
* @returns {string | null} The stringified range or null if it cannot be stringified.
315315
* @private
316316
*/
317-
static #handleSingleRange(range: FieldRange, min: number, max: number): string | null {
317+
static #handleSingleRange(field: CronField, range: FieldRange, max: number): string | null {
318318
const step = range.step;
319319
if (!step) {
320320
return null;
321321
}
322-
if (step === 1 && range.start === min && range.end && range.end >= max) {
323-
return '*';
322+
if (step === 1 && range.start === field.min && range.end && range.end >= max) {
323+
return field.hasQuestionMarkChar ? '?' : '*';
324324
}
325-
if (step !== 1 && range.start === min && range.end && range.end >= max - step + 1) {
325+
if (step !== 1 && range.start === field.min && range.end && range.end >= max - step + 1) {
326326
return `*/${step}`;
327327
}
328328
return null;
@@ -388,18 +388,23 @@ export class CronFieldCollection {
388388
if (field instanceof CronDayOfMonth) {
389389
max = this.#month.values.length === 1 ? CronMonth.daysInMonth[this.#month.values[0] - 1] : field.max;
390390
}
391-
const ranges = CronFieldCollection.compactField(values);
392391

392+
const ranges = CronFieldCollection.compactField(values);
393393
if (ranges.length === 1) {
394-
const singleRangeResult = CronFieldCollection.#handleSingleRange(ranges[0], field.min, max);
394+
const singleRangeResult = CronFieldCollection.#handleSingleRange(field, ranges[0], max);
395395
if (singleRangeResult) {
396396
return singleRangeResult;
397397
}
398398
}
399399
return ranges
400-
.map((range) =>
401-
range.count === 1 ? range.start.toString() : CronFieldCollection.#handleMultipleRanges(range, max),
402-
)
400+
.map((range) => {
401+
const value =
402+
range.count === 1 ? range.start.toString() : CronFieldCollection.#handleMultipleRanges(range, max);
403+
if (field instanceof CronDayOfWeek && field.nthDay > 0) {
404+
return `${value}#${field.nthDay}`;
405+
}
406+
return value;
407+
})
403408
.join(',');
404409
}
405410

src/fields/CronDayOfMonth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CronField } from './CronField';
1+
import { CronField, CronFieldOptions } from './CronField';
22
import { CronChars, CronMax, CronMin, DayOfMonthRange } from './types';
33

44
const MIN_DAY = 1;
@@ -28,11 +28,11 @@ export class CronDayOfMonth extends CronField {
2828
/**
2929
* CronDayOfMonth constructor. Initializes the "day of the month" field with the provided values.
3030
* @param {DayOfMonthRange[]} values - Values for the "day of the month" field
31-
* @param {boolean} [wildcard=false] - Whether this field is a wildcard
31+
* @param {CronFieldOptions} [options] - Options provided by the parser
3232
* @throws {Error} if validation fails
3333
*/
34-
constructor(values: DayOfMonthRange[], wildcard = false) {
35-
super(values, wildcard);
34+
constructor(values: DayOfMonthRange[], options?: CronFieldOptions) {
35+
super(values, options);
3636
this.validate();
3737
}
3838

src/fields/CronDayOfWeek.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CronField } from './CronField';
1+
import { CronField, CronFieldOptions } from './CronField';
22
import { CronChars, CronMax, CronMin, DayOfWeekRange } from './types';
33

44
const MIN_DAY = 0;
@@ -30,10 +30,10 @@ export class CronDayOfWeek extends CronField {
3030
/**
3131
* CronDayOfTheWeek constructor. Initializes the "day of the week" field with the provided values.
3232
* @param {DayOfWeekRange[]} values - Values for the "day of the week" field
33-
* @param {boolean} [wildcard=false] - Whether this field is a wildcard
33+
* @param {CronFieldOptions} [options] - Options provided by the parser
3434
*/
35-
constructor(values: DayOfWeekRange[], wildcard = false) {
36-
super(values, wildcard);
35+
constructor(values: DayOfWeekRange[], options?: CronFieldOptions) {
36+
super(values, options);
3737
this.validate();
3838
}
3939

@@ -44,4 +44,13 @@ export class CronDayOfWeek extends CronField {
4444
get values(): DayOfWeekRange[] {
4545
return super.values as DayOfWeekRange[];
4646
}
47+
48+
/**
49+
* Returns the nth day of the week if specified in the cron expression.
50+
* This is used for the '#' character in the cron expression.
51+
* @returns {number} The nth day of the week (1-5) or 0 if not specified.
52+
*/
53+
get nthDay(): number {
54+
return this.options.nthDayOfWeek ?? 0;
55+
}
4756
}

src/fields/CronField.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
11
import { CronChars, CronConstraints, CronFieldType, CronMax, CronMin } from './types';
22

3+
/**
4+
* Represents the serialized form of a cron field.
5+
* @typedef {Object} SerializedCronField
6+
* @property {boolean} wildcard - Indicates if the field is a wildcard.
7+
* @property {(number|string)[]} values - The values of the field.
8+
*/
39
export type SerializedCronField = {
410
wildcard: boolean;
511
values: (number | string)[];
612
};
713

14+
/**
15+
* Represents the options for a cron field.
16+
* @typedef {Object} CronFieldOptions
17+
* @property {string} rawValue - The raw value of the field.
18+
* @property {boolean} [wildcard] - Indicates if the field is a wildcard.
19+
* @property {number} [nthDayOfWeek] - The nth day of the week.
20+
*/
21+
export type CronFieldOptions = {
22+
rawValue?: string;
23+
wildcard?: boolean;
24+
nthDayOfWeek?: number;
25+
};
26+
827
/**
928
* Represents a field within a cron expression.
1029
* This is a base class and should not be instantiated directly.
1130
* @class CronField
1231
*/
1332
export abstract class CronField {
1433
readonly #hasLastChar: boolean = false;
34+
readonly #hasQuestionMarkChar: boolean = false;
35+
1536
readonly #wildcard: boolean = false;
1637
readonly #values: (number | string)[] = [];
1738

39+
protected readonly options: CronFieldOptions & { rawValue: string } = { rawValue: '' };
40+
1841
/**
1942
* Returns the minimum value allowed for this field.
2043
*/
@@ -43,7 +66,7 @@ export abstract class CronField {
4366
* Returns the regular expression used to validate this field.
4467
*/
4568
static get validChars(): RegExp {
46-
return /^[,*\dH/-]+$/;
69+
return /^[?,*\dH/-]+$/;
4770
}
4871

4972
/**
@@ -56,25 +79,27 @@ export abstract class CronField {
5679
/**
5780
* CronField constructor. Initializes the field with the provided values.
5881
* @param {number[] | string[]} values - Values for this field
59-
* @param {boolean} [wildcard=false] - Whether this field is a wildcard
82+
* @param {CronFieldOptions} [options] - Options provided by the parser
6083
* @throws {TypeError} if the constructor is called directly
6184
* @throws {Error} if validation fails
6285
*/
63-
protected constructor(
64-
values: (number | string)[],
65-
/* istanbul ignore next - we always pass a value */ wildcard = false,
66-
) {
86+
protected constructor(values: (number | string)[], options: CronFieldOptions = { rawValue: '' }) {
6787
if (!Array.isArray(values)) {
6888
throw new Error(`${this.constructor.name} Validation error, values is not an array`);
6989
}
7090
if (!(values.length > 0)) {
7191
throw new Error(`${this.constructor.name} Validation error, values contains no values`);
7292
}
93+
94+
/* istanbul ignore next */
95+
this.options = {
96+
...options,
97+
rawValue: options.rawValue ?? '',
98+
};
7399
this.#values = values.sort(CronField.sorter);
74-
this.#wildcard = wildcard;
75-
this.#hasLastChar = values.some((expression: number | string) => {
76-
return typeof expression === 'string' && expression.indexOf('L') >= 0;
77-
});
100+
this.#wildcard = this.options.wildcard !== undefined ? this.options.wildcard : this.#isWildcardValue();
101+
this.#hasLastChar = this.options.rawValue.includes('L');
102+
this.#hasQuestionMarkChar = this.options.rawValue.includes('?');
78103
}
79104

80105
/**
@@ -112,6 +137,14 @@ export abstract class CronField {
112137
return this.#hasLastChar;
113138
}
114139

140+
/**
141+
* Indicates whether this field has a "question mark" character.
142+
* @returns {boolean}
143+
*/
144+
get hasQuestionMarkChar(): boolean {
145+
return this.#hasQuestionMarkChar;
146+
}
147+
115148
/**
116149
* Indicates whether this field is a wildcard.
117150
* @returns {boolean}
@@ -144,7 +177,6 @@ export abstract class CronField {
144177

145178
/**
146179
* Serializes the field to an object.
147-
* @todo This is really only for debugging, should it be removed?
148180
* @returns {SerializedCronField}
149181
*/
150182
serialize(): SerializedCronField {
@@ -179,4 +211,19 @@ export abstract class CronField {
179211
throw new Error(`${this.constructor.name} Validation error, duplicate values found: ${duplicate}`);
180212
}
181213
}
214+
215+
/**
216+
* Determines if the field is a wildcard based on the values.
217+
* When options.rawValue is not empty, it checks if the raw value is a wildcard, otherwise it checks if all values in the range are included.
218+
* @returns {boolean}
219+
*/
220+
#isWildcardValue(): boolean {
221+
if (this.options.rawValue.length > 0) {
222+
return ['*', '?'].includes(this.options.rawValue);
223+
}
224+
225+
return Array.from({ length: this.max - this.min + 1 }, (_, i) => i + this.min).every((value) =>
226+
this.#values.includes(value),
227+
);
228+
}
182229
}

src/fields/CronHour.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CronField } from './CronField';
1+
import { CronField, CronFieldOptions } from './CronField';
22
import { CronChars, CronMax, CronMin, HourRange } from './types';
33

44
const MIN_HOUR = 0;
@@ -26,10 +26,10 @@ export class CronHour extends CronField {
2626
/**
2727
* CronHour constructor. Initializes the "hour" field with the provided values.
2828
* @param {HourRange[]} values - Values for the "hour" field
29-
* @param {boolean} [wildcard=false] - Whether this field is a wildcard
29+
* @param {CronFieldOptions} [options] - Options provided by the parser
3030
*/
31-
constructor(values: HourRange[], wildcard = false) {
32-
super(values, wildcard);
31+
constructor(values: HourRange[], options?: CronFieldOptions) {
32+
super(values, options);
3333
this.validate();
3434
}
3535

src/fields/CronMinute.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CronField } from './CronField';
1+
import { CronField, CronFieldOptions } from './CronField';
22
import { CronChars, CronMax, CronMin, SixtyRange } from './types';
33

44
const MIN_MINUTE = 0;
@@ -26,10 +26,10 @@ export class CronMinute extends CronField {
2626
/**
2727
* CronSecond constructor. Initializes the "second" field with the provided values.
2828
* @param {SixtyRange[]} values - Values for the "second" field
29-
* @param {boolean} [wildcard=false] - Whether this field is a wildcard
29+
* @param {CronFieldOptions} [options] - Options provided by the parser
3030
*/
31-
constructor(values: SixtyRange[], wildcard = false) {
32-
super(values, wildcard);
31+
constructor(values: SixtyRange[], options?: CronFieldOptions) {
32+
super(values, options);
3333
this.validate();
3434
}
3535

src/fields/CronMonth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { DAYS_IN_MONTH } from '../CronDate';
2-
import { CronField } from './CronField';
2+
import { CronField, CronFieldOptions } from './CronField';
33
import { CronChars, CronMax, CronMin, MonthRange } from './types';
44

55
const MIN_MONTH = 1;
@@ -31,10 +31,10 @@ export class CronMonth extends CronField {
3131
/**
3232
* CronDayOfMonth constructor. Initializes the "day of the month" field with the provided values.
3333
* @param {MonthRange[]} values - Values for the "day of the month" field
34-
* @param {boolean} [wildcard=false] - Whether this field is a wildcard
34+
* @param {CronFieldOptions} [options] - Options provided by the parser
3535
*/
36-
constructor(values: MonthRange[], wildcard = false) {
37-
super(values, wildcard);
36+
constructor(values: MonthRange[], options?: CronFieldOptions) {
37+
super(values, options);
3838
this.validate();
3939
}
4040

src/fields/CronSecond.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CronChars, CronMax, CronMin, SixtyRange } from './types';
2-
import { CronField } from './CronField';
2+
import { CronField, CronFieldOptions } from './CronField';
33

44
const MIN_SECOND = 0;
55
const MAX_SECOND = 59;
@@ -24,10 +24,10 @@ export class CronSecond extends CronField {
2424
/**
2525
* CronSecond constructor. Initializes the "second" field with the provided values.
2626
* @param {SixtyRange[]} values - Values for the "second" field
27-
* @param {boolean} [wildcard=false] - Whether this field is a wildcard
27+
* @param {CronFieldOptions} [options] - Options provided by the parser
2828
*/
29-
constructor(values: SixtyRange[], wildcard = false) {
30-
super(values, wildcard);
29+
constructor(values: SixtyRange[], options?: CronFieldOptions) {
30+
super(values, options);
3131
this.validate();
3232
}
3333

0 commit comments

Comments
 (0)