Skip to content

Commit

Permalink
feat: Added fields and typing
Browse files Browse the repository at this point in the history
Added support for type safety when selecting fields and created a `fields` option which uses exact values.
  • Loading branch information
nrdobie committed Feb 27, 2024
1 parent 965fb9b commit b5aaaaa
Show file tree
Hide file tree
Showing 16 changed files with 355 additions and 96 deletions.
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
},
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"immer": "^10.0.3"
"immer": "^10.0.3",
"zod": "^3.22.4"
}
}
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 87 additions & 11 deletions src/cuid2-extension.spec.ts
Original file line number Diff line number Diff line change
@@ -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
]
}
]]
`);
});
86 changes: 46 additions & 40 deletions src/cuid2-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof init>[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.
*
Expand All @@ -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.
Expand All @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions src/factories/get-exact-fields-factory.spec.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
20 changes: 20 additions & 0 deletions src/factories/get-exact-fields-factory.ts
Original file line number Diff line number Diff line change
@@ -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;
});
};
}
4 changes: 4 additions & 0 deletions src/factories/get-fields-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Function that returns the fields on a model to apply the CUID2 extension
*/
export type GetFieldsFunction = (operationModel: string) => string[];
Loading

0 comments on commit b5aaaaa

Please sign in to comment.