You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

160 lines
4.0 KiB
TypeScript

import { z } from "./deps/zod.ts";
import { autoProxy, unproxy } from "./lib/autoproxy.ts";
import { Const } from "./utils.ts";
import { ValidationErrors } from "./validation.ts";
const fDef = <OptionsSchema extends z.ZodTypeAny>(p: {
optionsSchema: OptionsSchema;
defaultOptions: z.infer<OptionsSchema>;
}) => p;
export const Fields = {
string: fDef({
optionsSchema: z.object({
displayAs: z.union([z.literal("input"), z.literal("textarea")]),
minLength: z.number().min(0),
maxLength: z.number().optional(),
}),
defaultOptions: {
displayAs: "input",
minLength: 0,
},
}),
number: fDef({
optionsSchema: z.object({
min: z.number().optional(),
max: z.number().optional(),
}),
defaultOptions: {},
}),
};
export type FieldOptions = {
[Kind in FieldKind]: z.infer<(typeof Fields)[Kind]["optionsSchema"]>;
};
export type FieldKind = keyof typeof Fields;
export const FieldKinds: readonly FieldKind[] = Object.keys(
Fields,
) as readonly FieldKind[];
export const defaultOptionsForFieldKind = <K extends FieldKind>(
kind: K,
): (typeof Fields)[K]["defaultOptions"] => Fields[kind].defaultOptions;
type ZodField<Kind extends FieldKind> = z.ZodObject<{
name: z.ZodString;
required: z.ZodBoolean;
kind: z.ZodLiteral<Kind>;
options: (typeof Fields)[Kind]["optionsSchema"];
}>;
type AtLeastTwo<V> = [V, V, ...V[]];
type ZodFields = {
[Kind in FieldKind]: ZodField<Kind>;
}[FieldKind];
const makeZodFields = (): AtLeastTwo<ZodFields> => {
const keys = Object.keys(Fields) as FieldKind[];
return keys.map((k) => {
const zf: ZodField<typeof k> = z.object({
name: z.string(),
required: z.boolean(),
kind: z.literal(k),
options: Fields[k].optionsSchema,
});
return zf;
}) as any;
};
export const Field = z.union(makeZodFields());
export type Field = z.infer<typeof Field>;
const SchemaBase = {
fields: z.array(Field),
name: z.string(),
};
export const Schema = z.object(SchemaBase);
export type ID<Brand extends string, T = number> = T & {
readonly [P in Brand]: never;
};
export type RefineID<T, IDT> = Omit<T, "id"> & {
id: IDT;
};
export type SchemaID = ID<"__schema_id">;
export type Schema = RefineID<z.infer<typeof Schema>, SchemaID>;
export const MaybePersistedSchema = z.object({
...SchemaBase,
id: z.number().optional(),
version: z.number().optional(),
});
export type MaybePersistedSchema = z.infer<typeof MaybePersistedSchema>;
export type ValidationErrors_<T> = ValidationErrors<Omit<T, "id">>;
export const validateSchema = (
schema: Const<MaybePersistedSchema>,
): ValidationErrors_<MaybePersistedSchema> => {
const errors = autoProxy<ValidateSchemaResult>({});
const fieldNameMap = new Map<string, { idx: number; count: number }>();
for (let i = 0; i < schema.fields.length; ++i) {
const f = schema.fields[i];
if (!fieldNameMap.has(f.name)) {
fieldNameMap.set(f.name, { idx: i, count: 0 });
}
const ent = fieldNameMap.get(f.name)!;
ent.count++;
ent.idx = i;
}
for (const [, v] of fieldNameMap) {
if (v.count > 1) {
errors.fields[v.idx].name = ["Duplicate field name"];
}
}
for (let i = 0; i < schema.fields.length; ++i) {
const field = schema.fields[i];
if (field.name == "") {
errors.fields[i].name = ["Field name cannot be empty"];
}
const kind = field.kind;
if (kind == "number") {
const options = field.options;
if (
options.max != undefined &&
options.min != undefined &&
options.min >= options.max
) {
(errors.fields[i].options as any).min = [
"Min must be less than or equal to max",
];
}
} else if (kind == "string") {
const options = field.options;
if (
options.maxLength != undefined &&
options.maxLength < options.minLength
) {
(errors.fields[i].options as any).maxLength = [
"Max length must be greater than or equal to min",
];
}
}
}
if (schema.name == "") {
errors.name = ["Name cannot be empty"];
}
// TODO: validate that schemas are named uniquely
return unproxy(errors);
};
export type ValidateSchemaResult = ReturnType<typeof validateSchema>;