Skip to content

Commit

Permalink
feat(string): Create .datetime() (#2087)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
0livare committed Feb 28, 2024
1 parent ddea4e9 commit 2a9e060
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 28 deletions.
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ const parsedUser = await userSchema.validate(
- [`string.email(message?: string | function): Schema`](#stringemailmessage-string--function-schema)
- [`string.url(message?: string | function): Schema`](#stringurlmessage-string--function-schema)
- [`string.uuid(message?: string | function): Schema`](#stringuuidmessage-string--function-schema)
- [`string.datetime(options?: {message?: string | function, allowOffset?: boolean, precision?: number})`](#stringdatetimeoptions-message-string--function-allowoffset-boolean-precision-number)
- [`string.datetime(message?: string | function)`](#stringdatetimemessage-string--function)
- [`string.ensure(): Schema`](#stringensure-schema)
- [`string.trim(message?: string | function): Schema`](#stringtrimmessage-string--function-schema)
- [`string.lowercase(message?: string | function): Schema`](#stringlowercasemessage-string--function-schema)
Expand Down Expand Up @@ -649,8 +651,8 @@ declare module 'yup' {
// Define your desired `SchemaMetadata` interface by merging the
// `CustomSchemaMetadata` interface.
export interface CustomSchemaMetadata {
placeholderText?: string
tooltipText?: string
placeholderText?: string;
tooltipText?: string;
//
}
}
Expand Down Expand Up @@ -1364,6 +1366,19 @@ Validates the value as a valid URL via a regex.

Validates the value as a valid UUID via a regex.

#### `string.datetime(options?: {message?: string | function, allowOffset?: boolean, precision?: number})`

Validates the value as an ISO datetime via a regex. Defaults to UTC validation; timezone offsets are not permitted (see `options.allowOffset`).

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.

`options.allowOffset`: Allow a time zone offset. False requires UTC 'Z' timezone. _(default: false)_
`options.precision`: Require a certain sub-second precision on the date. _(default: null -- any (or no) sub-second precision)_

#### `string.datetime(message?: string | function)`

An alternate signature for `string.datetime` that can be used when you don't need to pass options other than `message`.

#### `string.ensure(): Schema`

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

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

#### `date.min(limit: Date | string | Ref, message?: string | function): Schema`
Expand Down
8 changes: 8 additions & 0 deletions src/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export interface StringLocale {
email?: Message<{ regex: RegExp }>;
url?: Message<{ regex: RegExp }>;
uuid?: Message<{ regex: RegExp }>;
datetime?: Message;
datetime_offset?: Message;
datetime_precision?: Message<{ precision: number }>;
trim?: Message;
lowercase?: Message;
uppercase?: Message;
Expand Down Expand Up @@ -100,6 +103,11 @@ export let string: Required<StringLocale> = {
email: '${path} must be a valid email',
url: '${path} must be a valid URL',
uuid: '${path} must be a valid UUID',
datetime: '${path} must be a valid ISO date-time',
datetime_precision:
'${path} must be a valid ISO date-time with a sub-second precision of exactly ${precision} digits',
datetime_offset:
'${path} must be a valid ISO date-time with UTC "Z" timezone',
trim: '${path} must be a trimmed string',
lowercase: '${path} must be a lowercase string',
uppercase: '${path} must be a upper case string',
Expand Down
64 changes: 64 additions & 0 deletions src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
Optionals,
} from './util/types';
import Schema from './schema';
import { parseDateStruct } from './util/parseIsoDate';

// Taken from HTML spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
let rEmail =
Expand All @@ -28,6 +29,13 @@ let rUrl =
let rUUID =
/^(?:[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;

let yearMonthDay = '^\\d{4}-\\d{2}-\\d{2}';
let hourMinuteSecond = '\\d{2}:\\d{2}:\\d{2}';
let zOrOffset = '(([+-]\\d{2}(:?\\d{2})?)|Z)';
let rIsoDateTime = new RegExp(
`${yearMonthDay}T${hourMinuteSecond}(\\.\\d+)?${zOrOffset}$`,
);

let isTrimmed = (value: Maybe<string>) =>
isAbsent(value) || value === value.trim();

Expand All @@ -37,6 +45,14 @@ export type MatchOptions = {
name?: string;
};

export type DateTimeOptions = {
message: Message<{ allowOffset?: boolean; precision?: number }>;
/** Allow a time zone offset. False requires UTC 'Z' timezone. (default: false) */
allowOffset?: boolean;
/** Require a certain sub-second precision on the date. (default: undefined -- any or no sub-second precision) */
precision?: number;
};

let objStringTag = {}.toString();

function create(): StringSchema;
Expand Down Expand Up @@ -200,6 +216,54 @@ export default class StringSchema<
});
}

datetime(options?: DateTimeOptions | DateTimeOptions['message']) {
let message: DateTimeOptions['message'] = '';
let allowOffset: DateTimeOptions['allowOffset'];
let precision: DateTimeOptions['precision'];

if (options) {
if (typeof options === 'object') {
({
message = '',
allowOffset = false,
precision = undefined,
} = options as DateTimeOptions);
} else {
message = options;
}
}

return this.matches(rIsoDateTime, {
name: 'datetime',
message: message || locale.datetime,
excludeEmptyString: true,
})
.test({
name: 'datetime_offset',
message: message || locale.datetime_offset,
params: { allowOffset },
skipAbsent: true,
test: (value: Maybe<string>) => {
if (!value || allowOffset) return true;
const struct = parseDateStruct(value);
if (!struct) return false;
return !!struct.z;
},
})
.test({
name: 'datetime_precision',
message: message || locale.datetime_precision,
params: { precision },
skipAbsent: true,
test: (value: Maybe<string>) => {
if (!value || precision == undefined) return true;
const struct = parseDateStruct(value);
if (!struct) return false;
return struct.precision === precision;
},
});
}

//-- transforms --
ensure(): StringSchema<NonNullable<TType>> {
return this.default('' as Defined<TType>).transform((val) =>
Expand Down
56 changes: 31 additions & 25 deletions src/util/parseIsoDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,9 @@
// 1 YYYY 2 MM 3 DD 4 HH 5 mm 6 ss 7 msec 8 Z 9 ± 10 tzHH 11 tzmm
const isoReg = /^(\d{4}|[+-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,.](\d{1,}))?)?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?)?$/;

function toNumber(str: string, defaultValue = 0) {
return Number(str) || defaultValue;
}

export function parseIsoDate(date: string): number {
const regexResult = isoReg.exec(date);
if (!regexResult) return Date.parse ? Date.parse(date) : Number.NaN;

// use of toNumber() avoids NaN timestamps caused by “undefined”
// values being passed to Date constructor
const struct = {
year: toNumber(regexResult[1]),
month: toNumber(regexResult[2], 1) - 1,
day: toNumber(regexResult[3], 1),
hour: toNumber(regexResult[4]),
minute: toNumber(regexResult[5]),
second: toNumber(regexResult[6]),
millisecond: regexResult[7]
? // allow arbitrary sub-second precision beyond milliseconds
toNumber(regexResult[7].substring(0, 3))
: 0,
z: regexResult[8] || undefined,
plusMinus: regexResult[9] || undefined,
hourOffset: toNumber(regexResult[10]),
minuteOffset: toNumber(regexResult[11]),
};
const struct = parseDateStruct(date);
if (!struct) return Date.parse ? Date.parse(date) : Number.NaN;

// timestamps without timezone identifiers should be considered local time
if (struct.z === undefined && struct.plusMinus === undefined) {
Expand Down Expand Up @@ -66,3 +43,32 @@ export function parseIsoDate(date: string): number {
struct.millisecond,
);
}

export function parseDateStruct(date: string) {
const regexResult = isoReg.exec(date);
if (!regexResult) return null;

// use of toNumber() avoids NaN timestamps caused by “undefined”
// values being passed to Date constructor
return {
year: toNumber(regexResult[1]),
month: toNumber(regexResult[2], 1) - 1,
day: toNumber(regexResult[3], 1),
hour: toNumber(regexResult[4]),
minute: toNumber(regexResult[5]),
second: toNumber(regexResult[6]),
millisecond: regexResult[7]
? // allow arbitrary sub-second precision beyond milliseconds
toNumber(regexResult[7].substring(0, 3))
: 0,
precision: regexResult[7]?.length ?? undefined,
z: regexResult[8] || undefined,
plusMinus: regexResult[9] || undefined,
hourOffset: toNumber(regexResult[10]),
minuteOffset: toNumber(regexResult[11]),
};
}

function toNumber(str: string, defaultValue = 0) {
return Number(str) || defaultValue;
}
113 changes: 112 additions & 1 deletion test/string.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import * as TestHelpers from './helpers';

import { string, number, object, ref } from '../src';
import {
string,
number,
object,
ref,
ValidationError,
AnySchema,
} from '../src';

describe('String types', () => {
describe('casting', () => {
Expand Down Expand Up @@ -225,6 +232,110 @@ describe('String types', () => {
]);
});

describe('DATETIME', function () {
it('should check DATETIME correctly', function () {
let v = string().datetime();

return Promise.all([
expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true),
expect(v.isValid('1977-00-28T12:34:56.0Z')).resolves.toBe(true),
expect(v.isValid('1900-10-29T12:34:56.00Z')).resolves.toBe(true),
expect(v.isValid('1000-11-30T12:34:56.000Z')).resolves.toBe(true),
expect(v.isValid('4444-12-31T12:34:56.0000Z')).resolves.toBe(true),

// Should not allow time zone offset by default
expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(false),
expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(false),
expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(false),

expect(v.isValid('this is not a datetime')).resolves.toBe(false),
expect(v.isValid('2023-08-16T12:34:56')).resolves.toBe(false),
expect(v.isValid('2023-08-1612:34:56Z')).resolves.toBe(false),
expect(v.isValid('1970-01-01 00:00:00Z')).resolves.toBe(false),
expect(v.isValid('1970-01-01T00:00:00,000Z')).resolves.toBe(false),
expect(v.isValid('1970-01-01T0000')).resolves.toBe(false),
expect(v.isValid('1970-01-01T00:00.000')).resolves.toBe(false),
expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false),
expect(v.isValid('2023-08-16')).resolves.toBe(false),
expect(v.isValid('1970-as-df')).resolves.toBe(false),
expect(v.isValid('19700101')).resolves.toBe(false),
expect(v.isValid('197001')).resolves.toBe(false),
]);
});

it('should support DATETIME allowOffset option', function () {
let v = string().datetime({ allowOffset: true });

return Promise.all([
expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true),
expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(true),
expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(true),
expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(true),
expect(v.isValid('1970-01-01T00:00:00+0630')).resolves.toBe(true),
]);
});

it('should support DATETIME precision option', function () {
let v = string().datetime({ precision: 4 });

return Promise.all([
expect(v.isValid('2023-01-09T12:34:56.0000Z')).resolves.toBe(true),
expect(v.isValid('2023-01-09T12:34:56.00000Z')).resolves.toBe(false),
expect(v.isValid('2023-01-09T12:34:56.000Z')).resolves.toBe(false),
expect(v.isValid('2023-01-09T12:34:56.00Z')).resolves.toBe(false),
expect(v.isValid('2023-01-09T12:34:56.0Z')).resolves.toBe(false),
expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false),
expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(false),
expect(v.isValid('2010-04-10T14:06:14.0000+00:00')).resolves.toBe(
false,
),
]);
});

describe('DATETIME error strings', function () {
function getErrorString(schema: AnySchema, value: string) {
try {
schema.validateSync(value);
fail('should have thrown validation error');
} catch (e) {
const err = e as ValidationError;
return err.errors[0];
}
}

it('should use the default locale string on error', function () {
let v = string().datetime();
expect(getErrorString(v, 'asdf')).toBe(
'this must be a valid ISO date-time',
);
});

it('should use the allowOffset locale string on error when offset caused error', function () {
let v = string().datetime();
expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe(
'this must be a valid ISO date-time with UTC "Z" timezone',
);
});

it('should use the precision locale string on error when precision caused error', function () {
let v = string().datetime({ precision: 2 });
expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe(
'this must be a valid ISO date-time with a sub-second precision of exactly 2 digits',
);
});

it('should prefer options.message over all default error messages', function () {
let msg = 'hello';
let v = string().datetime({ message: msg });
expect(getErrorString(v, 'asdf')).toBe(msg);
expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe(msg);

v = string().datetime({ message: msg, precision: 2 });
expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe(msg);
});
});
});

xit('should check allowed values at the end', () => {

Check warning on line 339 in test/string.ts

View workflow job for this annotation

GitHub Actions / build

Disabled test
return Promise.all([
expect(
Expand Down

0 comments on commit 2a9e060

Please sign in to comment.