Skip to content

Instantly share code, notes, and snippets.

@moroz
Created January 12, 2022 23:46
Show Gist options
  • Select an option

  • Save moroz/8455d8726048ba5faf8b4ce44cb95587 to your computer and use it in GitHub Desktop.

Select an option

Save moroz/8455d8726048ba5faf8b4ce44cb95587 to your computer and use it in GitHub Desktop.
Changeset for Node
import { Prisma } from "@prisma/client";
import { Buffer } from "buffer";
import _ from "lodash";
export interface CastOptions {
trimStrings?: boolean;
}
export enum Types {
Integer,
Binary,
Float,
Boolean,
String,
Decimal
}
export interface SchemaFieldOptions {
default?: any;
}
export type SchemaFieldTuple = [Types, SchemaFieldOptions];
export type SchemaField = Types | SchemaFieldTuple;
export type Schema = Record<string, SchemaField>;
export type CastFunction = (value: any) => any;
export type Struct = Record<string, any>;
export type ChangesetChange = Record<string, any>;
export interface ChangesetError {
field: string;
message: string;
}
export const TypeMapper: Record<Types, CastFunction> = {
[Types.Integer]: Number,
[Types.Float]: Number,
[Types.String]: String,
[Types.Binary]: Buffer.from,
[Types.Boolean]: Boolean,
[Types.Decimal]: (value: any) => {
if (![undefined, "", null].includes(value)) {
return new Prisma.Decimal(Number(value));
}
}
};
export const NumberValidators = {
lessThan: (
value: number | Prisma.Decimal,
expected: number | Prisma.Decimal
) => {
if (value instanceof Prisma.Decimal) {
return value.lessThan(expected);
} else {
return value < expected;
}
},
greaterThan: (
value: number | Prisma.Decimal,
expected: number | Prisma.Decimal
) => {
if (value instanceof Prisma.Decimal) {
return value.greaterThan(expected);
} else {
return value > expected;
}
},
greaterThanOrEqualTo: (
value: number | Prisma.Decimal,
expected: number | Prisma.Decimal
) => {
if (value instanceof Prisma.Decimal) {
return value.greaterThanOrEqualTo(expected);
} else {
return value >= expected;
}
},
lessThanOrEqualTo: (
value: number | Prisma.Decimal,
expected: number | Prisma.Decimal
) => {
if (value instanceof Prisma.Decimal) {
return value.lessThanOrEqualTo(expected);
} else {
return value >= expected;
}
}
};
const LengthValidators = {
min: (value: string, length: number) => {
return value.length >= length;
},
max: (value: string, length: number) => {
return value.length <= length;
},
is: (value: string, length: number) => {
return value.length === length;
}
};
export class SchemaBuilder {
fields: Schema = {};
build() {
return this.fields;
}
addField(name: string, type: Types, opts?: SchemaFieldOptions) {
if (opts) {
this.fields[name] = [type, opts];
} else {
this.fields[name] = type;
}
}
integer(name: string, opts?: SchemaFieldOptions) {
this.addField(name, Types.Integer, opts);
return this;
}
float(name: string, opts?: SchemaFieldOptions) {
this.addField(name, Types.Float, opts);
return this;
}
string(name: string, opts?: SchemaFieldOptions) {
this.addField(name, Types.String, opts);
return this;
}
boolean(name: string, opts?: SchemaFieldOptions) {
this.addField(name, Types.Boolean, opts);
return this;
}
binary(name: string, opts?: SchemaFieldOptions) {
this.addField(name, Types.Binary, opts);
return this;
}
decimal(name: string, opts?: SchemaFieldOptions) {
this.addField(name, Types.Decimal, opts);
return this;
}
}
export class Changeset<T extends Struct = any> {
data: Struct;
schema: Schema;
changes: ChangesetChange[] = [];
errors: ChangesetError[] = [];
constructor(data: Struct, schema: Schema) {
this.data = data;
this.schema = schema;
}
private getCastFunction(field: keyof typeof this.schema) {
const type = this.schema[field];
if (!type === undefined) {
throw new Error(`unknown field ${field} given to cast.`);
}
if (Array.isArray(type)) {
return TypeMapper[type[0]];
}
return TypeMapper[type];
}
transformErrors() {
return this.errors.reduce((acc, { field, message }) => {
(acc[field] || (acc[field] = [])).push(message);
return acc;
}, {} as Record<string, string[]>);
}
cast(
params: Record<string, any>,
permitted: string[],
{ trimStrings = true }: CastOptions = {}
) {
const changes = Object.entries(params).reduce((acc, [key, value]) => {
if (!permitted.includes(key)) return acc;
const castFunction = this.getCastFunction(key);
let castValue = castFunction(value);
if (trimStrings && typeof castValue === "string")
castValue = castValue.trim();
// there is no change if the values are equal
if (
((castValue as any) instanceof Prisma.Decimal &&
this.data[key] instanceof Prisma.Decimal &&
(castValue as Prisma.Decimal).eq(this.data[key])) ||
castValue === this.data[key]
)
return acc;
return {
...acc,
[key]: castValue || null
};
}, {});
this.changes = { ...this.changes, ...changes };
return this;
}
validateRequired(fields: string[]) {
fields.forEach((field) => {
const value = this.getField(field);
if (["", null, undefined].includes(value)) {
this.errors.push({ field, message: "can't be blank" });
}
});
return this;
}
validateNumber(
field: string,
opts: Partial<
Record<keyof typeof NumberValidators, number | Prisma.Decimal>
>
) {
const value = this.getField(field);
if (
typeof value !== "number" &&
!((value as any) instanceof Prisma.Decimal)
)
return this;
Object.entries(opts).forEach(([validatorKey, expected]) => {
const validator =
NumberValidators[validatorKey as keyof typeof NumberValidators];
if (!validator || validator(value, expected)) return;
const errorType = _.snakeCase(validatorKey).replace(/_/g, " ");
this.errors.push({
field,
message: `must be ${errorType} ${expected}`
});
});
return this;
}
validateLength(
field: string,
opts: Partial<Record<keyof typeof LengthValidators, number>>
) {
const value = this.getField(field);
if (typeof value !== "string") {
return this;
}
Object.entries(opts).forEach(([validatorKey, expected]) => {
const validator =
LengthValidators[validatorKey as keyof typeof LengthValidators];
if (!validator || validator(value, expected)) return;
this.errors.push({
field,
message: `length is invalid, expected: ${validatorKey}: ${expected}`
});
});
return this;
}
getField(field: string) {
const change = this.changes[field as any];
if (change === undefined) return this.data[field as any];
return change;
}
get valid(): boolean {
return this.errors.length === 0;
}
tap(fn: (changeset: typeof this) => typeof this) {
return fn(this);
}
applyChanges() {
return {
...this.data,
...this.changes
} as Record<string, any>;
}
toPrismaParams() {
return Object.entries(this.changes).reduce((acc, [key, value]) => {
if (key.match(/Id$/)) {
const relation = key.replace(/Id$/, "");
return { ...acc, [relation]: { connect: { id: value } } };
}
return { ...acc, [key]: value };
}, {}) as T;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment