diff --git a/library/src/actions/index.ts b/library/src/actions/index.ts index 78c0f37bd..84dc72cbd 100644 --- a/library/src/actions/index.ts +++ b/library/src/actions/index.ts @@ -33,6 +33,7 @@ export * from './ipv4/index.ts'; export * from './ipv6/index.ts'; export * from './isoDate/index.ts'; export * from './isoDateTime/index.ts'; +export * from './isoDuration/index.ts'; export * from './isoTime/index.ts'; export * from './isoTimeSecond/index.ts'; export * from './isoTimestamp/index.ts'; diff --git a/library/src/actions/isoDuration/index.ts b/library/src/actions/isoDuration/index.ts new file mode 100644 index 000000000..5d7d61757 --- /dev/null +++ b/library/src/actions/isoDuration/index.ts @@ -0,0 +1 @@ +export * from './isoDuration.ts'; diff --git a/library/src/actions/isoDuration/isISO8601Duration.ts b/library/src/actions/isoDuration/isISO8601Duration.ts new file mode 100644 index 000000000..8fdadfc8c --- /dev/null +++ b/library/src/actions/isoDuration/isISO8601Duration.ts @@ -0,0 +1,36 @@ +const ALTERNATE_FORMAT: RegExp = /^[-+]?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; +const PERIOD_REGEX: RegExp = /\./g; +const COMMA_REGEX: RegExp = /,/g; +const WEEK_PART: RegExp = /\d+W/; +const DATE_PARTS: RegExp = /\d+[YMD]/; +const DATE_PARTS_ORDER: RegExp = /^(\d+([\.,]\d+)?Y)?(\d+([\.,]\d+)?M)?(\d+([\.,]\d+)?D)?(\d+([\.,]\d+)?W)?$/; +const TIME_PARTS_ORDER: RegExp = /^(\d+([\.,]\d+)?H)?(\d+([\.,]\d+)?M)?(\d+([\.,]\d+)?S)?$/; + +export const isISO8601Duration = (input: unknown): boolean => { + if (!input || typeof input !== 'string') return false; + if (!input.startsWith('P') || input === 'P' || input === 'PT') return false; + + if (ALTERNATE_FORMAT.test(input)) return true; + + const totalDecimalPeriods = (input.match(PERIOD_REGEX) || []).length; + const totalDecimalCommas = (input.match(COMMA_REGEX) || []).length; + if (totalDecimalPeriods > 0 && totalDecimalCommas > 0) return false; + + const parts = input.substring(1).split('T'); + + if (parts.length > 2) return false; + + const datePart = parts[0]; + const timePart = parts[1]; + + if (timePart === '') return false; + + const hasWeeks = WEEK_PART.test(datePart); + const hasOtherDateParts = DATE_PARTS.test(datePart); + if (hasWeeks && hasOtherDateParts) return false; + + if (datePart !== '' && !DATE_PARTS_ORDER.test(datePart)) return false; + if (typeof timePart === 'string' && timePart !== '' && !TIME_PARTS_ORDER.test(timePart)) return false; + + return true; +} \ No newline at end of file diff --git a/library/src/actions/isoDuration/isoDuration.test-d.ts b/library/src/actions/isoDuration/isoDuration.test-d.ts new file mode 100644 index 000000000..06ca96921 --- /dev/null +++ b/library/src/actions/isoDuration/isoDuration.test-d.ts @@ -0,0 +1,43 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { isoDuration, type IsoDurationAction, type IsoDurationIssue } from './isoDuration.ts'; + +describe('isoDuration', () => { + describe('should return action object', () => { + test('with undefined message', () => { + type Action = IsoDurationAction; + expectTypeOf(isoDuration()).toEqualTypeOf(); + expectTypeOf( + isoDuration(undefined) + ).toEqualTypeOf(); + }); + + test('with string message', () => { + expectTypeOf(isoDuration('message')).toEqualTypeOf< + IsoDurationAction + >(); + }); + + test('with function message', () => { + expectTypeOf( + isoDuration string>(() => 'message') + ).toEqualTypeOf string>>(); + }); + }); + + describe('should infer correct types', () => { + type Action = IsoDurationAction; + + test('of input', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of output', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of issue', () => { + expectTypeOf>().toEqualTypeOf>(); + }); + }); +}); diff --git a/library/src/actions/isoDuration/isoDuration.test.ts b/library/src/actions/isoDuration/isoDuration.test.ts new file mode 100644 index 000000000..8ef62422f --- /dev/null +++ b/library/src/actions/isoDuration/isoDuration.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from 'vitest'; +import type { StringIssue } from '../../schemas/index.ts'; +import { expectActionIssue } from '../../vitest/expectActionIssue.ts'; +import { expectNoActionIssue } from '../../vitest/expectNoActionIssue.ts'; +import { isoDuration, type IsoDurationAction, type IsoDurationIssue } from './isoDuration.ts'; +import { isISO8601Duration } from './isISO8601Duration.ts'; + +describe('isoDuration', () => { + describe('should return action object', () => { + const baseAction: Omit, 'message'> = { + kind: 'validation', + type: 'iso_duration', + reference: isoDuration, + expects: null, + requirement: isISO8601Duration, + async: false, + '~run': expect.any(Function), + }; + + test('with undefined message', () => { + const action: IsoDurationAction = { + ...baseAction, + message: undefined, + }; + expect(isoDuration()).toStrictEqual(action); + expect(isoDuration(undefined)).toStrictEqual(action); + }); + + test('with string message', () => { + expect(isoDuration('message')).toStrictEqual({ + ...baseAction, + message: 'message', + } satisfies IsoDurationAction); + }); + + test('with function message', () => { + const message = () => 'message'; + expect(isoDuration(message)).toStrictEqual({ + ...baseAction, + message, + } satisfies IsoDurationAction); + }); + }); + + describe('should return dataset without issues', () => { + const action = isoDuration(); + + test('for untyped inputs', () => { + const issues: [StringIssue] = [ + { + kind: 'schema', + type: 'string', + input: null, + expected: 'string', + received: 'null', + message: 'message', + }, + ]; + expect( + action['~run']({ typed: false, value: null, issues }, {}) + ).toStrictEqual({ + typed: false, + value: null, + issues, + }); + }); + + test('for valid ISO Durations', () => { + expectNoActionIssue(action, [ + 'P1Y', + 'P3M', + 'P2W', + 'P5D', + 'PT8H', + 'PT30M', + 'PT10S', + 'P1Y6M', + 'P3Y4M2D', + 'P2DT12H', + 'PT1H30M', + 'PT1H30M45S', + 'P1Y2M3DT4H5M6S', + 'P0Y0M0DT0H0M0S', + 'PT0S', + 'P1Y0M', + 'P0Y3M', + 'P10Y', + 'PT36H', + 'PT0.5S', + 'PT1,5H', + ]); + }); + }); + + describe('should return dataset with issues', () => { + const action = isoDuration('message'); + const baseIssue: Omit, 'input' | 'received'> = { + kind: 'validation', + type: 'iso_duration', + expected: null, + message: 'message', + requirement: isISO8601Duration, + }; + + test('for empty strings', () => { + expectActionIssue(action, baseIssue, ['', ' ', '\n']); + }); + + test('for missing prefix', () => { + expectActionIssue(action, baseIssue, [ + '1Y2M', + 'T30M', + '5D3H', + '3Y6M2DT12H30M' + ]); + }); + + test('for invalid order', () => { + expectActionIssue(action, baseIssue, [ + 'P1M2Y', + 'PT30S15M', + 'P3D2M1Y' + ]); + }); + + test('for missing T separator', () => { + expectActionIssue(action, baseIssue, [ + 'P1Y2M3D4H5M6S', + 'P5D10H', + 'P10Y3M5D1H' + ]); + }); + + test('for duplicate designators', () => { + expectActionIssue(action, baseIssue, [ + 'P1Y2Y3M', + 'PT5H6H', + 'P1Y2M3D4Q', + 'P1Y2MT3HZ', + ]); + }); + + test('for mix-matching with W designator', () => { + expectActionIssue(action, baseIssue, [ + 'P1Y2Y3M1W', + 'P3WT5H6H' + ]); + }); + + test('for mix-matching , and .', () => { + expectActionIssue(action, baseIssue, [ + 'P1.1Y2Y3MT2.5D', + 'P1,5YT5.1S' + ]); + }); + }); +}); diff --git a/library/src/actions/isoDuration/isoDuration.ts b/library/src/actions/isoDuration/isoDuration.ts new file mode 100644 index 000000000..883e2823b --- /dev/null +++ b/library/src/actions/isoDuration/isoDuration.ts @@ -0,0 +1,109 @@ +import type { + BaseIssue, + BaseValidation, + ErrorMessage, +} from '../../types/index.ts'; +import { _addIssue } from '../../utils/index.ts'; +import { isISO8601Duration } from "./isISO8601Duration.ts"; + +/** + * ISO duration issue interface. + */ +export interface IsoDurationIssue extends BaseIssue { + /** + * The issue kind. + */ + readonly kind: 'validation'; + /** + * The issue type. + */ + readonly type: 'iso_duration'; + /** + * The expected property. + */ + readonly expected: null; + /** + * The received property. + */ + readonly received: `"${string}"`; + /** + * The validation function. + */ + readonly requirement: (input: TInput) => boolean; +} + +/** + * ISO duration action interface. + */ +export interface IsoDurationAction< + TInput extends string, + TMessage extends ErrorMessage> | undefined, +> extends BaseValidation> { + /** + * The action type. + */ + readonly type: 'iso_duration'; + /** + * The action reference. + */ + readonly reference: typeof isoDuration; + /** + * The expected property. + */ + readonly expects: null; + /** + * The validation function. + */ + readonly requirement: (input: TInput) => boolean; + /** + * The error message. + */ + readonly message: TMessage; +} + +/** + * Creates an [ISO duration](https://en.wikipedia.org/wiki/ISO_8601) validation action. + * + * Format: P[n]Y[n]M[n]DT[n]H[n]M[n]S + * + * @returns An ISO duration action. + */ +export function isoDuration(): IsoDurationAction< + TInput, + undefined +>; + +/** + * Creates an [ISO duration](https://en.wikipedia.org/wiki/ISO_8601) validation action. + * + * Format: P[n]Y[n]M[n]DT[n]H[n]M[n]S + * + * @param message The error message. + * + * @returns An ISO duration action. + */ +export function isoDuration< + TInput extends string, + const TMessage extends ErrorMessage> | undefined, +>(message: TMessage): IsoDurationAction; + +// @__NO_SIDE_EFFECTS__ +export function isoDuration( + message?: ErrorMessage> +): IsoDurationAction> | undefined> { + return { + kind: 'validation', + type: 'iso_duration', + reference: isoDuration, + async: false, + expects: null, + requirement: isISO8601Duration, + message, + '~run'(dataset, config) { + if (dataset.typed && !this.requirement(dataset.value)) { + _addIssue(this, 'duration', dataset, config); + } + return dataset; + }, + }; +} diff --git a/website/src/routes/api/(actions)/isoDuration/index.mdx b/website/src/routes/api/(actions)/isoDuration/index.mdx new file mode 100644 index 000000000..d8aad2eb7 --- /dev/null +++ b/website/src/routes/api/(actions)/isoDuration/index.mdx @@ -0,0 +1,73 @@ +--- +title: isoDuration +description: Creates an ISO duration validation action. +source: /actions/isoDuration/isoDuration.ts +contributors: + - fabian-hiller + - muningis +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# isoDuration + +Creates an [ISO duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) validation action. + +Patterns: +- `P[n]Y[n]M[n]DT[n]H[n]M[n]S` +- `P[n]W` + +> This supports ISO 8601 format. Mix-matching W with other date parts as per ISO 8601-1, and `+` or `-` prefixes as per ISO 8601-2 are not supported. + +```ts +const Action = v.isoDuration(message); +``` + +## Generics + +- `TInput` +- `TMessage` + +## Parameters + +- `message` + +### Explanation + +With `isoDuration` you can validate the formatting of a string. If the input is not an ISO duration, you can use `message` to customize the error message. + +## Returns + +- `Action` + +## Examples + +The following examples show how `isoDuration` can be used. + +### ISO duration schema + +Schema to validate an ISO duration. + +```ts +const IsoDurationSchema = v.pipe( + v.string(), + v.isoDuration('String does not match ISO 8601 Duration format P[n]Y[n]M[n]DT[n]H[n]M[n]S or P[n]W') +); +``` + +## Related + +The following APIs can be combined with `isoDuration`. + +### Schemas + + + +### Methods + + + +### Utils + + diff --git a/website/src/routes/api/(actions)/isoDuration/properties.ts b/website/src/routes/api/(actions)/isoDuration/properties.ts new file mode 100644 index 000000000..537d310e9 --- /dev/null +++ b/website/src/routes/api/(actions)/isoDuration/properties.ts @@ -0,0 +1,58 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TInput: { + modifier: 'extends', + type: 'string', + }, + TMessage: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'ErrorMessage', + href: '../ErrorMessage/', + generics: [ + { + type: 'custom', + name: 'IsoDurationIssue', + href: '../IsoDurationIssue/', + generics: [ + { + type: 'custom', + name: 'TInput', + }, + ], + }, + ], + }, + 'undefined', + ], + }, + }, + message: { + type: { + type: 'custom', + name: 'TMessage', + }, + }, + Action: { + type: { + type: 'custom', + name: 'IsoDurationAction', + href: '../IsoDurationAction/', + generics: [ + { + type: 'custom', + name: 'TInput', + }, + { + type: 'custom', + name: 'TMessage', + }, + ], + }, + }, +}; diff --git a/website/src/routes/api/(methods)/pipe/index.mdx b/website/src/routes/api/(methods)/pipe/index.mdx index 533db5d2c..9e24a45d4 100644 --- a/website/src/routes/api/(methods)/pipe/index.mdx +++ b/website/src/routes/api/(methods)/pipe/index.mdx @@ -190,6 +190,7 @@ The following APIs can be combined with `pipe`. 'ipv6', 'isoDate', 'isoDateTime', + 'isoDuration', 'isoTime', 'isoTimeSecond', 'isoTimestamp', diff --git a/website/src/routes/api/(schemas)/any/index.mdx b/website/src/routes/api/(schemas)/any/index.mdx index 533243996..3f831b07b 100644 --- a/website/src/routes/api/(schemas)/any/index.mdx +++ b/website/src/routes/api/(schemas)/any/index.mdx @@ -7,6 +7,7 @@ contributors: - morinokami - jasperteo - EltonLobo07 + - muningis --- import { Link } from '@builder.io/qwik-city'; @@ -119,6 +120,7 @@ The following APIs can be combined with `any`. 'ipv6', 'isoDate', 'isoDateTime', + 'isoDuration', 'isoTime', 'isoTimeSecond', 'isoTimestamp', diff --git a/website/src/routes/api/(schemas)/custom/index.mdx b/website/src/routes/api/(schemas)/custom/index.mdx index 31dd4edd9..74fde260b 100644 --- a/website/src/routes/api/(schemas)/custom/index.mdx +++ b/website/src/routes/api/(schemas)/custom/index.mdx @@ -6,6 +6,7 @@ contributors: - fabian-hiller - morinokami - EltonLobo07 + - muningis --- import { ApiList, Property } from '~/components'; @@ -147,6 +148,7 @@ The following APIs can be combined with `custom`. 'ipv6', 'isoDate', 'isoDateTime', + 'isoDuration', 'isoTime', 'isoTimeSecond', 'isoTimestamp', diff --git a/website/src/routes/api/(schemas)/string/index.mdx b/website/src/routes/api/(schemas)/string/index.mdx index 7446e26b0..6370f16f4 100644 --- a/website/src/routes/api/(schemas)/string/index.mdx +++ b/website/src/routes/api/(schemas)/string/index.mdx @@ -8,6 +8,7 @@ contributors: - jasperteo - kazizi55 - EltonLobo07 + - muningis --- import { ApiList, Property } from '~/components'; @@ -165,6 +166,7 @@ The following APIs can be combined with `string`. 'ipv6', 'isoDate', 'isoDateTime', + 'isoDuration', 'isoTime', 'isoTimeSecond', 'isoTimestamp', diff --git a/website/src/routes/api/(schemas)/unknown/index.mdx b/website/src/routes/api/(schemas)/unknown/index.mdx index 20b3ae291..50caa034c 100644 --- a/website/src/routes/api/(schemas)/unknown/index.mdx +++ b/website/src/routes/api/(schemas)/unknown/index.mdx @@ -119,6 +119,7 @@ The following APIs can be combined with `unknown`. 'ipv6', 'isoDate', 'isoDateTime', + 'isoDuration', 'isoTime', 'isoTimeSecond', 'isoTimestamp', diff --git a/website/src/routes/api/(types)/IsoDurationAction/index.mdx b/website/src/routes/api/(types)/IsoDurationAction/index.mdx new file mode 100644 index 000000000..ab97e342e --- /dev/null +++ b/website/src/routes/api/(types)/IsoDurationAction/index.mdx @@ -0,0 +1,28 @@ +--- +title: IsoDurationAction +description: ISO duration action interface. +contributors: + - fabian-hiller + - muningis +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# IsoDurationAction + +ISO duration action interface. + +## Generics + +- `TInput` +- `TMessage` + +## Definition + +- `IsoDurationAction` + - `type` + - `reference` + - `expects` + - `requirement` + - `message` diff --git a/website/src/routes/api/(types)/IsoDurationAction/properties.ts b/website/src/routes/api/(types)/IsoDurationAction/properties.ts new file mode 100644 index 000000000..dd80d4149 --- /dev/null +++ b/website/src/routes/api/(types)/IsoDurationAction/properties.ts @@ -0,0 +1,106 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TInput: { + modifier: 'extends', + type: 'string', + }, + TMessage: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'ErrorMessage', + href: '../ErrorMessage/', + generics: [ + { + type: 'custom', + name: 'IsoDurationIssue', + href: '../IsoDurationIssue/', + generics: [ + { + type: 'custom', + name: 'TInput', + }, + ], + }, + ], + }, + 'undefined', + ], + }, + }, + BaseValidation: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseValidation', + href: '../BaseValidation/', + generics: [ + { + type: 'custom', + name: 'TInput', + }, + { + type: 'custom', + name: 'TInput', + }, + { + type: 'custom', + name: 'IsoDurationIssue', + href: '../IsoDurationIssue/', + generics: [ + { + type: 'custom', + name: 'TInput', + }, + ], + }, + ], + }, + }, + type: { + type: { + type: 'string', + value: 'iso_duration', + }, + }, + reference: { + type: { + type: 'custom', + modifier: 'typeof', + name: 'isoDuration', + href: '../isoDuration/', + }, + }, + expects: { + type: 'null', + }, + requirement: { + type: { + type: 'function', + params: [ + { + name: 'input', + type: { + type: 'custom', + name: 'TInput', + }, + }, + ], + return: { + type: 'custom', + name: 'MaybePromise', + generics: ['boolean'], + }, + }, + }, + message: { + type: { + type: 'custom', + name: 'TMessage', + }, + }, +}; diff --git a/website/src/routes/api/(types)/IsoDurationIssue/index.mdx b/website/src/routes/api/(types)/IsoDurationIssue/index.mdx new file mode 100644 index 000000000..57bab9527 --- /dev/null +++ b/website/src/routes/api/(types)/IsoDurationIssue/index.mdx @@ -0,0 +1,26 @@ +--- +title: IsoDateTimeIssue +description: ISO date time issue interface. +contributors: + - fabian-hiller +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# IsoDateTimeIssue + +ISO date time issue interface. + +## Generics + +- `TInput` + +## Definition + +- `IsoDateTimeIssue` + - `kind` + - `type` + - `expected` + - `received` + - `requirement` diff --git a/website/src/routes/api/(types)/IsoDurationIssue/properties.ts b/website/src/routes/api/(types)/IsoDurationIssue/properties.ts new file mode 100644 index 000000000..6f132d40c --- /dev/null +++ b/website/src/routes/api/(types)/IsoDurationIssue/properties.ts @@ -0,0 +1,72 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TInput: { + modifier: 'extends', + type: 'string', + }, + BaseIssue: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: [ + { + type: 'custom', + name: 'TInput', + }, + ], + }, + }, + kind: { + type: { + type: 'string', + value: 'validation', + }, + }, + type: { + type: { + type: 'string', + value: 'iso_duration', + }, + }, + expected: { + type: 'null', + }, + received: { + type: { + type: 'template', + parts: [ + { + type: 'string', + value: '"', + }, + 'string', + { + type: 'string', + value: '"', + }, + ], + }, + }, + requirement: { + type: { + type: 'function', + params: [ + { + name: 'input', + type: { + type: 'custom', + name: 'TInput', + }, + }, + ], + return: { + type: 'custom', + name: 'MaybePromise', + generics: ['boolean'], + }, + }, + }, +}; diff --git a/website/src/routes/api/menu.md b/website/src/routes/api/menu.md index a95aa5110..27be53e60 100644 --- a/website/src/routes/api/menu.md +++ b/website/src/routes/api/menu.md @@ -109,6 +109,7 @@ - [ipv6](/api/ipv6/) - [isoDate](/api/isoDate/) - [isoDateTime](/api/isoDateTime/) +- [isoDuration](/api/isoDuration/) - [isoTime](/api/isoTime/) - [isoTimeSecond](/api/isoTimeSecond/) - [isoTimestamp](/api/isoTimestamp/) @@ -425,6 +426,8 @@ - [IsoDateIssue](/api/IsoDateIssue/) - [IsoDateTimeAction](/api/IsoDateTimeAction/) - [IsoDateTimeIssue](/api/IsoDateTimeIssue/) +- [IsoDurationAction](/api/IsoDurationAction/) +- [IsoDurationIssue](/api/IsoDurationIssue/) - [IsoTimeAction](/api/IsoTimeAction/) - [IsoTimeIssue](/api/IsoTimeIssue/) - [IsoTimeSecondAction](/api/IsoTimeSecondAction/)