@prairielearn/zod 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.cjs +3 -0
- package/.turbo/turbo-build.log +0 -0
- package/CHANGELOG.md +7 -0
- package/README.md +16 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +64 -0
- package/dist/index.test.js.map +1 -0
- package/package.json +42 -0
- package/src/index.test.ts +78 -0
- package/src/index.ts +104 -0
- package/tsconfig.json +8 -0
package/.mocharc.cjs
ADDED
|
File without changes
|
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# `@prairielearn/zod`
|
|
2
|
+
|
|
3
|
+
Useful Zod schemas.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
### `BooleanFromCheckboxSchema`
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { BooleanFromCheckboxSchema } from '@prairielearn/zod';
|
|
11
|
+
|
|
12
|
+
BooleanFromCheckboxSchema.parse(''); // false
|
|
13
|
+
BooleanFromCheckboxSchema.parse('true'); // true
|
|
14
|
+
BooleanFromCheckboxSchema.parse('1'); // true
|
|
15
|
+
BooleanFromCheckboxSchema.parse('on'); // true
|
|
16
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* A Zod schema for a boolean from a single checkbox input in the body
|
|
4
|
+
* parameters from a form. This will return a boolean with a value of `true` if
|
|
5
|
+
* the checkbox is checked (the input is present) and `false` if it is not
|
|
6
|
+
* checked.
|
|
7
|
+
*/
|
|
8
|
+
export declare const BooleanFromCheckboxSchema: z.ZodEffects<z.ZodOptional<z.ZodString>, boolean, string | undefined>;
|
|
9
|
+
/**
|
|
10
|
+
* A Zod schema for a PostgreSQL ID.
|
|
11
|
+
*
|
|
12
|
+
* We store IDs as BIGINT in PostgreSQL, which are passed to JavaScript as
|
|
13
|
+
* either strings (if the ID is fetched directly) or numbers (if passed via
|
|
14
|
+
* `to_jsonb()`). This schema coerces the ID to a string to ensure consistent
|
|
15
|
+
* handling.
|
|
16
|
+
*
|
|
17
|
+
* The `refine` step is important to ensure that the thing we've coerced to a
|
|
18
|
+
* string is actually a number. If it's not, we want to fail quickly.
|
|
19
|
+
*/
|
|
20
|
+
export declare const IdSchema: z.ZodEffects<z.ZodString, string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* A Zod schema for a PostgreSQL interval.
|
|
23
|
+
*
|
|
24
|
+
* This handles two representations of an interval:
|
|
25
|
+
*
|
|
26
|
+
* - A string like "1 year 2 days", which is how intervals will be represented
|
|
27
|
+
* if they go through `to_jsonb` in a query.
|
|
28
|
+
* - A {@link PostgresIntervalSchema} object, which is what we'll get if a
|
|
29
|
+
* query directly returns an interval column. The interval will already be
|
|
30
|
+
* parsed by `postgres-interval` by way of `pg-types`.
|
|
31
|
+
*
|
|
32
|
+
* In either case, we convert the interval to a number of milliseconds.
|
|
33
|
+
*/
|
|
34
|
+
export declare const IntervalSchema: z.ZodEffects<z.ZodUnion<[z.ZodString, z.ZodObject<{
|
|
35
|
+
years: z.ZodDefault<z.ZodNumber>;
|
|
36
|
+
months: z.ZodDefault<z.ZodNumber>;
|
|
37
|
+
days: z.ZodDefault<z.ZodNumber>;
|
|
38
|
+
hours: z.ZodDefault<z.ZodNumber>;
|
|
39
|
+
minutes: z.ZodDefault<z.ZodNumber>;
|
|
40
|
+
seconds: z.ZodDefault<z.ZodNumber>;
|
|
41
|
+
milliseconds: z.ZodDefault<z.ZodNumber>;
|
|
42
|
+
}, "strip", z.ZodTypeAny, {
|
|
43
|
+
years: number;
|
|
44
|
+
months: number;
|
|
45
|
+
days: number;
|
|
46
|
+
hours: number;
|
|
47
|
+
minutes: number;
|
|
48
|
+
seconds: number;
|
|
49
|
+
milliseconds: number;
|
|
50
|
+
}, {
|
|
51
|
+
years?: number | undefined;
|
|
52
|
+
months?: number | undefined;
|
|
53
|
+
days?: number | undefined;
|
|
54
|
+
hours?: number | undefined;
|
|
55
|
+
minutes?: number | undefined;
|
|
56
|
+
seconds?: number | undefined;
|
|
57
|
+
milliseconds?: number | undefined;
|
|
58
|
+
}>]>, number, string | {
|
|
59
|
+
years?: number | undefined;
|
|
60
|
+
months?: number | undefined;
|
|
61
|
+
days?: number | undefined;
|
|
62
|
+
hours?: number | undefined;
|
|
63
|
+
minutes?: number | undefined;
|
|
64
|
+
seconds?: number | undefined;
|
|
65
|
+
milliseconds?: number | undefined;
|
|
66
|
+
}>;
|
|
67
|
+
/**
|
|
68
|
+
* A Zod schema for a date string in ISO format.
|
|
69
|
+
*
|
|
70
|
+
* Accepts either a string or a Date object. If a string is passed, it is
|
|
71
|
+
* validated and parsed as an ISO date string.
|
|
72
|
+
*
|
|
73
|
+
* Useful for parsing dates from JSON, which are always strings.
|
|
74
|
+
*/
|
|
75
|
+
export declare const DateFromISOString: z.ZodEffects<z.ZodEffects<z.ZodUnion<[z.ZodString, z.ZodDate]>, string | Date, string | Date>, Date, string | Date>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import parsePostgresInterval from 'postgres-interval';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const INTERVAL_MS_PER_SECOND = 1000;
|
|
4
|
+
const INTERVAL_MS_PER_MINUTE = 60 * INTERVAL_MS_PER_SECOND;
|
|
5
|
+
const INTERVAL_MS_PER_HOUR = 60 * INTERVAL_MS_PER_MINUTE;
|
|
6
|
+
const INTERVAL_MS_PER_DAY = 24 * INTERVAL_MS_PER_HOUR;
|
|
7
|
+
const INTERVAL_MS_PER_MONTH = 30 * INTERVAL_MS_PER_DAY;
|
|
8
|
+
const INTERVAL_MS_PER_YEAR = 365.25 * INTERVAL_MS_PER_DAY;
|
|
9
|
+
/**
|
|
10
|
+
* A Zod schema for a boolean from a single checkbox input in the body
|
|
11
|
+
* parameters from a form. This will return a boolean with a value of `true` if
|
|
12
|
+
* the checkbox is checked (the input is present) and `false` if it is not
|
|
13
|
+
* checked.
|
|
14
|
+
*/
|
|
15
|
+
export const BooleanFromCheckboxSchema = z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.transform((s) => !!s);
|
|
19
|
+
/**
|
|
20
|
+
* A Zod schema for a PostgreSQL ID.
|
|
21
|
+
*
|
|
22
|
+
* We store IDs as BIGINT in PostgreSQL, which are passed to JavaScript as
|
|
23
|
+
* either strings (if the ID is fetched directly) or numbers (if passed via
|
|
24
|
+
* `to_jsonb()`). This schema coerces the ID to a string to ensure consistent
|
|
25
|
+
* handling.
|
|
26
|
+
*
|
|
27
|
+
* The `refine` step is important to ensure that the thing we've coerced to a
|
|
28
|
+
* string is actually a number. If it's not, we want to fail quickly.
|
|
29
|
+
*/
|
|
30
|
+
export const IdSchema = z
|
|
31
|
+
.string({ coerce: true })
|
|
32
|
+
.refine((val) => /^\d+$/.test(val), { message: 'ID is not a non-negative integer' });
|
|
33
|
+
/**
|
|
34
|
+
* A Zod schema for the objects produced by the `postgres-interval` library.
|
|
35
|
+
*/
|
|
36
|
+
const PostgresIntervalSchema = z.object({
|
|
37
|
+
years: z.number().default(0),
|
|
38
|
+
months: z.number().default(0),
|
|
39
|
+
days: z.number().default(0),
|
|
40
|
+
hours: z.number().default(0),
|
|
41
|
+
minutes: z.number().default(0),
|
|
42
|
+
seconds: z.number().default(0),
|
|
43
|
+
milliseconds: z.number().default(0),
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* A Zod schema for a PostgreSQL interval.
|
|
47
|
+
*
|
|
48
|
+
* This handles two representations of an interval:
|
|
49
|
+
*
|
|
50
|
+
* - A string like "1 year 2 days", which is how intervals will be represented
|
|
51
|
+
* if they go through `to_jsonb` in a query.
|
|
52
|
+
* - A {@link PostgresIntervalSchema} object, which is what we'll get if a
|
|
53
|
+
* query directly returns an interval column. The interval will already be
|
|
54
|
+
* parsed by `postgres-interval` by way of `pg-types`.
|
|
55
|
+
*
|
|
56
|
+
* In either case, we convert the interval to a number of milliseconds.
|
|
57
|
+
*/
|
|
58
|
+
export const IntervalSchema = z
|
|
59
|
+
.union([z.string(), PostgresIntervalSchema])
|
|
60
|
+
.transform((interval) => {
|
|
61
|
+
if (typeof interval === 'string') {
|
|
62
|
+
interval = parsePostgresInterval(interval);
|
|
63
|
+
}
|
|
64
|
+
// This calculation matches Postgres's behavior when computing the number of
|
|
65
|
+
// milliseconds in an interval with `EXTRACT(epoch from '...'::interval) * 1000`.
|
|
66
|
+
// The noteworthy parts of this conversion are that 1 year = 365.25 days and
|
|
67
|
+
// 1 month = 30 days.
|
|
68
|
+
return (interval.years * INTERVAL_MS_PER_YEAR +
|
|
69
|
+
interval.months * INTERVAL_MS_PER_MONTH +
|
|
70
|
+
interval.days * INTERVAL_MS_PER_DAY +
|
|
71
|
+
interval.hours * INTERVAL_MS_PER_HOUR +
|
|
72
|
+
interval.minutes * INTERVAL_MS_PER_MINUTE +
|
|
73
|
+
interval.seconds * INTERVAL_MS_PER_SECOND +
|
|
74
|
+
interval.milliseconds);
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* A Zod schema for a date string in ISO format.
|
|
78
|
+
*
|
|
79
|
+
* Accepts either a string or a Date object. If a string is passed, it is
|
|
80
|
+
* validated and parsed as an ISO date string.
|
|
81
|
+
*
|
|
82
|
+
* Useful for parsing dates from JSON, which are always strings.
|
|
83
|
+
*/
|
|
84
|
+
export const DateFromISOString = z
|
|
85
|
+
.union([z.string(), z.date()])
|
|
86
|
+
.refine((s) => {
|
|
87
|
+
const date = new Date(s);
|
|
88
|
+
return !Number.isNaN(date.getTime());
|
|
89
|
+
}, {
|
|
90
|
+
message: 'must be a valid ISO date string',
|
|
91
|
+
})
|
|
92
|
+
.transform((s) => new Date(s));
|
|
93
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import parsePostgresInterval from 'postgres-interval';\nimport { z } from 'zod';\n\nconst INTERVAL_MS_PER_SECOND = 1000;\nconst INTERVAL_MS_PER_MINUTE = 60 * INTERVAL_MS_PER_SECOND;\nconst INTERVAL_MS_PER_HOUR = 60 * INTERVAL_MS_PER_MINUTE;\nconst INTERVAL_MS_PER_DAY = 24 * INTERVAL_MS_PER_HOUR;\nconst INTERVAL_MS_PER_MONTH = 30 * INTERVAL_MS_PER_DAY;\nconst INTERVAL_MS_PER_YEAR = 365.25 * INTERVAL_MS_PER_DAY;\n\n/**\n * A Zod schema for a boolean from a single checkbox input in the body\n * parameters from a form. This will return a boolean with a value of `true` if\n * the checkbox is checked (the input is present) and `false` if it is not\n * checked.\n */\nexport const BooleanFromCheckboxSchema = z\n .string()\n .optional()\n .transform((s) => !!s);\n\n/**\n * A Zod schema for a PostgreSQL ID.\n *\n * We store IDs as BIGINT in PostgreSQL, which are passed to JavaScript as\n * either strings (if the ID is fetched directly) or numbers (if passed via\n * `to_jsonb()`). This schema coerces the ID to a string to ensure consistent\n * handling.\n *\n * The `refine` step is important to ensure that the thing we've coerced to a\n * string is actually a number. If it's not, we want to fail quickly.\n */\nexport const IdSchema = z\n .string({ coerce: true })\n .refine((val) => /^\\d+$/.test(val), { message: 'ID is not a non-negative integer' });\n\n/**\n * A Zod schema for the objects produced by the `postgres-interval` library.\n */\nconst PostgresIntervalSchema = z.object({\n years: z.number().default(0),\n months: z.number().default(0),\n days: z.number().default(0),\n hours: z.number().default(0),\n minutes: z.number().default(0),\n seconds: z.number().default(0),\n milliseconds: z.number().default(0),\n});\n\n/**\n * A Zod schema for a PostgreSQL interval.\n *\n * This handles two representations of an interval:\n *\n * - A string like \"1 year 2 days\", which is how intervals will be represented\n * if they go through `to_jsonb` in a query.\n * - A {@link PostgresIntervalSchema} object, which is what we'll get if a\n * query directly returns an interval column. The interval will already be\n * parsed by `postgres-interval` by way of `pg-types`.\n *\n * In either case, we convert the interval to a number of milliseconds.\n */\nexport const IntervalSchema = z\n .union([z.string(), PostgresIntervalSchema])\n .transform((interval) => {\n if (typeof interval === 'string') {\n interval = parsePostgresInterval(interval);\n }\n\n // This calculation matches Postgres's behavior when computing the number of\n // milliseconds in an interval with `EXTRACT(epoch from '...'::interval) * 1000`.\n // The noteworthy parts of this conversion are that 1 year = 365.25 days and\n // 1 month = 30 days.\n return (\n interval.years * INTERVAL_MS_PER_YEAR +\n interval.months * INTERVAL_MS_PER_MONTH +\n interval.days * INTERVAL_MS_PER_DAY +\n interval.hours * INTERVAL_MS_PER_HOUR +\n interval.minutes * INTERVAL_MS_PER_MINUTE +\n interval.seconds * INTERVAL_MS_PER_SECOND +\n interval.milliseconds\n );\n });\n\n/**\n * A Zod schema for a date string in ISO format.\n *\n * Accepts either a string or a Date object. If a string is passed, it is\n * validated and parsed as an ISO date string.\n *\n * Useful for parsing dates from JSON, which are always strings.\n */\nexport const DateFromISOString = z\n .union([z.string(), z.date()])\n .refine(\n (s) => {\n const date = new Date(s);\n return !Number.isNaN(date.getTime());\n },\n {\n message: 'must be a valid ISO date string',\n },\n )\n .transform((s) => new Date(s));\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { assert } from 'chai';
|
|
2
|
+
import parsePostgresInterval from 'postgres-interval';
|
|
3
|
+
import { IdSchema, IntervalSchema } from './index.js';
|
|
4
|
+
describe('IdSchema', () => {
|
|
5
|
+
it('parses a valid id', () => {
|
|
6
|
+
const id = IdSchema.parse('123');
|
|
7
|
+
assert.equal(id, '123');
|
|
8
|
+
});
|
|
9
|
+
it('parses a nullable id', () => {
|
|
10
|
+
const id = IdSchema.nullable().parse(null);
|
|
11
|
+
assert.equal(id, null);
|
|
12
|
+
});
|
|
13
|
+
it('parses an optional id', () => {
|
|
14
|
+
const id = IdSchema.optional().parse(undefined);
|
|
15
|
+
assert.equal(id, undefined);
|
|
16
|
+
});
|
|
17
|
+
it('rejects a negative ID', () => {
|
|
18
|
+
const result = IdSchema.safeParse('-1');
|
|
19
|
+
assert.isFalse(result.success);
|
|
20
|
+
});
|
|
21
|
+
it('rejects a non-numeric ID', () => {
|
|
22
|
+
const result = IdSchema.safeParse('abc');
|
|
23
|
+
assert.isFalse(result.success);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('IntervalSchema', () => {
|
|
27
|
+
it('handles a PostgresInterval object', () => {
|
|
28
|
+
const interval = IntervalSchema.parse(parsePostgresInterval('1 year 2 months 3 days'));
|
|
29
|
+
assert.equal(interval, 37000800000);
|
|
30
|
+
});
|
|
31
|
+
it('parses an interval with date', () => {
|
|
32
|
+
const interval = IntervalSchema.parse('1 year 2 months 3 days');
|
|
33
|
+
assert.equal(interval, 37000800000);
|
|
34
|
+
});
|
|
35
|
+
it('parses an interval with time', () => {
|
|
36
|
+
const interval = IntervalSchema.parse('04:05:06.7');
|
|
37
|
+
assert.equal(interval, 14706700);
|
|
38
|
+
});
|
|
39
|
+
it('parses an interval with microsecond-precision time', () => {
|
|
40
|
+
const interval = IntervalSchema.parse('01:02:03.456789');
|
|
41
|
+
assert.equal(interval, 3723456.789);
|
|
42
|
+
});
|
|
43
|
+
it('parses a complex interval', () => {
|
|
44
|
+
const interval = IntervalSchema.parse('1 years 2 mons 3 days 04:05:06.789');
|
|
45
|
+
assert.equal(interval, 37015506789);
|
|
46
|
+
});
|
|
47
|
+
it('parses interval with negative months', () => {
|
|
48
|
+
const interval = IntervalSchema.parse('-10 mons 3 days 04:05:06.789');
|
|
49
|
+
assert.equal(interval, -25646093211);
|
|
50
|
+
});
|
|
51
|
+
it('parses interval with negative years and months', () => {
|
|
52
|
+
const interval = IntervalSchema.parse('-1 years -2 months 3 days 04:05:06.789');
|
|
53
|
+
assert.equal(interval, -36467693211);
|
|
54
|
+
});
|
|
55
|
+
it('parses interval with negative years, months, and days', () => {
|
|
56
|
+
const interval = IntervalSchema.parse('-1 years -2 months -3 days 04:05:06.789');
|
|
57
|
+
assert.equal(interval, -36986093211);
|
|
58
|
+
});
|
|
59
|
+
it('parses a negative interval', () => {
|
|
60
|
+
const interval = IntervalSchema.parse('-1 years -2 months -3 days -04:05:06.789');
|
|
61
|
+
assert.equal(interval, -37015506789);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
//# sourceMappingURL=index.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { assert } from 'chai';\nimport parsePostgresInterval from 'postgres-interval';\n\nimport { IdSchema, IntervalSchema } from './index.js';\n\ndescribe('IdSchema', () => {\n it('parses a valid id', () => {\n const id = IdSchema.parse('123');\n assert.equal(id, '123');\n });\n\n it('parses a nullable id', () => {\n const id = IdSchema.nullable().parse(null);\n assert.equal(id, null);\n });\n\n it('parses an optional id', () => {\n const id = IdSchema.optional().parse(undefined);\n assert.equal(id, undefined);\n });\n\n it('rejects a negative ID', () => {\n const result = IdSchema.safeParse('-1');\n assert.isFalse(result.success);\n });\n\n it('rejects a non-numeric ID', () => {\n const result = IdSchema.safeParse('abc');\n assert.isFalse(result.success);\n });\n});\n\ndescribe('IntervalSchema', () => {\n it('handles a PostgresInterval object', () => {\n const interval = IntervalSchema.parse(parsePostgresInterval('1 year 2 months 3 days'));\n assert.equal(interval, 37000800000);\n });\n\n it('parses an interval with date', () => {\n const interval = IntervalSchema.parse('1 year 2 months 3 days');\n assert.equal(interval, 37000800000);\n });\n\n it('parses an interval with time', () => {\n const interval = IntervalSchema.parse('04:05:06.7');\n assert.equal(interval, 14706700);\n });\n\n it('parses an interval with microsecond-precision time', () => {\n const interval = IntervalSchema.parse('01:02:03.456789');\n assert.equal(interval, 3723456.789);\n });\n\n it('parses a complex interval', () => {\n const interval = IntervalSchema.parse('1 years 2 mons 3 days 04:05:06.789');\n assert.equal(interval, 37015506789);\n });\n\n it('parses interval with negative months', () => {\n const interval = IntervalSchema.parse('-10 mons 3 days 04:05:06.789');\n assert.equal(interval, -25646093211);\n });\n\n it('parses interval with negative years and months', () => {\n const interval = IntervalSchema.parse('-1 years -2 months 3 days 04:05:06.789');\n assert.equal(interval, -36467693211);\n });\n\n it('parses interval with negative years, months, and days', () => {\n const interval = IntervalSchema.parse('-1 years -2 months -3 days 04:05:06.789');\n assert.equal(interval, -36986093211);\n });\n\n it('parses a negative interval', () => {\n const interval = IntervalSchema.parse('-1 years -2 months -3 days -04:05:06.789');\n assert.equal(interval, -37015506789);\n });\n});\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prairielearn/zod",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/PrairieLearn/PrairieLearn.git",
|
|
9
|
+
"directory": "packages/zod"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"dev": "tsc --watch --preserveWatchOutput",
|
|
14
|
+
"test": "c8 mocha src/**/*.test.ts"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@prairielearn/tsconfig": "^0.0.0",
|
|
18
|
+
"@types/chai": "^5.0.1",
|
|
19
|
+
"@types/mocha": "^10.0.10",
|
|
20
|
+
"@types/node": "^20.17.16",
|
|
21
|
+
"c8": "^10.1.3",
|
|
22
|
+
"chai": "^5.1.2",
|
|
23
|
+
"mocha": "^10.8.2",
|
|
24
|
+
"tsx": "^4.19.2",
|
|
25
|
+
"typescript": "^5.7.3"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"postgres-interval": "^4.0.2",
|
|
29
|
+
"zod": "^3.24.1"
|
|
30
|
+
},
|
|
31
|
+
"c8": {
|
|
32
|
+
"reporter": [
|
|
33
|
+
"html",
|
|
34
|
+
"text-summary",
|
|
35
|
+
"cobertura"
|
|
36
|
+
],
|
|
37
|
+
"all": true,
|
|
38
|
+
"include": [
|
|
39
|
+
"src/**"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { assert } from 'chai';
|
|
2
|
+
import parsePostgresInterval from 'postgres-interval';
|
|
3
|
+
|
|
4
|
+
import { IdSchema, IntervalSchema } from './index.js';
|
|
5
|
+
|
|
6
|
+
describe('IdSchema', () => {
|
|
7
|
+
it('parses a valid id', () => {
|
|
8
|
+
const id = IdSchema.parse('123');
|
|
9
|
+
assert.equal(id, '123');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('parses a nullable id', () => {
|
|
13
|
+
const id = IdSchema.nullable().parse(null);
|
|
14
|
+
assert.equal(id, null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('parses an optional id', () => {
|
|
18
|
+
const id = IdSchema.optional().parse(undefined);
|
|
19
|
+
assert.equal(id, undefined);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects a negative ID', () => {
|
|
23
|
+
const result = IdSchema.safeParse('-1');
|
|
24
|
+
assert.isFalse(result.success);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects a non-numeric ID', () => {
|
|
28
|
+
const result = IdSchema.safeParse('abc');
|
|
29
|
+
assert.isFalse(result.success);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('IntervalSchema', () => {
|
|
34
|
+
it('handles a PostgresInterval object', () => {
|
|
35
|
+
const interval = IntervalSchema.parse(parsePostgresInterval('1 year 2 months 3 days'));
|
|
36
|
+
assert.equal(interval, 37000800000);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('parses an interval with date', () => {
|
|
40
|
+
const interval = IntervalSchema.parse('1 year 2 months 3 days');
|
|
41
|
+
assert.equal(interval, 37000800000);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('parses an interval with time', () => {
|
|
45
|
+
const interval = IntervalSchema.parse('04:05:06.7');
|
|
46
|
+
assert.equal(interval, 14706700);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('parses an interval with microsecond-precision time', () => {
|
|
50
|
+
const interval = IntervalSchema.parse('01:02:03.456789');
|
|
51
|
+
assert.equal(interval, 3723456.789);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('parses a complex interval', () => {
|
|
55
|
+
const interval = IntervalSchema.parse('1 years 2 mons 3 days 04:05:06.789');
|
|
56
|
+
assert.equal(interval, 37015506789);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('parses interval with negative months', () => {
|
|
60
|
+
const interval = IntervalSchema.parse('-10 mons 3 days 04:05:06.789');
|
|
61
|
+
assert.equal(interval, -25646093211);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('parses interval with negative years and months', () => {
|
|
65
|
+
const interval = IntervalSchema.parse('-1 years -2 months 3 days 04:05:06.789');
|
|
66
|
+
assert.equal(interval, -36467693211);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('parses interval with negative years, months, and days', () => {
|
|
70
|
+
const interval = IntervalSchema.parse('-1 years -2 months -3 days 04:05:06.789');
|
|
71
|
+
assert.equal(interval, -36986093211);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('parses a negative interval', () => {
|
|
75
|
+
const interval = IntervalSchema.parse('-1 years -2 months -3 days -04:05:06.789');
|
|
76
|
+
assert.equal(interval, -37015506789);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import parsePostgresInterval from 'postgres-interval';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const INTERVAL_MS_PER_SECOND = 1000;
|
|
5
|
+
const INTERVAL_MS_PER_MINUTE = 60 * INTERVAL_MS_PER_SECOND;
|
|
6
|
+
const INTERVAL_MS_PER_HOUR = 60 * INTERVAL_MS_PER_MINUTE;
|
|
7
|
+
const INTERVAL_MS_PER_DAY = 24 * INTERVAL_MS_PER_HOUR;
|
|
8
|
+
const INTERVAL_MS_PER_MONTH = 30 * INTERVAL_MS_PER_DAY;
|
|
9
|
+
const INTERVAL_MS_PER_YEAR = 365.25 * INTERVAL_MS_PER_DAY;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A Zod schema for a boolean from a single checkbox input in the body
|
|
13
|
+
* parameters from a form. This will return a boolean with a value of `true` if
|
|
14
|
+
* the checkbox is checked (the input is present) and `false` if it is not
|
|
15
|
+
* checked.
|
|
16
|
+
*/
|
|
17
|
+
export const BooleanFromCheckboxSchema = z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.transform((s) => !!s);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A Zod schema for a PostgreSQL ID.
|
|
24
|
+
*
|
|
25
|
+
* We store IDs as BIGINT in PostgreSQL, which are passed to JavaScript as
|
|
26
|
+
* either strings (if the ID is fetched directly) or numbers (if passed via
|
|
27
|
+
* `to_jsonb()`). This schema coerces the ID to a string to ensure consistent
|
|
28
|
+
* handling.
|
|
29
|
+
*
|
|
30
|
+
* The `refine` step is important to ensure that the thing we've coerced to a
|
|
31
|
+
* string is actually a number. If it's not, we want to fail quickly.
|
|
32
|
+
*/
|
|
33
|
+
export const IdSchema = z
|
|
34
|
+
.string({ coerce: true })
|
|
35
|
+
.refine((val) => /^\d+$/.test(val), { message: 'ID is not a non-negative integer' });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A Zod schema for the objects produced by the `postgres-interval` library.
|
|
39
|
+
*/
|
|
40
|
+
const PostgresIntervalSchema = z.object({
|
|
41
|
+
years: z.number().default(0),
|
|
42
|
+
months: z.number().default(0),
|
|
43
|
+
days: z.number().default(0),
|
|
44
|
+
hours: z.number().default(0),
|
|
45
|
+
minutes: z.number().default(0),
|
|
46
|
+
seconds: z.number().default(0),
|
|
47
|
+
milliseconds: z.number().default(0),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A Zod schema for a PostgreSQL interval.
|
|
52
|
+
*
|
|
53
|
+
* This handles two representations of an interval:
|
|
54
|
+
*
|
|
55
|
+
* - A string like "1 year 2 days", which is how intervals will be represented
|
|
56
|
+
* if they go through `to_jsonb` in a query.
|
|
57
|
+
* - A {@link PostgresIntervalSchema} object, which is what we'll get if a
|
|
58
|
+
* query directly returns an interval column. The interval will already be
|
|
59
|
+
* parsed by `postgres-interval` by way of `pg-types`.
|
|
60
|
+
*
|
|
61
|
+
* In either case, we convert the interval to a number of milliseconds.
|
|
62
|
+
*/
|
|
63
|
+
export const IntervalSchema = z
|
|
64
|
+
.union([z.string(), PostgresIntervalSchema])
|
|
65
|
+
.transform((interval) => {
|
|
66
|
+
if (typeof interval === 'string') {
|
|
67
|
+
interval = parsePostgresInterval(interval);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// This calculation matches Postgres's behavior when computing the number of
|
|
71
|
+
// milliseconds in an interval with `EXTRACT(epoch from '...'::interval) * 1000`.
|
|
72
|
+
// The noteworthy parts of this conversion are that 1 year = 365.25 days and
|
|
73
|
+
// 1 month = 30 days.
|
|
74
|
+
return (
|
|
75
|
+
interval.years * INTERVAL_MS_PER_YEAR +
|
|
76
|
+
interval.months * INTERVAL_MS_PER_MONTH +
|
|
77
|
+
interval.days * INTERVAL_MS_PER_DAY +
|
|
78
|
+
interval.hours * INTERVAL_MS_PER_HOUR +
|
|
79
|
+
interval.minutes * INTERVAL_MS_PER_MINUTE +
|
|
80
|
+
interval.seconds * INTERVAL_MS_PER_SECOND +
|
|
81
|
+
interval.milliseconds
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A Zod schema for a date string in ISO format.
|
|
87
|
+
*
|
|
88
|
+
* Accepts either a string or a Date object. If a string is passed, it is
|
|
89
|
+
* validated and parsed as an ISO date string.
|
|
90
|
+
*
|
|
91
|
+
* Useful for parsing dates from JSON, which are always strings.
|
|
92
|
+
*/
|
|
93
|
+
export const DateFromISOString = z
|
|
94
|
+
.union([z.string(), z.date()])
|
|
95
|
+
.refine(
|
|
96
|
+
(s) => {
|
|
97
|
+
const date = new Date(s);
|
|
98
|
+
return !Number.isNaN(date.getTime());
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
message: 'must be a valid ISO date string',
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
.transform((s) => new Date(s));
|