Skip to content

Commit deabefd

Browse files
committed
Support for object unions and intersections.
1 parent 69c10b6 commit deabefd

File tree

8 files changed

+327
-7
lines changed

8 files changed

+327
-7
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Support for object unions, with implicit default values.
13+
1014
### Fixed
1115

1216
- It wasn't possible to directly assign `undefined` to a field in the `$errors` store.
17+
- Intersections in Zod schemas weren't handled properly.
1318

1419
## [2.7.0] - 2024-03-03
1520

src/lib/jsonSchema/schemaDefaults.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SchemaError } from '$lib/errors.js';
22
import { assertSchema } from '$lib/utils.js';
3+
import { merge } from 'ts-deepmerge';
34
import type { JSONSchema } from './index.js';
45
import { schemaInfo } from './schemaInfo.js';
56
import type { SchemaType } from './schemaInfo.js';
@@ -20,8 +21,8 @@ function _defaultValues(schema: JSONSchema, isOptional: boolean, path: string[])
2021
const info = schemaInfo(schema, isOptional, path);
2122
if (!info) return undefined;
2223

23-
//if (schema.type == 'object') console.log('--- OBJECT ---'); //debug
24-
//else console.dir({ path, schema, isOptional }, { depth: 10 }); //debug
24+
//if (schema.type == 'object') console.log('--- OBJECT ---');
25+
//else console.dir({ path, schema, isOptional }, { depth: 10 });
2526

2627
let objectDefaults: Record<string, unknown> | undefined = undefined;
2728

@@ -69,6 +70,8 @@ function _defaultValues(schema: JSONSchema, isOptional: boolean, path: string[])
6970
return _multiType.size > 1;
7071
};
7172

73+
let output: Record<string, unknown> = {};
74+
7275
// Check unions first, so default values can take precedence over nullable and optional
7376
if (!objectDefaults && info.union) {
7477
const singleDefault = info.union.filter(
@@ -92,6 +95,19 @@ function _defaultValues(schema: JSONSchema, isOptional: boolean, path: string[])
9295
path
9396
);
9497
}
98+
99+
// Objects must have default values to avoid setting undefined properties on nested data
100+
if (info.types[0] == 'object' && info.union.length) {
101+
output =
102+
info.union.length > 1
103+
? merge.withOptions(
104+
{ allowUndefinedOverrides: true },
105+
...info.union.map(
106+
(s) => _defaultValues(s, isOptional, path) as Record<string, unknown>
107+
)
108+
)
109+
: (_defaultValues(info.union[0], isOptional, path) as Record<string, unknown>);
110+
}
95111
}
96112
}
97113

@@ -103,14 +119,13 @@ function _defaultValues(schema: JSONSchema, isOptional: boolean, path: string[])
103119

104120
// Objects
105121
if (info.properties) {
106-
const output: Record<string, unknown> = {};
107122
for (const [key, value] of Object.entries(info.properties)) {
108123
assertSchema(value, [...path, key]);
109124

110125
const def =
111126
objectDefaults && objectDefaults[key] !== undefined
112127
? objectDefaults[key]
113-
: _defaultValues(value, !schema.required?.includes(key), [...path, key]);
128+
: _defaultValues(value, !info.required?.includes(key), [...path, key]);
114129

115130
//if (def !== undefined) output[key] = def;
116131
output[key] = def;

src/lib/jsonSchema/schemaInfo.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { SchemaError } from '$lib/errors.js';
21
import { assertSchema } from '$lib/utils.js';
32
import type { JSONSchema7, JSONSchema7Definition, JSONSchema7TypeName } from 'json-schema';
3+
import { merge } from 'ts-deepmerge';
44

55
export type SchemaType =
66
| JSONSchema7TypeName
@@ -38,7 +38,15 @@ export function schemaInfo(
3838
): SchemaInfo {
3939
assertSchema(schema, path);
4040

41-
if (!path) throw new SchemaError('Why?', path);
41+
if (schema.allOf && schema.allOf.length) {
42+
return {
43+
...merge.withOptions(
44+
{ allowUndefinedOverrides: false },
45+
...schema.allOf.map((s) => schemaInfo(s, false, []))
46+
),
47+
schema
48+
};
49+
}
4250

4351
const types = schemaTypes(schema, path);
4452

src/routes/(v2)/v2/Navigation.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
'unknown-in-schema',
4444
'issue-374',
4545
'issue-366',
46-
'issue-368'
46+
'issue-368',
47+
'zod-discriminated'
4748
].sort();
4849
</script>
4950

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { superValidate, message } from '$lib/index.js';
2+
import { zod } from '$lib/adapters/zod.js';
3+
import { fail } from '@sveltejs/kit';
4+
import { UserProfileZodSchema } from './schema.js';
5+
6+
export const load = async () => {
7+
const form = await superValidate(
8+
{
9+
name: 'Programmer',
10+
11+
},
12+
zod(UserProfileZodSchema)
13+
);
14+
15+
return { form };
16+
};
17+
18+
export const actions = {
19+
default: async ({ request }) => {
20+
const form = await superValidate(request, zod(UserProfileZodSchema));
21+
console.log(form);
22+
23+
if (!form.valid) return fail(400, { form });
24+
25+
return message(form, 'Form posted successfully!');
26+
}
27+
};
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<script lang="ts">
2+
import { page } from '$app/stores';
3+
import { superForm } from '$lib/index.js';
4+
import SuperDebug from '$lib/index.js';
5+
import { ProfileType } from './schema.js';
6+
export let data;
7+
8+
const { form, errors, message, enhance } = superForm(data.form, {
9+
dataType: 'json'
10+
});
11+
</script>
12+
13+
<SuperDebug data={$form} />
14+
15+
<h3>Superforms testing ground - Zod</h3>
16+
17+
{#if $message}
18+
<!-- eslint-disable-next-line svelte/valid-compile -->
19+
<div class="status" class:error={$page.status >= 400} class:success={$page.status == 200}>
20+
{$message}
21+
</div>
22+
{/if}
23+
24+
<form method="POST" use:enhance>
25+
<label>
26+
Name<br />
27+
<input name="name" aria-invalid={$errors.name ? 'true' : undefined} bind:value={$form.name} />
28+
{#if $errors.name}<span class="invalid">{$errors.name}</span>{/if}
29+
</label>
30+
31+
<label>
32+
Email<br />
33+
<input
34+
name="email"
35+
type="email"
36+
aria-invalid={$errors.email ? 'true' : undefined}
37+
bind:value={$form.email}
38+
/>
39+
{#if $errors.email}<span class="invalid">{$errors.email}</span>{/if}
40+
</label>
41+
42+
<label>
43+
Type
44+
<br />
45+
<select bind:value={$form.type}>
46+
<option value={ProfileType.STUDENT}>Student</option>
47+
<option value={ProfileType.FACULTY}>Faculty</option>
48+
<option value={ProfileType.STAFF}>Staff</option>
49+
</select>
50+
</label>
51+
<hr />
52+
53+
{#if $form.type === ProfileType.STUDENT}
54+
<label>
55+
Year of Study<br />
56+
<input name="yearOfStudy" type="number" bind:value={$form.typeData.yearOfStudy} />
57+
</label>
58+
<label>
59+
Branch<br />
60+
<input name="branch" type="text" bind:value={$form.typeData.branch} />
61+
</label>
62+
<label>
63+
Department<br />
64+
<input name="department" type="text" bind:value={$form.typeData.department} />
65+
</label>
66+
<label>
67+
Student ID<br />
68+
<input name="studentId" type="text" bind:value={$form.typeData.studentId} />
69+
</label>
70+
{:else if $form.type === ProfileType.FACULTY}
71+
<label>
72+
Designation<br />
73+
<input name="designation" type="text" bind:value={$form.typeData.designation} />
74+
</label>
75+
<label>
76+
Branch<br />
77+
<input name="branch" type="text" bind:value={$form.typeData.branch} />
78+
</label>
79+
<label>
80+
Department<br />
81+
<input name="department" type="text" bind:value={$form.typeData.department} />
82+
</label>
83+
<label>
84+
Faculty Id<br />
85+
<input name="facultyId" type="text" bind:value={$form.typeData.facultyId} />
86+
</label>
87+
{/if}
88+
89+
<button>Submit</button>
90+
</form>
91+
92+
<style>
93+
.invalid {
94+
color: red;
95+
}
96+
97+
.status {
98+
color: white;
99+
padding: 4px;
100+
padding-left: 8px;
101+
border-radius: 2px;
102+
font-weight: 500;
103+
}
104+
105+
.status.success {
106+
background-color: seagreen;
107+
}
108+
109+
.status.error {
110+
background-color: #ff2a02;
111+
}
112+
113+
input {
114+
background-color: #ddd;
115+
}
116+
117+
a {
118+
text-decoration: underline;
119+
}
120+
121+
hr {
122+
margin-top: 4rem;
123+
}
124+
125+
form {
126+
padding-top: 1rem;
127+
padding-bottom: 1rem;
128+
}
129+
</style>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { z } from 'zod';
2+
3+
export const schema = z.object({
4+
name: z.string().min(1),
5+
email: z.string().email()
6+
});
7+
8+
export enum ProfileType {
9+
STUDENT = 'STUDENT',
10+
FACULTY = 'FACULTY',
11+
STAFF = 'STAFF'
12+
}
13+
14+
const studentZSchema = z.object({
15+
yearOfStudy: z.number().min(1),
16+
branch: z.string().min(2),
17+
department: z.string().min(2),
18+
studentId: z.string().min(2),
19+
clubs: z.array(z.string()).optional()
20+
});
21+
22+
const facultyZSchema = z.object({
23+
department: z.string().min(2),
24+
branch: z.string().min(2),
25+
designation: z.string().min(2),
26+
facultyId: z.string().min(2)
27+
});
28+
29+
const staffZSchema = z.object({
30+
department: z.string().min(2),
31+
branch: z.string().min(2),
32+
designation: z.string().min(2),
33+
staffId: z.string().min(2)
34+
});
35+
36+
const profileSchema = z
37+
.discriminatedUnion('type', [
38+
z.object({
39+
type: z.literal(ProfileType.STUDENT),
40+
typeData: studentZSchema
41+
}),
42+
z.object({
43+
type: z.literal(ProfileType.FACULTY),
44+
typeData: facultyZSchema
45+
}),
46+
z.object({
47+
type: z.literal(ProfileType.STAFF),
48+
typeData: staffZSchema
49+
})
50+
])
51+
.default({
52+
type: ProfileType.STUDENT,
53+
typeData: { yearOfStudy: 1, branch: '', department: '', studentId: '' }
54+
});
55+
56+
export const UserProfileZodSchema = z
57+
.object({
58+
name: z.string().min(2),
59+
email: z.string().email(),
60+
type: z.nativeEnum(ProfileType)
61+
})
62+
.and(profileSchema);

0 commit comments

Comments
 (0)