From 6e6995b60a5d79d1dd69c8a7bccbcc2fdc980b28 Mon Sep 17 00:00:00 2001 From: Sascha Timme <4854317+saschatimme@users.noreply.github.com> Date: Wed, 20 Mar 2024 13:52:16 +0100 Subject: [PATCH] fix: nested entity references (#1) Co-authored-by: Andrew Bastin --- src/__tests__/entityReference.spec.ts | 90 ++++++++++++++++++- src/index.ts | 121 ++++++++++++-------------- 2 files changed, 145 insertions(+), 66 deletions(-) diff --git a/src/__tests__/entityReference.spec.ts b/src/__tests__/entityReference.spec.ts index fb64280..f28bc82 100644 --- a/src/__tests__/entityReference.spec.ts +++ b/src/__tests__/entityReference.spec.ts @@ -13,7 +13,7 @@ const v1_schema = z.object({ z.object({ name: z.string(), value: z.string(), - }), + }) ), }); @@ -33,7 +33,7 @@ const v2_schema = z.object({ value: z.string(), masked: z.literal(false), }), - ]), + ]) ), }); @@ -192,3 +192,89 @@ describe("entityReference", () => { }); }); }); + +const migrate_child_v1 = z.object({ v: z.literal(1), a: z.number() }); +const migrate_child_v2 = z.object({ v: z.literal(2), b: z.number() }); +const migrateChildVersioned = createVersionedEntity({ + latestVersion: 2, + getVersion(data) { + if (typeof data !== "object" || data === null) { + return null; + } + // @ts-expect-error + return data["v"]; + }, + versionMap: { + 1: defineVersion({ + initial: true, + schema: migrate_child_v1, + }), + 2: defineVersion({ + initial: false, + schema: migrate_child_v2, + up( + old: z.infer + ): z.infer { + return { v: 2, b: old.a }; + }, + }), + }, +}); +const migrateChildSchema = entityReference(migrateChildVersioned); + +const migrate_parent_v1 = z.object({ + v: z.literal(1), + c: z.number(), + child: migrateChildSchema, +}); +const migrate_parent_v2 = z.object({ + v: z.literal(2), + d: z.number(), + child: migrateChildSchema, +}); + +const migrateParentVersioned = createVersionedEntity({ + latestVersion: 2, + getVersion(data) { + if (typeof data !== "object" || data === null) { + return null; + } + // @ts-expect-error + return data["v"]; + }, + versionMap: { + 1: defineVersion({ + initial: true, + schema: migrate_parent_v1, + }), + 2: defineVersion({ + initial: false, + schema: migrate_parent_v2, + up( + old: z.infer + ): z.infer { + return { v: 2, d: old.c, child: old.child }; + }, + }), + }, +}); +const migrateParentSchema = entityReference(migrateParentVersioned); + +describe("nested entityReference", () => { + it("nest migrations should migrate to latest version", () => { + const result = migrateParentSchema.safeParse({ + v: 1, + c: 4, + child: { + v: 1, + a: 8, + }, + }); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data).toEqual({ v: 2, d: 4, child: { v: 2, b: 8 } }); + } + }); +}); diff --git a/src/index.ts b/src/index.ts index e19665c..e91da94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod" /** * Defines a version of a Verzod entity schema and how to upgrade from the previous version. @@ -7,19 +7,19 @@ export type Version = { /** * The schema for this version of the entity. */ - schema: NewScheme; + schema: NewScheme } & ( | { /** * Whether this version is the initial version of the entity. */ - initial: true; + initial: true } | { /** * Whether this version is the initial version of the entity. */ - initial: false; + initial: false /** * Migrate from the previous version of the schema @@ -27,9 +27,9 @@ export type Version = { * * @returns The data as in the new version of the schema */ - up: (old: OldScheme) => z.infer; + up: (old: OldScheme) => z.infer } -); +) /** * A helper function to define a version of a Verzod entity schema @@ -40,18 +40,15 @@ export type Version = { * @param def The version definition */ export const defineVersion = ( - def: Version, -) => def; + def: Version +) => def /** * Extracts the final type from a version definition */ -export type SchemaOf> = T extends Version< - infer S, - any -> +export type SchemaOf> = T extends Version ? z.infer - : never; + : never /** * The definition of a result derived from parsing a Verzod entity. @@ -59,44 +56,44 @@ export type SchemaOf> = T extends Version< export type ParseResult = | { type: "ok"; value: T } | { - type: "err"; + type: "err" error: | { /** * The version of the data was not able to be determined by the entity definition. * Most probably the data is not a valid entity. */ - type: "VER_CHECK_FAIL"; + type: "VER_CHECK_FAIL" } | { /** * The version of the data as determined by the entity definition * is not a valid version as it is not defined in the entity's version map. */ - type: "INVALID_VER"; + type: "INVALID_VER" } | { /** * The data is of a valid version but does not pass * the schema validation for that version. */ - type: "GIVEN_VER_VALIDATION_FAIL"; + type: "GIVEN_VER_VALIDATION_FAIL" /** * The version of the data as determined by the entity definition. */ - version: number; + version: number /** * The definition of the version of the data * corresponding to the determined version */ - versionDef: Version; + versionDef: Version /** * The `ZodError` returned by the schema validation. */ - error: z.ZodError; + error: z.ZodError } | { /** @@ -109,12 +106,12 @@ export type ParseResult = * then this error will be thrown when you try to parse a version 1 data, * as Verzod will try to migrate from 1 to 2 and then from 2 to 3. */ - type: "BUG_NO_INTERMEDIATE_FOUND"; + type: "BUG_NO_INTERMEDIATE_FOUND" /** * The version that is missing from the entity definition. */ - missingVer: number; + missingVer: number } | { /** @@ -123,19 +120,18 @@ export type ParseResult = * has marked an intermediate version as initial and thus * does not have an `up` function to migrate from the previous version. */ - type: "BUG_INTERMEDIATE_MARKED_INITIAL"; + type: "BUG_INTERMEDIATE_MARKED_INITIAL" /** * The version that is marked as initial. */ - ver: number; - }; - }; + ver: number + } + } export class VersionedEntity< LatestVer extends number, - M extends Record> & - Record>, + M extends Record> & Record> > { /** * @package @@ -144,7 +140,7 @@ export class VersionedEntity< constructor( private versionMap: M, private latestVersion: LatestVer, - private getVersion: (data: unknown) => number | null, + private getVersion: (data: unknown) => number | null ) {} /** @@ -153,15 +149,15 @@ export class VersionedEntity< * @returns Whether the given data is a valid entity of any version of the entity. */ public is(data: unknown): data is SchemaOf { - let ver = this.getVersion(data); + let ver = this.getVersion(data) - if (ver === null) return false; + if (ver === null) return false - const verDef = this.versionMap[ver]; + const verDef = this.versionMap[ver] - if (!verDef) return false; + if (!verDef) return false - return verDef.schema.safeParse(data).success; + return verDef.schema.safeParse(data).success } /** @@ -170,7 +166,7 @@ export class VersionedEntity< * @returns Whether the given data is a valid entity of the latest version of the entity. */ public isLatest(data: unknown): data is SchemaOf { - return this.versionMap[this.latestVersion].schema.safeParse(data).success; + return this.versionMap[this.latestVersion].schema.safeParse(data).success } /** @@ -179,19 +175,19 @@ export class VersionedEntity< * @returns The result from parsing data, if successful, older versions are migrated to the latest version */ public safeParse(data: unknown): ParseResult> { - const ver = this.getVersion(data); + const ver = this.getVersion(data) if (ver === null) { - return { type: "err", error: { type: "VER_CHECK_FAIL" } }; + return { type: "err", error: { type: "VER_CHECK_FAIL" } } } - const verDef = this.versionMap[ver]; + const verDef = this.versionMap[ver] if (!verDef) { - return { type: "err", error: { type: "INVALID_VER" } }; + return { type: "err", error: { type: "INVALID_VER" } } } - const pass = verDef.schema.safeParse(data); + const pass = verDef.schema.safeParse(data) if (!pass.success) { return { @@ -202,33 +198,32 @@ export class VersionedEntity< versionDef: verDef, error: pass.error, }, - }; + } } - let finalData = data; + let finalData = pass.data for (let up = ver + 1; up <= this.latestVersion; up++) { - const upDef = this.versionMap[up]; + const upDef = this.versionMap[up] if (!upDef) { return { type: "err", error: { type: "BUG_NO_INTERMEDIATE_FOUND", missingVer: up }, - }; + } } if (upDef.initial) { return { type: "err", error: { type: "BUG_INTERMEDIATE_MARKED_INITIAL", ver: up }, - }; + } } - finalData = upDef.up(finalData); + finalData = upDef.up(finalData) } - // @ts-expect-error - TypeScript cannot understand that the above loop will update finalData to the latest version - return { type: "ok", value: finalData }; + return { type: "ok", value: finalData } } } @@ -239,7 +234,7 @@ export class VersionedEntity< export type InferredEntity> = Entity extends VersionedEntity ? SchemaOf - : never; + : never /** * Provides a union type of all the versions of an entity. @@ -247,7 +242,7 @@ export type InferredEntity> = export type AllSchemasOfEntity> = Entity extends VersionedEntity ? SchemaOf - : never; + : never /** * Creates a Verzod Versioned entity @@ -256,13 +251,13 @@ export type AllSchemasOfEntity> = export function createVersionedEntity< LatestVer extends number, VersionMap extends Record> & - Record>, + Record> >(def: { - versionMap: VersionMap; - latestVersion: LatestVer; - getVersion: (data: unknown) => number | null; + versionMap: VersionMap + latestVersion: LatestVer + getVersion: (data: unknown) => number | null }) { - return new VersionedEntity(def.versionMap, def.latestVersion, def.getVersion); + return new VersionedEntity(def.versionMap, def.latestVersion, def.getVersion) } /** @@ -273,23 +268,21 @@ export function createVersionedEntity< * * NOTE: This assumes the schema has a floating (not dependent) version to the entity. */ -export function entityReference>( - entity: Entity, -) { +export function entityReference>(entity: Entity) { return z .custom>((data) => { - return entity.is(data); + return entity.is(data) }) .transform>((data) => { - const parseResult = entity.safeParse(data); + const parseResult = entity.safeParse(data) if (parseResult.type !== "ok") { // This should never happen unless you have a very weird/bad entity definition. throw new Error( - "Invalid entity definition. `entity.is` returned success, safeParse failed.", - ); + "Invalid entity definition. `entity.is` returned success, safeParse failed." + ) } - return parseResult.value as InferredEntity; - }); + return parseResult.value as InferredEntity + }) }