Skip to content

Commit 2a9e060

Browse files
authored
feat(string): Create .datetime() (#2087)
* refactor(parse-iso-date): Extract date struct to separate fn This will allow us to reuse the date struct for .datetime() * feat(string): Create .datetime() * test(string): Add tests for .datetime() * docs: Add .datetime() to the README * fix(datetime): Make DateTimeOptions non-nullable undefined is now the default value for precision, which I think makes sense because it indicates that the precision is "not defined", and therefore can have any or zero digits of precision.
1 parent ddea4e9 commit 2a9e060

File tree

5 files changed

+234
-28
lines changed

5 files changed

+234
-28
lines changed

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ const parsedUser = await userSchema.validate(
135135
- [`string.email(message?: string | function): Schema`](#stringemailmessage-string--function-schema)
136136
- [`string.url(message?: string | function): Schema`](#stringurlmessage-string--function-schema)
137137
- [`string.uuid(message?: string | function): Schema`](#stringuuidmessage-string--function-schema)
138+
- [`string.datetime(options?: {message?: string | function, allowOffset?: boolean, precision?: number})`](#stringdatetimeoptions-message-string--function-allowoffset-boolean-precision-number)
139+
- [`string.datetime(message?: string | function)`](#stringdatetimemessage-string--function)
138140
- [`string.ensure(): Schema`](#stringensure-schema)
139141
- [`string.trim(message?: string | function): Schema`](#stringtrimmessage-string--function-schema)
140142
- [`string.lowercase(message?: string | function): Schema`](#stringlowercasemessage-string--function-schema)
@@ -649,8 +651,8 @@ declare module 'yup' {
649651
// Define your desired `SchemaMetadata` interface by merging the
650652
// `CustomSchemaMetadata` interface.
651653
export interface CustomSchemaMetadata {
652-
placeholderText?: string
653-
tooltipText?: string
654+
placeholderText?: string;
655+
tooltipText?: string;
654656
//
655657
}
656658
}
@@ -1364,6 +1366,19 @@ Validates the value as a valid URL via a regex.
13641366

13651367
Validates the value as a valid UUID via a regex.
13661368

1369+
#### `string.datetime(options?: {message?: string | function, allowOffset?: boolean, precision?: number})`
1370+
1371+
Validates the value as an ISO datetime via a regex. Defaults to UTC validation; timezone offsets are not permitted (see `options.allowOffset`).
1372+
1373+
Unlike `.date()`, `datetime` will not convert the string to a `Date` object. `datetime` also provides greater customization over the required format of the datetime string than `date` does.
1374+
1375+
`options.allowOffset`: Allow a time zone offset. False requires UTC 'Z' timezone. _(default: false)_
1376+
`options.precision`: Require a certain sub-second precision on the date. _(default: null -- any (or no) sub-second precision)_
1377+
1378+
#### `string.datetime(message?: string | function)`
1379+
1380+
An alternate signature for `string.datetime` that can be used when you don't need to pass options other than `message`.
1381+
13671382
#### `string.ensure(): Schema`
13681383

13691384
Transforms `undefined` and `null` values to an empty string along with
@@ -1464,6 +1479,8 @@ await schema.isValid(new Date()); // => true
14641479
The default `cast` logic of `date` is pass the value to the `Date` constructor, failing that, it will attempt
14651480
to parse the date as an ISO date string.
14661481

1482+
> If you would like ISO strings to not be cast to a `Date` object, use `.datetime()` instead.
1483+
14671484
Failed casts return an invalid Date.
14681485

14691486
#### `date.min(limit: Date | string | Ref, message?: string | function): Schema`

src/locale.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export interface StringLocale {
2020
email?: Message<{ regex: RegExp }>;
2121
url?: Message<{ regex: RegExp }>;
2222
uuid?: Message<{ regex: RegExp }>;
23+
datetime?: Message;
24+
datetime_offset?: Message;
25+
datetime_precision?: Message<{ precision: number }>;
2326
trim?: Message;
2427
lowercase?: Message;
2528
uppercase?: Message;
@@ -100,6 +103,11 @@ export let string: Required<StringLocale> = {
100103
email: '${path} must be a valid email',
101104
url: '${path} must be a valid URL',
102105
uuid: '${path} must be a valid UUID',
106+
datetime: '${path} must be a valid ISO date-time',
107+
datetime_precision:
108+
'${path} must be a valid ISO date-time with a sub-second precision of exactly ${precision} digits',
109+
datetime_offset:
110+
'${path} must be a valid ISO date-time with UTC "Z" timezone',
103111
trim: '${path} must be a trimmed string',
104112
lowercase: '${path} must be a lowercase string',
105113
uppercase: '${path} must be a upper case string',

src/string.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
Optionals,
1515
} from './util/types';
1616
import Schema from './schema';
17+
import { parseDateStruct } from './util/parseIsoDate';
1718

1819
// Taken from HTML spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
1920
let rEmail =
@@ -28,6 +29,13 @@ let rUrl =
2829
let rUUID =
2930
/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
3031

32+
let yearMonthDay = '^\\d{4}-\\d{2}-\\d{2}';
33+
let hourMinuteSecond = '\\d{2}:\\d{2}:\\d{2}';
34+
let zOrOffset = '(([+-]\\d{2}(:?\\d{2})?)|Z)';
35+
let rIsoDateTime = new RegExp(
36+
`${yearMonthDay}T${hourMinuteSecond}(\\.\\d+)?${zOrOffset}$`,
37+
);
38+
3139
let isTrimmed = (value: Maybe<string>) =>
3240
isAbsent(value) || value === value.trim();
3341

@@ -37,6 +45,14 @@ export type MatchOptions = {
3745
name?: string;
3846
};
3947

48+
export type DateTimeOptions = {
49+
message: Message<{ allowOffset?: boolean; precision?: number }>;
50+
/** Allow a time zone offset. False requires UTC 'Z' timezone. (default: false) */
51+
allowOffset?: boolean;
52+
/** Require a certain sub-second precision on the date. (default: undefined -- any or no sub-second precision) */
53+
precision?: number;
54+
};
55+
4056
let objStringTag = {}.toString();
4157

4258
function create(): StringSchema;
@@ -200,6 +216,54 @@ export default class StringSchema<
200216
});
201217
}
202218

219+
datetime(options?: DateTimeOptions | DateTimeOptions['message']) {
220+
let message: DateTimeOptions['message'] = '';
221+
let allowOffset: DateTimeOptions['allowOffset'];
222+
let precision: DateTimeOptions['precision'];
223+
224+
if (options) {
225+
if (typeof options === 'object') {
226+
({
227+
message = '',
228+
allowOffset = false,
229+
precision = undefined,
230+
} = options as DateTimeOptions);
231+
} else {
232+
message = options;
233+
}
234+
}
235+
236+
return this.matches(rIsoDateTime, {
237+
name: 'datetime',
238+
message: message || locale.datetime,
239+
excludeEmptyString: true,
240+
})
241+
.test({
242+
name: 'datetime_offset',
243+
message: message || locale.datetime_offset,
244+
params: { allowOffset },
245+
skipAbsent: true,
246+
test: (value: Maybe<string>) => {
247+
if (!value || allowOffset) return true;
248+
const struct = parseDateStruct(value);
249+
if (!struct) return false;
250+
return !!struct.z;
251+
},
252+
})
253+
.test({
254+
name: 'datetime_precision',
255+
message: message || locale.datetime_precision,
256+
params: { precision },
257+
skipAbsent: true,
258+
test: (value: Maybe<string>) => {
259+
if (!value || precision == undefined) return true;
260+
const struct = parseDateStruct(value);
261+
if (!struct) return false;
262+
return struct.precision === precision;
263+
},
264+
});
265+
}
266+
203267
//-- transforms --
204268
ensure(): StringSchema<NonNullable<TType>> {
205269
return this.default('' as Defined<TType>).transform((val) =>

src/util/parseIsoDate.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,32 +10,9 @@
1010
// 1 YYYY 2 MM 3 DD 4 HH 5 mm 6 ss 7 msec 8 Z 9 ± 10 tzHH 11 tzmm
1111
const isoReg = /^(\d{4}|[+-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,.](\d{1,}))?)?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?)?$/;
1212

13-
function toNumber(str: string, defaultValue = 0) {
14-
return Number(str) || defaultValue;
15-
}
16-
1713
export function parseIsoDate(date: string): number {
18-
const regexResult = isoReg.exec(date);
19-
if (!regexResult) return Date.parse ? Date.parse(date) : Number.NaN;
20-
21-
// use of toNumber() avoids NaN timestamps caused by “undefined”
22-
// values being passed to Date constructor
23-
const struct = {
24-
year: toNumber(regexResult[1]),
25-
month: toNumber(regexResult[2], 1) - 1,
26-
day: toNumber(regexResult[3], 1),
27-
hour: toNumber(regexResult[4]),
28-
minute: toNumber(regexResult[5]),
29-
second: toNumber(regexResult[6]),
30-
millisecond: regexResult[7]
31-
? // allow arbitrary sub-second precision beyond milliseconds
32-
toNumber(regexResult[7].substring(0, 3))
33-
: 0,
34-
z: regexResult[8] || undefined,
35-
plusMinus: regexResult[9] || undefined,
36-
hourOffset: toNumber(regexResult[10]),
37-
minuteOffset: toNumber(regexResult[11]),
38-
};
14+
const struct = parseDateStruct(date);
15+
if (!struct) return Date.parse ? Date.parse(date) : Number.NaN;
3916

4017
// timestamps without timezone identifiers should be considered local time
4118
if (struct.z === undefined && struct.plusMinus === undefined) {
@@ -66,3 +43,32 @@ export function parseIsoDate(date: string): number {
6643
struct.millisecond,
6744
);
6845
}
46+
47+
export function parseDateStruct(date: string) {
48+
const regexResult = isoReg.exec(date);
49+
if (!regexResult) return null;
50+
51+
// use of toNumber() avoids NaN timestamps caused by “undefined”
52+
// values being passed to Date constructor
53+
return {
54+
year: toNumber(regexResult[1]),
55+
month: toNumber(regexResult[2], 1) - 1,
56+
day: toNumber(regexResult[3], 1),
57+
hour: toNumber(regexResult[4]),
58+
minute: toNumber(regexResult[5]),
59+
second: toNumber(regexResult[6]),
60+
millisecond: regexResult[7]
61+
? // allow arbitrary sub-second precision beyond milliseconds
62+
toNumber(regexResult[7].substring(0, 3))
63+
: 0,
64+
precision: regexResult[7]?.length ?? undefined,
65+
z: regexResult[8] || undefined,
66+
plusMinus: regexResult[9] || undefined,
67+
hourOffset: toNumber(regexResult[10]),
68+
minuteOffset: toNumber(regexResult[11]),
69+
};
70+
}
71+
72+
function toNumber(str: string, defaultValue = 0) {
73+
return Number(str) || defaultValue;
74+
}

test/string.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import * as TestHelpers from './helpers';
22

3-
import { string, number, object, ref } from '../src';
3+
import {
4+
string,
5+
number,
6+
object,
7+
ref,
8+
ValidationError,
9+
AnySchema,
10+
} from '../src';
411

512
describe('String types', () => {
613
describe('casting', () => {
@@ -225,6 +232,110 @@ describe('String types', () => {
225232
]);
226233
});
227234

235+
describe('DATETIME', function () {
236+
it('should check DATETIME correctly', function () {
237+
let v = string().datetime();
238+
239+
return Promise.all([
240+
expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true),
241+
expect(v.isValid('1977-00-28T12:34:56.0Z')).resolves.toBe(true),
242+
expect(v.isValid('1900-10-29T12:34:56.00Z')).resolves.toBe(true),
243+
expect(v.isValid('1000-11-30T12:34:56.000Z')).resolves.toBe(true),
244+
expect(v.isValid('4444-12-31T12:34:56.0000Z')).resolves.toBe(true),
245+
246+
// Should not allow time zone offset by default
247+
expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(false),
248+
expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(false),
249+
expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(false),
250+
251+
expect(v.isValid('this is not a datetime')).resolves.toBe(false),
252+
expect(v.isValid('2023-08-16T12:34:56')).resolves.toBe(false),
253+
expect(v.isValid('2023-08-1612:34:56Z')).resolves.toBe(false),
254+
expect(v.isValid('1970-01-01 00:00:00Z')).resolves.toBe(false),
255+
expect(v.isValid('1970-01-01T00:00:00,000Z')).resolves.toBe(false),
256+
expect(v.isValid('1970-01-01T0000')).resolves.toBe(false),
257+
expect(v.isValid('1970-01-01T00:00.000')).resolves.toBe(false),
258+
expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false),
259+
expect(v.isValid('2023-08-16')).resolves.toBe(false),
260+
expect(v.isValid('1970-as-df')).resolves.toBe(false),
261+
expect(v.isValid('19700101')).resolves.toBe(false),
262+
expect(v.isValid('197001')).resolves.toBe(false),
263+
]);
264+
});
265+
266+
it('should support DATETIME allowOffset option', function () {
267+
let v = string().datetime({ allowOffset: true });
268+
269+
return Promise.all([
270+
expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true),
271+
expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(true),
272+
expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(true),
273+
expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(true),
274+
expect(v.isValid('1970-01-01T00:00:00+0630')).resolves.toBe(true),
275+
]);
276+
});
277+
278+
it('should support DATETIME precision option', function () {
279+
let v = string().datetime({ precision: 4 });
280+
281+
return Promise.all([
282+
expect(v.isValid('2023-01-09T12:34:56.0000Z')).resolves.toBe(true),
283+
expect(v.isValid('2023-01-09T12:34:56.00000Z')).resolves.toBe(false),
284+
expect(v.isValid('2023-01-09T12:34:56.000Z')).resolves.toBe(false),
285+
expect(v.isValid('2023-01-09T12:34:56.00Z')).resolves.toBe(false),
286+
expect(v.isValid('2023-01-09T12:34:56.0Z')).resolves.toBe(false),
287+
expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false),
288+
expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(false),
289+
expect(v.isValid('2010-04-10T14:06:14.0000+00:00')).resolves.toBe(
290+
false,
291+
),
292+
]);
293+
});
294+
295+
describe('DATETIME error strings', function () {
296+
function getErrorString(schema: AnySchema, value: string) {
297+
try {
298+
schema.validateSync(value);
299+
fail('should have thrown validation error');
300+
} catch (e) {
301+
const err = e as ValidationError;
302+
return err.errors[0];
303+
}
304+
}
305+
306+
it('should use the default locale string on error', function () {
307+
let v = string().datetime();
308+
expect(getErrorString(v, 'asdf')).toBe(
309+
'this must be a valid ISO date-time',
310+
);
311+
});
312+
313+
it('should use the allowOffset locale string on error when offset caused error', function () {
314+
let v = string().datetime();
315+
expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe(
316+
'this must be a valid ISO date-time with UTC "Z" timezone',
317+
);
318+
});
319+
320+
it('should use the precision locale string on error when precision caused error', function () {
321+
let v = string().datetime({ precision: 2 });
322+
expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe(
323+
'this must be a valid ISO date-time with a sub-second precision of exactly 2 digits',
324+
);
325+
});
326+
327+
it('should prefer options.message over all default error messages', function () {
328+
let msg = 'hello';
329+
let v = string().datetime({ message: msg });
330+
expect(getErrorString(v, 'asdf')).toBe(msg);
331+
expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe(msg);
332+
333+
v = string().datetime({ message: msg, precision: 2 });
334+
expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe(msg);
335+
});
336+
});
337+
});
338+
228339
xit('should check allowed values at the end', () => {
229340
return Promise.all([
230341
expect(

0 commit comments

Comments
 (0)