Skip to content

Commit 433210c

Browse files
committed
add helper for convex validators
1 parent 050ad29 commit 433210c

File tree

2 files changed

+169
-10
lines changed

2 files changed

+169
-10
lines changed

packages/convex-helpers/server/validators.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
object,
1717
optional,
1818
union as or,
19+
parse,
1920
pretend,
2021
pretendRequired,
2122
string,
@@ -498,4 +499,102 @@ describe("validate", () => {
498499
expect(validate(any, null)).toBe(true);
499500
expect(validate(any, { complex: "object" })).toBe(true);
500501
});
502+
503+
test("parse strips unknown fields", () => {
504+
const validator = object({
505+
name: string,
506+
age: number,
507+
});
508+
509+
const result = parse(validator, {
510+
name: "Alice",
511+
age: 30,
512+
unknown: "field",
513+
});
514+
expect(result).toEqual({ name: "Alice", age: 30 });
515+
});
516+
517+
test("parse strips unknown fields from unions", () => {
518+
const validator = or(object({ name: string }), object({ age: number }));
519+
const result = parse(validator, {
520+
name: "Alice",
521+
age: 30,
522+
unknown: "field",
523+
});
524+
expect(result).toEqual({ name: "Alice" });
525+
});
526+
527+
test("parse strips unknown fields from arrays", () => {
528+
const validator = array(object({ name: string }));
529+
const result = parse(validator, [
530+
{ name: "Alice" },
531+
{ name: "Bob", unknown: "field" },
532+
]);
533+
expect(result[0]).toMatchObject({ name: "Alice" });
534+
expect(result[1]).toMatchObject({ name: "Bob" });
535+
});
536+
537+
test("parse strips unknown fields from records", () => {
538+
const validator = vv.record(string, object({ name: string }));
539+
const result = parse(validator, {
540+
a: { name: "Alice" },
541+
b: { name: "Bob", unknown: "field" },
542+
});
543+
expect(result).toEqual({ a: { name: "Alice" }, b: { name: "Bob" } });
544+
});
545+
546+
test("parse strips unknown fields from nested objects", () => {
547+
const validator = object({
548+
name: string,
549+
age: number,
550+
details: object({
551+
name: string,
552+
age: number,
553+
}),
554+
union: or(object({ name: string }), object({ age: number })),
555+
array: array(object({ name: string })),
556+
record: vv.record(string, object({ name: string })),
557+
});
558+
const result = parse(validator, {
559+
name: "Alice",
560+
age: 30,
561+
details: { name: "Alice", age: 30 },
562+
union: { name: "Alice", foo: "bar" },
563+
array: [{ name: "Alice", foo: "bar" }],
564+
record: { a: { name: "Alice", foo: "bar" } },
565+
});
566+
expect(result).toEqual({
567+
name: "Alice",
568+
age: 30,
569+
details: { name: "Alice", age: 30 },
570+
union: { name: "Alice" },
571+
array: [{ name: "Alice" }],
572+
record: { a: { name: "Alice" } },
573+
});
574+
});
575+
576+
test("union matches first member with unknown fields", () => {
577+
const validator = or(
578+
object({ name: string }),
579+
object({ name: string, age: number }),
580+
);
581+
const result = parse(validator, {
582+
name: "Alice",
583+
age: 30,
584+
unknown: "field",
585+
});
586+
expect(result).toEqual({ name: "Alice" });
587+
});
588+
589+
test("union matches second member if matches second strictly", () => {
590+
const validator = or(
591+
object({ name: string }),
592+
object({ name: string, age: number }),
593+
);
594+
const result = parse(validator, {
595+
name: "Alice",
596+
age: 30,
597+
});
598+
expect(result).toEqual({ name: "Alice", age: 30 });
599+
});
501600
});

packages/convex-helpers/validators.ts

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
GenericValidator,
3+
Infer,
34
ObjectType,
45
PropertyValidators,
56
VObject,
@@ -363,6 +364,8 @@ export function validate<T extends Validator<any, any, any>>(
363364
throw?: boolean;
364365
/* If provided, v.id validation will check that the id is for the table. */
365366
db?: GenericDatabaseReader<GenericDataModel>;
367+
/* If true, allow fields that are not in an object validator. */
368+
allowUnknownFields?: boolean;
366369
/* A prefix for the path of the value being validated, for error reporting.
367370
This is used for recursive calls, do not set it manually. */
368371
_pathPrefix?: string;
@@ -480,17 +483,19 @@ export function validate<T extends Validator<any, any, any>>(
480483
break;
481484
}
482485
}
483-
for (const k of Object.keys(value)) {
484-
if (validator.fields[k] === undefined) {
485-
if (opts?.throw) {
486-
throw new ValidationError(
487-
"nothing",
488-
typeof (value as any)[k],
489-
appendPath(opts, k),
490-
);
486+
if (!opts?.allowUnknownFields) {
487+
for (const k of Object.keys(value)) {
488+
if (validator.fields[k] === undefined) {
489+
if (opts?.throw) {
490+
throw new ValidationError(
491+
"nothing",
492+
typeof (value as any)[k],
493+
appendPath(opts, k),
494+
);
495+
}
496+
valid = false;
497+
break;
491498
}
492-
valid = false;
493-
break;
494499
}
495500
}
496501
break;
@@ -538,6 +543,61 @@ export function validate<T extends Validator<any, any, any>>(
538543
return valid;
539544
}
540545

546+
export function parse<T extends Validator<any, any, any>>(
547+
validator: T,
548+
value: unknown,
549+
): Infer<T> {
550+
validate(validator, value, { allowUnknownFields: true, throw: true });
551+
return stripUnknownFields(validator, value);
552+
}
553+
554+
function stripUnknownFields<T extends Validator<any, any, any>>(
555+
validator: T,
556+
value: Infer<T>,
557+
): Infer<T> {
558+
switch (validator.kind) {
559+
case "object": {
560+
const result: Infer<T> = {};
561+
for (const [k, v] of Object.entries(value)) {
562+
if (validator.fields[k] !== undefined) {
563+
result[k] = stripUnknownFields(validator.fields[k], v);
564+
}
565+
}
566+
return result;
567+
}
568+
case "record": {
569+
const result: Infer<T> = {};
570+
for (const [k, v] of Object.entries(value)) {
571+
result[k] = stripUnknownFields(validator.value, v);
572+
}
573+
return result;
574+
}
575+
case "array": {
576+
return (value as any[]).map((e) =>
577+
stripUnknownFields(validator.element, e),
578+
);
579+
}
580+
case "union": {
581+
// First try a strict match
582+
for (const member of validator.members) {
583+
if (validate(member, value, { allowUnknownFields: false })) {
584+
return stripUnknownFields(member, value);
585+
}
586+
}
587+
// Then try a permissive match
588+
for (const member of validator.members) {
589+
if (validate(member, value, { allowUnknownFields: true })) {
590+
return stripUnknownFields(member, value);
591+
}
592+
}
593+
throw new Error("No matching member in union");
594+
}
595+
default: {
596+
return value as Infer<T>;
597+
}
598+
}
599+
}
600+
541601
function appendPath(opts: { _pathPrefix?: string } | undefined, path: string) {
542602
return opts?._pathPrefix ? `${opts._pathPrefix}.${path}` : path;
543603
}

0 commit comments

Comments
 (0)