diff --git a/README.md b/README.md index e61dfb6..5197fd1 100644 --- a/README.md +++ b/README.md @@ -35,26 +35,46 @@ model DualId { } ``` -Then when initializing your PrismaClient, extend it with the `cuid2` middleware: +Then when initializing your PrismaClient, extend it with the `cuid2` middleware and provide the fields you want to +use CUID2 for: ```typescript import { PrismaClient } from '@prisma/client' import cuid2Extension from 'prisma-extension-cuid2' -const prisma = new PrismaClient().$extend(cuid2Extension()) +const prisma = new PrismaClient().$extend(cuid2Extension({ + fields: ['SingleId:id', 'DualId:id1', 'DualId:id2'] +})) export default prisma ``` +By default if you don't specify the `fields` or `includeFields` options, the extension will use the `*:id` pattern to +apply the extension which can cause issues, see the options section for more information. + + ## Options +### `fields` _(recommended)_ + +Specify the fields to apply the extension to. This option takes in an array of `ModelName:FieldName` strings. This is +the recommended way to use the extension, as it provides the most safety and control. + +```typescript +cuid2Extension({ + fields: ['SingleId:id', 'DualId:id1', 'DualId:id2'] +}) +``` + ### `includeFields` and `excludeFields` -By default, the extension will apply to all fields with the name of `id` in your schema. If you want to customize which -fields the extension applies to, you can use the `includeFields` and `excludeFields` options. Both options take in an -array of `ModelName:FieldName` strings, The `includeFields` supports `*` as a wildcard for model names and -`excludeFields` supports `*` as a wildcard for field names. +If your schema is large and has a fairly standard format for models, you can use the `includeFields` and `excludeFields` +options instead of specifying each field individually. These options take in an array of `ModelName:FieldName` strings, +with `includeFields` supporting wildcard model names and `excludeFields` supporting wildcard field names. +**DANGER:** Due to how Prisma generates code, this extension does not have a way to know which fields are on any given +model. The extension will attempt to set the include fields on every model that matches regardless of whether the field +exists. This will cause runtime errors if you are not careful. ```typescript // Changing the default field name from `id` to `cuid` cuid2Extension({ diff --git a/package.json b/package.json index 9571620..6d346ca 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ }, "dependencies": { "@paralleldrive/cuid2": "^2.2.2", - "immer": "^10.0.3" + "immer": "^10.0.3", + "zod": "^3.22.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e05e1cf..126bd08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: immer: specifier: ^10.0.3 version: 10.0.3 + zod: + specifier: ^3.22.4 + version: 3.22.4 devDependencies: '@eslint/js': @@ -5015,3 +5018,7 @@ packages: optionalDependencies: commander: 9.5.0 dev: true + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + dev: false diff --git a/src/cuid2-extension.spec.ts b/src/cuid2-extension.spec.ts index 76d3be7..3ee539a 100644 --- a/src/cuid2-extension.spec.ts +++ b/src/cuid2-extension.spec.ts @@ -1,41 +1,117 @@ import { expect, test } from "vitest"; import cuid2Extension, { Cuid2ExtensionOptions } from "./cuid2-extension"; +import { ExcludeField, Field, IncludeField } from "./valid-fields"; test("cuid2Extension returns an extension", () => { const extension = cuid2Extension(); expect(extension).toBeTypeOf("function"); }); +test("cuid2Extension throws error when fields is not in correct format", () => { + const options: Cuid2ExtensionOptions = { + fields: ["invalidFormat"] as unknown as Field[], + }; + expect(() => cuid2Extension(options)).toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "validation": "regex", + "code": "invalid_string", + "message": "Invalid", + "path": [ + "fields", + 0 + ] + } + ]] + `); +}); + +test("cuid2Extension throws error when using fields and includeFields", () => { + const options: Cuid2ExtensionOptions = { + fields: ["Model:Field"] as unknown as Field[], + includeFields: ["Model:Field"] as unknown as IncludeField[], + }; + expect(() => cuid2Extension(options)).toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot provide both \`fields\` and \`includeFields\`/\`excludeFields\` options.]`, + ); +}); + test("cuid2Extension throws error when includeFields is not provided", () => { const options: Cuid2ExtensionOptions = { includeFields: undefined, }; - expect(() => cuid2Extension(options)).toThrow("You must provide the `includeFields` option."); + expect(() => cuid2Extension(options)).toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "array", + "received": "undefined", + "path": [ + "includeFields" + ], + "message": "Required" + } + ]] + `); }); test("cuid2Extension throws error when includeFields is does not have at least on item", () => { const options: Cuid2ExtensionOptions = { includeFields: [], }; - expect(() => cuid2Extension(options)).toThrow("You must provide at least one field in the `includeFields` option."); + expect(() => cuid2Extension(options)).toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "too_small", + "minimum": 1, + "type": "array", + "inclusive": true, + "exact": false, + "message": "Array must contain at least 1 element(s)", + "path": [ + "includeFields" + ] + } + ]] + `); }); test("cuid2Extension throws error when includeFields is not in correct format", () => { const options: Cuid2ExtensionOptions = { - includeFields: ["invalidFormat"], + includeFields: ["invalidFormat"] as unknown as IncludeField[], }; - expect(() => cuid2Extension(options)).toThrow( - "The `includeFields` option must be in the format of `ModelName:FieldName`.", - ); + expect(() => cuid2Extension(options)).toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "validation": "regex", + "code": "invalid_string", + "message": "Invalid", + "path": [ + "includeFields", + 0 + ] + } + ]] + `); }); test("cuid2Extension throws error when excludeFields is not in correct format", () => { const options: Cuid2ExtensionOptions = { - includeFields: ["Model:Field"], - excludeFields: ["invalidFormat"], + includeFields: ["Model:Field"] as unknown as IncludeField[], + excludeFields: ["invalidFormat"] as unknown as ExcludeField[], }; - expect(() => cuid2Extension(options)).toThrow( - "The `excludeFields` option must be in the format of `ModelName:FieldName`.", - ); + expect(() => cuid2Extension(options)).toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "validation": "regex", + "code": "invalid_string", + "message": "Invalid", + "path": [ + "excludeFields", + 0 + ] + } + ]] + `); }); diff --git a/src/cuid2-extension.ts b/src/cuid2-extension.ts index 5661b23..b47e467 100644 --- a/src/cuid2-extension.ts +++ b/src/cuid2-extension.ts @@ -2,14 +2,35 @@ import { init } from "@paralleldrive/cuid2"; import { Prisma } from "@prisma/client"; import { produce } from "immer"; -import getFieldsFactory from "./get-fields-factory"; +import getExactFieldsFactory from "./factories/get-exact-fields-factory"; +import { type GetFieldsFunction } from "./factories/get-fields-function"; +import getWildcardFieldsFactory from "./factories/get-wildcard-fields-factory"; +import { type ExcludeField, type Field, type IncludeField } from "./valid-fields"; +import { exactValidator, wildcardValidator } from "./validators"; -const FIELD_REGEX = /^([^:]+):([^:]+)$/; const OPERATIONS = ["create", "createMany", "upsert"]; type Cuid2InitOptions = Parameters[0]; -export type Cuid2ExtensionOptions = { +export type Cuid2ExtensionOptionsBase = { + /** + * This allows you to customize the CUID2 generation. + * + * A useful option is to set the `fingerprint` to a unique value for your application. + * + * @example + * const cuid2 = cuid2Extension({ + * cuid2Options: { + * fingerprint: process.env.DEVICE_ID + * } + * }) + * + * @see https://github.com/paralleldrive/cuid2?tab=readme-ov-file#configuration + */ + cuid2Options?: Cuid2InitOptions; +}; + +type Cuid2ExtensionOptionsWildcard = { /** * The fields to automatically set the CUID2 value on. * @@ -20,7 +41,7 @@ export type Cuid2ExtensionOptions = { * * @default ["*:id"] */ - includeFields?: string[]; + includeFields?: IncludeField[]; /** * The fields to exclude from being automatically set the CUID2 value on. @@ -33,54 +54,39 @@ export type Cuid2ExtensionOptions = { * @example ["User:id"] * @example ["Post:*"] */ - excludeFields?: string[]; + excludeFields?: ExcludeField[]; +}; +type Cuid2ExtensionOptionsExact = { /** - * This allows you to customize the CUID2 generation. + * Requires the exact fields to include for the CUID2 extension. * - * A useful option is to set the `fingerprint` to a unique value for your application. - * - * @example - * const cuid2 = cuid2Extension({ - * cuid2Options: { - * fingerprint: process.env.DEVICE_ID - * } - * }) - * - * @see https://github.com/paralleldrive/cuid2?tab=readme-ov-file#configuration + * This is the recommended way to use the extension as it provides a clear understanding of which fields are being + * affected and supports type safety. */ - cuid2Options?: Cuid2InitOptions; + fields: Field[]; }; -const DEFAULT_OPTIONS: Cuid2ExtensionOptions = { - includeFields: ["*:id"], -}; +export type Cuid2ExtensionOptions = Cuid2ExtensionOptionsBase & + (Cuid2ExtensionOptionsWildcard | Cuid2ExtensionOptionsExact); export default function cuid2Extension(options?: Cuid2ExtensionOptions) { - const mergedOptions = { - ...DEFAULT_OPTIONS, - ...options, - }; - - if (!mergedOptions.includeFields) { - throw new Error("You must provide the `includeFields` option."); + if (options && "fields" in options && ("includeFields" in options || "excludeFields" in options)) { + throw new Error("You cannot provide both `fields` and `includeFields`/`excludeFields` options."); } - if (mergedOptions.includeFields.length === 0) { - throw new Error("You must provide at least one field in the `includeFields` option."); + let getFields: GetFieldsFunction; + if (options === undefined) { + getFields = getWildcardFieldsFactory(["*:id"]); + } else if ("fields" in options) { + const validatedOptions = exactValidator.parse(options); + getFields = getExactFieldsFactory(validatedOptions.fields); + } else { + const validatedOptions = wildcardValidator.parse(options); + getFields = getWildcardFieldsFactory(validatedOptions.includeFields, validatedOptions.excludeFields); } - if (mergedOptions.includeFields.some((applyToField) => !FIELD_REGEX.test(applyToField))) { - throw new Error("The `includeFields` option must be in the format of `ModelName:FieldName`."); - } - - if (mergedOptions.excludeFields && mergedOptions.excludeFields.some((skipField) => !FIELD_REGEX.test(skipField))) { - throw new Error("The `excludeFields` option must be in the format of `ModelName:FieldName`."); - } - - const createId = init(mergedOptions.cuid2Options); - - const getFields = getFieldsFactory(mergedOptions.includeFields, mergedOptions.excludeFields); + const createId = init(options?.cuid2Options); return Prisma.defineExtension({ name: "cuid2", diff --git a/src/factories/get-exact-fields-factory.spec.ts b/src/factories/get-exact-fields-factory.spec.ts new file mode 100644 index 0000000..78fa233 --- /dev/null +++ b/src/factories/get-exact-fields-factory.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; + +import getExactFieldsFactory from "./get-exact-fields-factory"; + +test("getExactFieldsFactory returns a function", () => { + const getFields = getExactFieldsFactory([]); + expect(getFields).toBeTypeOf("function"); +}); + +test("getExactFieldsFactory handles fields correctly", () => { + const getFields = getExactFieldsFactory(["TestModel:field1", "TestModel:field2"]); + const fields = getFields("TestModel"); + expect(fields).toEqual(["field1", "field2"]); +}); + +test("getExactFieldsFactory ignores fields for other models", () => { + const getFields = getExactFieldsFactory(["TestModel:field1", "OtherModel:field2"]); + const fields = getFields("TestModel"); + expect(fields).toEqual(["field1"]); +}); diff --git a/src/factories/get-exact-fields-factory.ts b/src/factories/get-exact-fields-factory.ts new file mode 100644 index 0000000..8e611a1 --- /dev/null +++ b/src/factories/get-exact-fields-factory.ts @@ -0,0 +1,20 @@ +import { type GetFieldsFunction } from "./get-fields-function"; + +/** + * Returns a function that returns the fields to apply to a model + * + * @param fields + */ +export default function getExactFieldsFactory(fields: string[]): GetFieldsFunction { + return (operationModel: string) => { + return fields + .filter((field) => { + const [model] = field.split(":"); + return model === operationModel; + }) + .map((fieldPair) => { + const [, field] = fieldPair.split(":"); + return field; + }); + }; +} diff --git a/src/factories/get-fields-function.ts b/src/factories/get-fields-function.ts new file mode 100644 index 0000000..202156a --- /dev/null +++ b/src/factories/get-fields-function.ts @@ -0,0 +1,4 @@ +/** + * Function that returns the fields on a model to apply the CUID2 extension + */ +export type GetFieldsFunction = (operationModel: string) => string[]; diff --git a/src/factories/get-wildcard-fields-factory.spec.ts b/src/factories/get-wildcard-fields-factory.spec.ts new file mode 100644 index 0000000..d75a3f2 --- /dev/null +++ b/src/factories/get-wildcard-fields-factory.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "vitest"; + +import getWildcardFieldsFactory from "./get-wildcard-fields-factory"; + +test("getWildcardFieldsFactory" + " returns a function", () => { + const getFields = getWildcardFieldsFactory([], []); + expect(getFields).toBeTypeOf("function"); +}); + +test("getWildcardFieldsFactory" + " handles includeFields correctly", () => { + const getFields = getWildcardFieldsFactory(["TestModel:field1", "TestModel:field2"], []); + const fields = getFields("TestModel"); + expect(fields).toEqual(["field1", "field2"]); +}); + +test("getWildcardFieldsFactory" + " handles wildcard in includeFields correctly", () => { + const getFields = getWildcardFieldsFactory(["*:field1", "TestModel:field2"], []); + const fields = getFields("TestModel"); + expect(fields).toEqual(["field1", "field2"]); +}); + +test("getWildcardFieldsFactory" + " handles excludeFields correctly", () => { + const getFields = getWildcardFieldsFactory(["TestModel:field1", "TestModel:field2"], ["TestModel:field1"]); + const fields = getFields("TestModel"); + expect(fields).toEqual(["field2"]); +}); + +test("getWildcardFieldsFactory" + " handles wildcard in excludeFields correctly", () => { + const getFields = getWildcardFieldsFactory(["TestModel:field1", "TestModel:field2"], ["TestModel:*"]); + const fields = getFields("TestModel"); + expect(fields).toEqual([]); +}); diff --git a/src/get-fields-factory.ts b/src/factories/get-wildcard-fields-factory.ts similarity index 77% rename from src/get-fields-factory.ts rename to src/factories/get-wildcard-fields-factory.ts index 5aa2d03..b82e9a7 100644 --- a/src/get-fields-factory.ts +++ b/src/factories/get-wildcard-fields-factory.ts @@ -1,7 +1,4 @@ -/** - * Function that returns the fields on a model to apply the CUID2 extension - */ -export type GetFieldsFunction = (operationModel: string) => string[]; +import { type GetFieldsFunction } from "./get-fields-function"; /** * Returns a function that returns the fields to apply to a model @@ -9,7 +6,10 @@ export type GetFieldsFunction = (operationModel: string) => string[]; * @param includeFields * @param excludeFields */ -export default function getFieldsFactory(includeFields: string[], excludeFields: string[] = []): GetFieldsFunction { +export default function getWildcardFieldsFactory( + includeFields: string[], + excludeFields: string[] = [], +): GetFieldsFunction { return (operationModel: string) => { const includeFieldsForModel = includeFields .filter((includeField) => { diff --git a/src/get-fields-factory.spec.ts b/src/get-fields-factory.spec.ts deleted file mode 100644 index 9d84c63..0000000 --- a/src/get-fields-factory.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { expect, test } from "vitest"; - -import getFieldsFactory from "./get-fields-factory"; - -test("getFieldsFactory returns a function", () => { - const getFields = getFieldsFactory([], []); - expect(getFields).toBeTypeOf("function"); -}); - -test("getFieldsFactory handles includeFields correctly", () => { - const getFields = getFieldsFactory(["TestModel:field1", "TestModel:field2"], []); - const fields = getFields("TestModel"); - expect(fields).toEqual(["field1", "field2"]); -}); - -test("getFieldsFactory handles wildcard in includeFields correctly", () => { - const getFields = getFieldsFactory(["*:field1", "TestModel:field2"], []); - const fields = getFields("TestModel"); - expect(fields).toEqual(["field1", "field2"]); -}); - -test("getFieldsFactory handles excludeFields correctly", () => { - const getFields = getFieldsFactory(["TestModel:field1", "TestModel:field2"], ["TestModel:field1"]); - const fields = getFields("TestModel"); - expect(fields).toEqual(["field2"]); -}); - -test("getFieldsFactory handles wildcard in excludeFields correctly", () => { - const getFields = getFieldsFactory(["TestModel:field1", "TestModel:field2"], ["TestModel:*"]); - const fields = getFields("TestModel"); - expect(fields).toEqual([]); -}); diff --git a/src/index.ts b/src/index.ts index b88dd11..01928da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +// skip v8 check import cuid2Extension, { Cuid2ExtensionOptions } from "./cuid2-extension"; export default cuid2Extension; diff --git a/src/integration.spec.ts b/src/integration.spec.ts index 608dd22..35f0942 100644 --- a/src/integration.spec.ts +++ b/src/integration.spec.ts @@ -48,6 +48,23 @@ test("cuid2Extension sets the CUID2 value on the specified fields", async () => expect(singleId.id).not.toMatch(UUID_REGEX); }); +test("cuid2Extension sets the CUID2 value on the exact fields", async () => { + const cuid2 = cuid2Extension({ + fields: ["DualId:id", "SingleId:id"], + }); + + const modifiedPrismaClient = prismaClient.$extends(cuid2); + + const singleId = await modifiedPrismaClient.singleId.create({ + data: { + value: "test", + }, + }); + + expect(singleId.id).toBeTypeOf("string"); + expect(singleId.id).not.toMatch(UUID_REGEX); +}); + test("cuid2Extension does not set the CUID2 value on fields not specified", async () => { const cuid2 = cuid2Extension({ includeFields: ["*:id"], diff --git a/src/valid-fields.ts b/src/valid-fields.ts new file mode 100644 index 0000000..1c80526 --- /dev/null +++ b/src/valid-fields.ts @@ -0,0 +1,64 @@ +import { type Prisma } from "@prisma/client"; + +/** + * The generated location in Prisma that contains the models and fields. + */ +type TypeMapModels = Prisma.TypeMap["model"]; + +/** + * Extracts the model names from the Prisma type map. + */ +type ModelNames = keyof TypeMapModels; + +/** + * Uses the Prisma type map to extract the field names for a given model. + */ +type FieldNames = T extends string ? keyof TypeMapModels[T]["fields"] : never; + +/** + * Convert a model into a string union of the field names. + * + * @example + * type UserFields = FieldStrings<"User">; + * // => "User:id" | "User:email" | "User:createdAt" + */ +type FieldStrings> = { + [K in TFieldNames]: K extends string ? `${TModelName}:${K}` : never; +}[TFieldNames]; + +/** + * Create a string union of all the field names for all the models. + */ +type FieldStringsForModels = { + [K in ModelNames]: FieldStrings; +}[ModelNames]; + +type FieldStringsOnAllModels = { + [K in ModelNames]: FieldStrings>; +}[ModelNames]; + +/** + * Create a string union for all the models with a wildcard. + */ +type WildcardModels = { + [K in ModelNames]: `${K}:*`; +}[ModelNames]; + +/** + * Create a string union for all the fields with a wildcard. + */ +type WildcardFields> = { + [K in TFieldNames]: K extends string ? `*:${K}` : never; +}[TFieldNames]; + +/** + * Create a string union for all the fields with a wildcard for all the models. + */ +type WildcardFieldStringsForModels = { + [K in ModelNames]: WildcardFields; +}[ModelNames]; + +export type IncludeField = FieldStringsForModels | WildcardFieldStringsForModels; +export type ExcludeField = FieldStringsForModels | FieldStringsOnAllModels | WildcardModels; + +export type Field = FieldStringsForModels; diff --git a/src/validators.ts b/src/validators.ts new file mode 100644 index 0000000..ec94f80 --- /dev/null +++ b/src/validators.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +const cuid2OptionsValidator = z + .object({ + random: z.function().optional(), + counter: z.function().optional(), + fingerprint: z.string().optional(), + length: z.number().optional(), + }) + .optional(); + +export const wildcardValidator = z.object({ + includeFields: z.array(z.string().regex(/^(?:[A-Za-z][A-Za-z0-9]*|\*):[A-Za-z][A-Za-z0-9_]*$/)).min(1), + excludeFields: z.array(z.string().regex(/^[A-Za-z][A-Za-z0-9]*:(?:[A-Za-z][A-Za-z0-9_]*|\*)$/)).optional(), + cuid2Options: cuid2OptionsValidator, +}); + +export const exactValidator = z.object({ + fields: z.array(z.string().regex(/^[A-Za-z][A-Za-z0-9]*:[A-Za-z][A-Za-z0-9_]*$/)).min(1), + cuid2Options: cuid2OptionsValidator, +}); diff --git a/vite.config.ts b/vite.config.ts index dcb8a70..5aab23d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,7 +5,9 @@ import tsconfigPaths from "vite-tsconfig-paths"; const config = defineConfig({ plugins: [tsconfigPaths()], test: { - coverage: {}, + coverage: { + exclude: ["src/index.ts", "src/valid-fields.ts", "src/factories/get-fields-function.ts"], + }, }, });