Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature: String time
Browse files Browse the repository at this point in the history
vbudovski committed Jan 3, 2025
1 parent d3579ec commit 1fd27b1
Showing 6 changed files with 135 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -117,3 +117,19 @@ A valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) date string `YYYY-MM-
```typescript
p.string().date();
```

### `time`

A valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) time string `hh:mm:ss[.s+]`.

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

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

```typescript
p.string().time({ precision: 3 });
// 01:02:03.123 ✅
// 01:02:03.1234 ❌
```
26 changes: 26 additions & 0 deletions paseri-lib/bench/string/time.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().time();
const zodSchema = z.string().time();

const dataValid = '00:00:00';
const dataInvalid = '99:99:99';

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
@@ -15,6 +15,7 @@ const issueCodes = {
DOES_NOT_START_WITH: 'does_not_start_with' as Tagged<'does_not_start_with', 'IssueCode'>,
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'>,
// BigInt/Number.
TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>,
TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>,
1 change: 1 addition & 0 deletions paseri-lib/src/locales/en-GB.ts
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ const en_GB = {
does_not_start_with: 'Does not start with search string.',
does_not_end_with: 'Does not end with search string.',
invalid_date_string: 'Invalid date string.',
invalid_time_string: 'Invalid time string.',
too_small: 'Too small.',
too_large: 'Too large.',
invalid_integer: 'Number must be an integer.',
76 changes: 75 additions & 1 deletion paseri-lib/src/schemas/string.test.ts
Original file line number Diff line number Diff line change
@@ -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, uuidRegex } from './string.ts';
import { dateRegex, emailRegex, emojiRegex, nanoidRegex, timeRegex, uuidRegex } from './string.ts';

const { test } = Deno;

@@ -19,6 +19,19 @@ function formatDate(value: Date): string {
return `${year}-${month}-${date}`;
}

function formatTime(value: Date, precision?: number): string {
const hour = String(value.getHours()).padStart(2, '0');
const minute = String(value.getMinutes()).padStart(2, '0');
const second = String(value.getSeconds()).padStart(2, '0');
const fraction = value.getMilliseconds() / 1000;
const fractionString =
precision === undefined
? String(fraction).slice(1)
: `.${fraction.toFixed(precision).slice(2).padEnd(precision, '0')}`;

return `${hour}:${minute}:${second}${fractionString}`;
}

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

@@ -491,6 +504,61 @@ test('Date ReDoS', () => {
expect(diagnostics.status).toBe('safe');
});

test('Valid time', () => {
fc.assert(
fc.property(
fc.date({ min: new Date(0, 0, 1), max: new Date(9999, 11, 31) }),
fc.option(fc.integer({ min: 0, max: 8 }), { nil: undefined }),
(date, precision) => {
const data = formatTime(date, precision);

const schema = p.string().time({ precision });
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 time', () => {
const schema = p.string().time();

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

test('Time ReDoS', () => {
fc.assert(
fc.property(fc.option(fc.integer({ min: 0, max: 8 }), { nil: undefined }), (precision) => {
const regex = timeRegex(precision);
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();

@@ -589,4 +657,10 @@ test('Immutable', async (t) => {
const modified = original.date();
expect(modified).not.toEqual(original);
});

await t.step('time', () => {
const original = p.string();
const modified = original.time();
expect(modified).not.toEqual(original);
});
});
17 changes: 16 additions & 1 deletion paseri-lib/src/schemas/string.ts
Original file line number Diff line number Diff line change
@@ -11,6 +11,9 @@ const nanoidRegex = /^[a-z0-9_-]{21}$/i;
const dateRegexString =
'((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))';
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)}$`);

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

@@ -29,6 +32,7 @@ class StringSchema extends Schema<string> {
DOES_NOT_START_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_START_WITH },
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 },
} as const satisfies Record<string, LeafNode>;

protected _clone(): StringSchema {
@@ -176,6 +180,17 @@ class StringSchema extends Schema<string> {
}
});

return cloned;
}
time(options: { precision?: number } = {}): StringSchema {
const cloned = this._clone();
cloned._checks = this._checks || [];
cloned._checks.push((_value) => {
if (!timeRegex(options.precision).test(_value)) {
return this.issues.INVALID_TIME_STRING;
}
});

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

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

0 comments on commit 1fd27b1

Please sign in to comment.