Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions packages/js-lib/src/zod/zod.shared.schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ describe('z.isoDate', () => {
'2001-W01', // valid ISO 8601 YYYY-Www
'2001W01', // valid ISO 8601 YYYYWww
'2001-01-1', // invalid
'2001-13-01',
'2001-21-01',
'2001-08-32',
'2001-08-32',
]
test.each(invalidCases)('should not accept %s format', date => {
const schema = z.isoDate()
Expand All @@ -152,4 +156,116 @@ describe('z.isoDate', () => {
expect(ajvResult[0]).not.toBeNull()
expect(ajvResult[1]).toBe(date)
})

test('should accept valid dates when `before` param is defined', () => {
const date = '2001-01-01' as IsoDate
const schema = z.isoDate({ before: date })

expect(schema.safeParse('2000-12-30').success).toBe(true)
expect(schema.safeParse('2000-12-31').success).toBe(true)
expect(schema.safeParse('2001-01-01').success).toBe(false)
expect(schema.safeParse('2001-01-02').success).toBe(false)

const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2000-12-31')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-01')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).not.toBeNull()
})

test('should accept valid dates when `sameOrBefore` param is defined', () => {
const date = '2001-01-01' as IsoDate
const schema = z.isoDate({ sameOrBefore: date })

expect(schema.safeParse('2000-12-30').success).toBe(true)
expect(schema.safeParse('2000-12-31').success).toBe(true)
expect(schema.safeParse('2001-01-01').success).toBe(true)
expect(schema.safeParse('2001-01-02').success).toBe(false)

const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2000-12-31')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).not.toBeNull()
})

test('should accept valid dates when `after` param is defined', () => {
const date = '2001-01-01' as IsoDate
const schema = z.isoDate({ after: date })

expect(schema.safeParse('2000-12-30').success).toBe(false)
expect(schema.safeParse('2000-12-31').success).toBe(false)
expect(schema.safeParse('2001-01-01').success).toBe(false)
expect(schema.safeParse('2001-01-02').success).toBe(true)

const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2000-12-31')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2001-01-01')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull()
})

test('should accept valid dates when `sameOrAfter` param is defined', () => {
const date = '2001-01-01' as IsoDate
const schema = z.isoDate({ sameOrAfter: date })

expect(schema.safeParse('2000-12-30').success).toBe(false)
expect(schema.safeParse('2000-12-31').success).toBe(false)
expect(schema.safeParse('2001-01-01').success).toBe(true)
expect(schema.safeParse('2001-01-02').success).toBe(true)

const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2000-12-31')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull()
})

test('should accept valid dates when `between` param is defined with `[]`', () => {
const date1 = '2001-01-01' as IsoDate
const date2 = '2001-01-03' as IsoDate
const schema = z.isoDate({ between: { min: date1, max: date2, incl: '[]' } })

expect(schema.safeParse('2000-12-30').success).toBe(false)
expect(schema.safeParse('2000-12-31').success).toBe(false)
expect(schema.safeParse('2001-01-01').success).toBe(true)
expect(schema.safeParse('2001-01-02').success).toBe(true)
expect(schema.safeParse('2001-01-03').success).toBe(true)
expect(schema.safeParse('2001-01-04').success).toBe(false)

const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2000-12-31')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-03')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-04')[0]).not.toBeNull()
})

test('should accept valid dates when `between` param is defined with `[)`', () => {
const date1 = '2001-01-01' as IsoDate
const date2 = '2001-01-03' as IsoDate
const schema = z.isoDate({ between: { min: date1, max: date2, incl: '[)' } })

expect(schema.safeParse('2000-12-30').success).toBe(false)
expect(schema.safeParse('2000-12-31').success).toBe(false)
expect(schema.safeParse('2001-01-01').success).toBe(true)
expect(schema.safeParse('2001-01-02').success).toBe(true)
expect(schema.safeParse('2001-01-03').success).toBe(false)
expect(schema.safeParse('2001-01-04').success).toBe(false)

const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2000-12-31')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-03')[0]).not.toBeNull()
expect(ajvSchema.getValidationResult('2001-01-04')[0]).not.toBeNull()
})
})
69 changes: 63 additions & 6 deletions packages/js-lib/src/zod/zod.shared.schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { ZodString } from 'zod'
import { z } from 'zod'
import type { IANATimezone, IsoDate, UnixTimestamp, UnixTimestampMillis } from '../types.js'
import { localDate } from '../datetime/localDate.js'
import { _assert } from '../error/assert.js'
import {
_typeCast,
type IANATimezone,
type Inclusiveness,
type IsoDate,
type UnixTimestamp,
type UnixTimestampMillis,
} from '../types.js'

type ZodBranded<T, B> = T & Record<'_zod', Record<'output', B>>
export type ZodBrandedString<B> = ZodBranded<z.ZodString, B>
Expand Down Expand Up @@ -54,11 +63,59 @@ function semVer(): z.ZodString {
.describe('SemVer')
}

function isoDate(): ZodBrandedString<IsoDate> {
return z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, { error: 'Must be a YYYY-MM-DD string' })
.describe('IsoDate') as ZodBrandedString<IsoDate>
export interface CustomZodIsoDateParams {
before?: IsoDate
sameOrBefore?: IsoDate
after?: IsoDate
sameOrAfter?: IsoDate
between?: { min: IsoDate; max: IsoDate; incl: Inclusiveness }
}

export interface JsonSchemaDescriptionParams {
schema: 'isoDate'
params: CustomZodIsoDateParams
}

function isoDate(params: CustomZodIsoDateParams = {}): ZodBrandedString<IsoDate> {
const { before, sameOrBefore, after, sameOrAfter, between } = params

_assert(Object.keys(params).length <= 1, 'Only one condition is allowed in `isoDate()`!')

let error = 'Should be be a YYYY-MM-DD string'
if (after) error = `Should be after ${after}`
if (sameOrAfter) error = `Should be on or after ${sameOrAfter}`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: I prefer else if, which skips evaluating more conditions as soon as it encountered one truthy condition (since they are mutually exclusive)

if (before) error = `Should be before ${before}`
if (sameOrBefore) error = `Should be on or before ${sameOrBefore}`
if (between) {
const { min, max, incl } = between
error = `Should be between ${min} and ${max} (incl: ${incl})`
}

let schema = z.string().refine(
dateString => {
if (!localDate.isValidString(dateString)) return false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note that I'm afraid of the performance implications.
In reality we deal with hundreds/thousands of isoDates per "validation run", so, it's very performance-sensitive code, I'd like to scrutinize it.

For example, we could get away with just string comparisons and not relying on LocalDate api.

isValidString can be instead done via regex?

Also, I'd like a fast-pass for case when there are no parameters

_typeCast<IsoDate>(dateString)

const ld = localDate.fromString(dateString)

if (before) return ld.isBefore(before)
if (sameOrBefore) return ld.isSameOrBefore(sameOrBefore)
if (after) return ld.isAfter(after)
if (sameOrAfter) return ld.isSameOrAfter(sameOrAfter)
if (between) return ld.isBetween(between.min, between.max, between.incl)

return true
},
{ error },
)

// Here we hide the instructions in the description that Ajv will understand
// For some reason, if I add the `.describe()` earlier to support early-return when no conditions are specified,
// then the description is lost. It seems it must be the last call in the call chain.
const description = { schema: 'isoDate', params } satisfies JsonSchemaDescriptionParams
schema = schema.describe(JSON.stringify(description))

return schema as ZodBrandedString<IsoDate>
}

function email(): z.ZodEmail {
Expand Down
153 changes: 152 additions & 1 deletion packages/nodejs-lib/src/validation/ajv/ajvSchema.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { _typeCast } from '@naturalcycles/js-lib/types'
import { _typeCast, type IsoDate } from '@naturalcycles/js-lib/types'
import { z } from '@naturalcycles/js-lib/zod'
import { describe, expect, test } from 'vitest'
import { AjvSchema, HIDDEN_AJV_SCHEMA, type ZodTypeWithAjvSchema } from './ajvSchema.js'
Expand Down Expand Up @@ -35,3 +35,154 @@ describe('createFromZod', () => {
expect(ajvSchemaAgain).toBe(ajvSchema)
})
})

describe('isoDate keyword', () => {
test('should accept 2001-01-01 ISO date format', () => {
const date = '2001-01-01'
const schema = z.isoDate()

const result = schema.parse(date)
expect(result).toBe(date)

const ajvResult = AjvSchema.createFromZod(schema).getValidationResult(date)
expect(ajvResult[0]).toBeNull()
expect(ajvResult[1]).toBe(date)
})

const invalidCases = [
'20010101', // valid ISO 8601 YYYYMMDD
'2001-01', // valid ISO 8601 YYYY-MM
'2001-W01-1', // valid ISO 8601 YYYY-Www-D
'2001W011', // valid ISO 8601 YYYYWwwD
'2001-W01', // valid ISO 8601 YYYY-Www
'2001W01', // valid ISO 8601 YYYYWww
'2001-01-1', // invalid
]
test.each(invalidCases)('should not accept %s format', date => {
const schema = z.isoDate()

const result = schema.safeParse(date)
expect(result.success).toBe(false)

const ajvResult = AjvSchema.createFromZod(schema).getValidationResult(date)
expect(ajvResult[0]).not.toBeNull()
expect(ajvResult[1]).toBe(date)
})

test('should accept valid dates when `before` param is defined', () => {
const date = '2001-01-01' as IsoDate
const schema = z.isoDate({ before: date })
const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2000-12-31')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be before 2001-01-01
Input: 2001-01-01]
`)
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be before 2001-01-01
Input: 2001-01-02]
`)
})

test('should accept valid dates when `sameOrBefore` param is defined', () => {
const date = '2001-01-01' as IsoDate
const schema = z.isoDate({ sameOrBefore: date })
const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2000-12-31')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be on or before 2001-01-01
Input: 2001-01-02]
`)
})

test('should accept valid dates when `after` param is defined', () => {
const date = '2001-01-01' as IsoDate
const schema = z.isoDate({ after: date })
const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be after 2001-01-01
Input: 2000-12-30]
`)
expect(ajvSchema.getValidationResult('2000-12-31')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be after 2001-01-01
Input: 2000-12-31]
`)
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be after 2001-01-01
Input: 2001-01-01]
`)
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull()
})

test('should accept valid dates when `sameOrAfter` param is defined', () => {
const date = '2001-01-01' as IsoDate
const schema = z.isoDate({ sameOrAfter: date })
const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be on or after 2001-01-01
Input: 2000-12-30]
`)
expect(ajvSchema.getValidationResult('2000-12-31')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be on or after 2001-01-01
Input: 2000-12-31]
`)
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull()
})

test('should accept valid dates when `between` param is defined with `[]`', () => {
const date1 = '2001-01-01' as IsoDate
const date2 = '2001-01-03' as IsoDate
const schema = z.isoDate({ between: { min: date1, max: date2, incl: '[]' } })
const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [])
Input: 2000-12-30]
`)
expect(ajvSchema.getValidationResult('2000-12-31')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [])
Input: 2000-12-31]
`)
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-03')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-04')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [])
Input: 2001-01-04]
`)
})

test('should accept valid dates when `between` param is defined with `[)`', () => {
const date1 = '2001-01-01' as IsoDate
const date2 = '2001-01-03' as IsoDate
const schema = z.isoDate({ between: { min: date1, max: date2, incl: '[)' } })
const ajvSchema = AjvSchema.createFromZod(schema)

expect(ajvSchema.getValidationResult('2000-12-30')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [))
Input: 2000-12-30]
`)
expect(ajvSchema.getValidationResult('2000-12-31')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [))
Input: 2000-12-31]
`)
expect(ajvSchema.getValidationResult('2001-01-01')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-02')[0]).toBeNull()
expect(ajvSchema.getValidationResult('2001-01-03')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [))
Input: 2001-01-03]
`)
expect(ajvSchema.getValidationResult('2001-01-04')[0]).toMatchInlineSnapshot(`
[AjvValidationError: Object should be between 2001-01-01 and 2001-01-03 (incl: [))
Input: 2001-01-04]
`)
})
})
Loading