Skip to content

Commit

Permalink
feature: String datetime
Browse files Browse the repository at this point in the history
  • Loading branch information
vbudovski committed Jan 3, 2025
1 parent 1fd27b1 commit 0e491b3
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,37 @@ p.string().time({ precision: 3 });
// 01:02:03.123 ✅
// 01:02:03.1234 ❌
```

### `datetime`

A valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) UTC datetime string `YYYY-MM-DDThh:mm:ss[.s+]Z`.

```typescript
p.string().datetime();
```

You can require a fixed precision by setting the `precision` option.

```typescript
p.string().datetime({ precision: 3 });
// 2020-01-02T01:02:03.123Z ✅
// 2020-01-02T01:02:03.1234Z ❌
```

Non-UTC offsets are accepted by setting the `offset` option.

```typescript
p.string().datetime({ offset: true });
// 2020-01-02T01:02:03.123Z ✅
// 2020-01-02T01:02:03.123+02:30 ✅
// 2020-01-02T01:02:03.123-0430 ✅
// 2020-01-02T01:02:03.123 ❌
```

Offset-less (naïve) values are accepted by setting the `local` option.

```typescript
p.string().datetime({ local: true });
// 2020-01-02T01:02:03.123Z ✅
// 2020-01-02T01:02:03.123 ✅
```
26 changes: 26 additions & 0 deletions paseri-lib/bench/string/datetime.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { z } from 'zod';
import * as p from '../../src/index.ts';

const { bench } = Deno;

const paseriSchema = p.string().datetime();
const zodSchema = z.string().datetime();

const dataValid = '2020-01-01T01:02:03.45678Z';
const dataInvalid = '2024-01-32T00:00:00';

bench('Paseri', { group: 'Date valid' }, () => {
paseriSchema.safeParse(dataValid);
});

bench('Zod', { group: 'Date valid' }, () => {
zodSchema.safeParse(dataValid);
});

bench('Paseri', { group: 'Date invalid' }, () => {
paseriSchema.safeParse(dataInvalid);
});

bench('Zod', { group: 'Date invalid' }, () => {
zodSchema.safeParse(dataInvalid);
});
1 change: 1 addition & 0 deletions paseri-lib/src/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const issueCodes = {
DOES_NOT_END_WITH: 'does_not_end_with' as Tagged<'does_not_end_with', 'IssueCode'>,
INVALID_DATE_STRING: 'invalid_date_string' as Tagged<'invalid_date_string', 'IssueCode'>,
INVALID_TIME_STRING: 'invalid_time_string' as Tagged<'invalid_time_string', 'IssueCode'>,
INVALID_DATE_TIME_STRING: 'invalid_date_time_string' as Tagged<'invalid_date_time_string', 'IssueCode'>,
// BigInt/Number.
TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>,
TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>,
Expand Down
1 change: 1 addition & 0 deletions paseri-lib/src/locales/en-GB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const en_GB = {
does_not_end_with: 'Does not end with search string.',
invalid_date_string: 'Invalid date string.',
invalid_time_string: 'Invalid time string.',
invalid_date_time_string: 'Invalid datetime string.',
too_small: 'Too small.',
too_large: 'Too large.',
invalid_integer: 'Number must be an integer.',
Expand Down
74 changes: 73 additions & 1 deletion paseri-lib/src/schemas/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fc from 'fast-check';
import { checkSync } from 'recheck';
import emoji from '../emoji.json' with { type: 'json' };
import * as p from '../index.ts';
import { dateRegex, emailRegex, emojiRegex, nanoidRegex, timeRegex, uuidRegex } from './string.ts';
import { dateRegex, datetimeRegex, emailRegex, emojiRegex, nanoidRegex, timeRegex, uuidRegex } from './string.ts';

const { test } = Deno;

Expand Down Expand Up @@ -32,6 +32,15 @@ function formatTime(value: Date, precision?: number): string {
return `${hour}:${minute}:${second}${fractionString}`;
}

function formatDatetime(value: Date, timezone: number, precision?: number, offset?: boolean, local?: boolean): string {
const timezoneString =
timezone === 0
? 'Z'
: `${Math.sign(timezone) >= 0 ? '+' : '-'}${String(Math.floor(Math.abs(timezone) / 60)).padStart(2, '0')}:${String(Math.abs(timezone) % 60).padStart(2, '0')}`;

return `${formatDate(value)}T${formatTime(value, precision)}${local ? '' : offset ? timezoneString : 'Z'}`;
}

test('Valid type', () => {
const schema = p.string();

Expand Down Expand Up @@ -559,6 +568,69 @@ test('Time ReDoS', () => {
);
});

test('Valid datetime', () => {
fc.assert(
fc.property(
fc.date({ min: new Date(0, 0, 1), max: new Date(9999, 11, 31) }),
fc.integer({ min: -1000, max: 1000 }),
fc.option(fc.integer({ min: 0, max: 8 }), { nil: undefined }),
fc.boolean(),
fc.boolean(),
(date, timezone, precision, offset, local) => {
const data = formatDatetime(date, timezone, precision, offset, local);

const schema = p.string().datetime({ precision, offset, local });
const result = schema.safeParse(data);
if (result.ok) {
expectTypeOf(result.value).toEqualTypeOf<string>;
expect(result.value).toBe(data);
} else {
expect(result.ok).toBeTruthy();
}
},
),
);
});

test('Invalid datetime', () => {
const schema = p.string().datetime();

fc.assert(
fc.property(
fc.string().filter((value) => !datetimeRegex().test(value)),
(data) => {
const result = schema.safeParse(data);
if (!result.ok) {
expect(result.messages()).toEqual([{ path: [], message: 'Invalid datetime string.' }]);
} else {
expect(result.ok).toBeFalsy();
}
},
),
);
});

test('Datetime ReDoS', () => {
fc.assert(
fc.property(
fc.option(fc.integer({ min: 0, max: 8 }), { nil: undefined }),
fc.boolean(),
fc.boolean(),
(precision, offset, local) => {
const regex = datetimeRegex(precision, offset, local);
const diagnostics = checkSync(regex.source, regex.flags);
if (diagnostics.status === 'vulnerable') {
console.log(`Vulnerable pattern: ${diagnostics.attack.pattern}`);
} else if (diagnostics.status === 'unknown') {
console.log(`Error: ${diagnostics.error.kind}.`);
}
expect(diagnostics.status).toBe('safe');
},
),
{ ignoreEqualValues: true },
);
});

test('Optional', () => {
const schema = p.string().optional();

Expand Down
23 changes: 22 additions & 1 deletion paseri-lib/src/schemas/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ const dateRegex = new RegExp(`^${dateRegexString}$`);
const timeRegexString = (precision?: number) =>
`([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d${precision === undefined ? '(\\.\\d+)?' : `\\.\\d{${precision}}`}`;
const timeRegex = (precision?: number) => new RegExp(`^${timeRegexString(precision)}$`);
const datetimeRegex = (precision?: number, offset?: boolean, local?: boolean) => {
const timezone: string[] = [];
timezone.push(local ? 'Z?' : 'Z');
if (offset) {
timezone.push('([+-][0-5]\\d:[0-5]\\d)');
}

return new RegExp(`^${dateRegexString}T${timeRegexString(precision)}${timezone.join('|')}$`);
};

type CheckFunction = (value: string) => TreeNode | undefined;

Expand All @@ -33,6 +42,7 @@ class StringSchema extends Schema<string> {
DOES_NOT_END_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_END_WITH },
INVALID_DATE_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_STRING },
INVALID_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_TIME_STRING },
INVALID_DATE_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_TIME_STRING },
} as const satisfies Record<string, LeafNode>;

protected _clone(): StringSchema {
Expand Down Expand Up @@ -191,6 +201,17 @@ class StringSchema extends Schema<string> {
}
});

return cloned;
}
datetime(options: { precision?: number; offset?: boolean; local?: boolean } = {}): StringSchema {
const cloned = this._clone();
cloned._checks = this._checks || [];
cloned._checks.push((_value) => {
if (!datetimeRegex(options.precision, options.offset, options.local).test(_value)) {
return this.issues.INVALID_DATE_TIME_STRING;
}
});

return cloned;
}
}
Expand All @@ -202,4 +223,4 @@ const singleton = /* @__PURE__ */ new StringSchema();
*/
const string = /* @__PURE__ */ (): StringSchema => singleton;

export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex, dateRegex, timeRegex };
export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex, dateRegex, timeRegex, datetimeRegex };

0 comments on commit 0e491b3

Please sign in to comment.