Skip to content

Commit

Permalink
wip: Prisma とモデルの仮実装
Browse files Browse the repository at this point in the history
  • Loading branch information
wappon28dev committed Jan 5, 2025
1 parent 1eb216c commit 00d643a
Show file tree
Hide file tree
Showing 15 changed files with 715 additions and 24 deletions.
2 changes: 2 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
"words": [
"cloudflare",
"datasource",
"neverthrow",
"prisma",
"sqlite",
"Uncapitalize",
"uuid"
]
}
32 changes: 32 additions & 0 deletions app/routes/test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
import { MemberId } from '@/utils/models/member';
import { useLoaderData } from '@remix-run/react';

export async function loader({ context }: LoaderFunctionArgs) {
const { db } = context;
const { Member } = db.models;
const member = (await Member.factories.from(
MemberId.from('0188c0f2-8e47-11ec-b909-0242ac120002')._unsafeUnwrap(),
))._unsafeUnwrap();

return { member };
}

export default function Index() {
const { member } = useLoaderData<typeof loader>();

return (
<div>
<div>test</div>
<textarea
defaultValue={JSON.stringify(member, null, 2)}
style={{
width: '100%',
minHeight: '10lh',
// @ts-expect-error お NEW なプロパティ
fieldSizing: 'content',
}}
/>
</div>
);
}
69 changes: 69 additions & 0 deletions app/services/database.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { DatabaseError, PrismaClientError } from '@/types/database';
import type { ModelMetadata } from '@/types/model';
import { clientKnownErrorCode } from '@/types/database';
import { getEntries } from '@/utils';
import { __Member } from '@/utils/models/member';
import { Prisma, type PrismaClient } from '@prisma/client';
import { ResultAsync } from 'neverthrow';
import { match } from 'ts-pattern';

export class Database {
public models;

public constructor(protected client: PrismaClient) {
this.models = {
Member: __Member(client),
};
}

public static transformError(
metadata: ModelMetadata<any, 'CATCH_ALL'>,
caller: string,
error: unknown,
{ message, hint }: Partial<{ message: string; hint: string }> = {},
): DatabaseError {
const detail
= match(error)
.when(
(e) => e instanceof Prisma.PrismaClientKnownRequestError,
(e) => {
const type = getEntries(clientKnownErrorCode).find(([, code]) => code === e.code)?.[0] ?? 'UNKNOWN_REQUEST_ERROR' as const;
return { type, _raw: e };
},
)
.when(
(e) => e instanceof Prisma.PrismaClientValidationError,
(e) => ({ type: 'DATA_VALIDATION_FAILED' as const, _raw: e }),
)
.when(
(e) => e instanceof Prisma.PrismaClientKnownRequestError || e instanceof Prisma.PrismaClientUnknownRequestError,
(e) => ({ type: 'UNKNOWN_REQUEST_ERROR' as const, _raw: e }),
)
.when(
(e) => e instanceof Prisma.PrismaClientRustPanicError || e instanceof Prisma.PrismaClientInitializationError,
(e) => ({ type: 'UNKNOWN_ERROR' as const, _raw: e }),
)
.otherwise(
() => ({ type: 'UNEXPECTED_ERROR' as const, _raw: new Error('Unknown error', { cause: error }) }),
);

const _message
= match(detail.type)
.with('NO_ROWS_FOUND', () => `この${metadata.displayName}は見つかりませんでした`)
.with('ALREADY_EXISTS', () => `この${metadata.displayName}は既に存在します`)
.with('DATA_VALIDATION_FAILED', () => `この${metadata.displayName}のデータが不正です`)
.otherwise(() => `${metadata.displayName}の処理中にエラーが発生しました`);

return {
metadata,
caller,
message: message ?? _message,
hint,
...detail,
};
}

public static transformResult<S>(fn: Promise<S>): ResultAsync<S, PrismaClientError> {
return ResultAsync.fromPromise(fn, (e) => e as PrismaClientError);
}
}
48 changes: 48 additions & 0 deletions app/types/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { ModelMetadata } from '@/types/model';
import type { Prisma } from '@prisma/client';
import type { ResultAsync } from 'neverthrow';

export const clientKnownErrorCode = {
ALREADY_EXISTS: 'P2002',
NO_ROWS_FOUND: 'P2025',
} as const satisfies Record<string, string>;

// ref: https://www.prisma.io/docs/orm/reference/error-reference
export type PrismaClientError =
| Prisma.PrismaClientKnownRequestError
| Prisma.PrismaClientValidationError
| Prisma.PrismaClientUnknownRequestError
| Prisma.PrismaClientRustPanicError
| Prisma.PrismaClientInitializationError
| Error;

export type DatabaseErrorDetail =
| {
type: keyof typeof clientKnownErrorCode | 'UNKNOWN_REQUEST_ERROR';
_raw: Prisma.PrismaClientKnownRequestError;
}
| {
type: 'DATA_VALIDATION_FAILED';
_raw: Prisma.PrismaClientValidationError;
}
| {
type: 'UNKNOWN_REQUEST_ERROR';
_raw: Prisma.PrismaClientKnownRequestError | Prisma.PrismaClientUnknownRequestError;
}
| {
type: 'UNKNOWN_ERROR';
_raw: Prisma.PrismaClientRustPanicError | Prisma.PrismaClientInitializationError;
}
| {
type: 'UNEXPECTED_ERROR';
_raw: Error;
};

export type DatabaseError = {
metadata: ModelMetadata<any>;
caller: string;
message: string;
hint?: string;
} & DatabaseErrorDetail;

export type DatabaseResult<S> = ResultAsync<S, DatabaseError>;
43 changes: 43 additions & 0 deletions app/types/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Model } from '@/utils/model';
import type { Prisma, PrismaClient } from '@prisma/client';

export interface ModelMetadata<
M extends Prisma.ModelName,
V extends 'CATCH_ALL' | 'DEFAULT' = 'DEFAULT',
> {
modelName: M;
displayName: string;
primaryKeyName: V extends 'CATCH_ALL' ? any : keyof PrismaClient[Uncapitalize<M>]['fields'];
}

// __M = (client) => M のときの M
export type ModelGenerator<
Metadata extends ModelMetadata<any, 'CATCH_ALL'>,
Schema extends object,
> = (client: PrismaClient) => new (data: Schema) => Model<Metadata, Schema>;

export type AnyModelGenerator = ModelGenerator<ModelMetadata<any, 'CATCH_ALL'>, any>;
export type AnyModel = Model<any, any>;

// __M = (client) => M のときの M
// ただし, __M = (client) => M のときの M に対しても適用可能
export type ModelEntityOf<T extends AnyModelGenerator | AnyModel>
= T extends AnyModelGenerator
? InstanceType<ReturnType<T>>
: T extends AnyModel
? T
: never;

// M: Model<Metadata, Schema> のときの Metadata
// ただし, __M = (client) => M のときの M に対しても適用可能
export type ModelMetadataOf<T extends AnyModelGenerator | AnyModel>
= ModelEntityOf<T> extends Model<infer M, any>
? M
: never;

// M: Model<Metadata, Schema> のときの Metadata
// ただし, __M = (client) => M のときの M に対しても適用可能
export type ModelSchemaOf<T extends AnyModelGenerator | AnyModel>
= ModelEntityOf<T> extends Model<any, infer S>
? S
: never;
25 changes: 25 additions & 0 deletions app/types/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type Override<T, U extends { [Key in keyof T]?: unknown }> = Omit<
T,
keyof U
> &
U;

export type Entries<T> = Array<
keyof T extends infer U ? (U extends keyof T ? [U, T[U]] : never) : never
>;

export type ArrayElem<ArrayType extends readonly unknown[]> =
ArrayType extends ReadonlyArray<infer ElementType> ? ElementType : never;

export type OmitStrict<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

export type Nullable<T> = T | null | undefined;

// ref: https://zenn.dev/ourly_tech_blog/articles/branded_type_20240726#3.-branded-type%E3%81%AE%E5%AE%9F%E8%A3%85%E6%96%B9%E6%B3%95
declare const __brand: unique symbol;
export type Brand<K, T> = K & { [__brand]: T };

// ref: https://www.totaltypescript.com/concepts/the-prettify-helper
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
47 changes: 47 additions & 0 deletions app/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,53 @@
import type { Brand, Entries } from '@/types/utils';
import type { Result } from 'neverthrow';
import type { ZodError } from 'zod';
import { TYPES } from '@/consts/member';
import { err, ok } from 'neverthrow';
import { string } from 'zod';

export function toTypeName(key: string) {
const type = TYPES.find((t) => t.key === key);
return type?.name;
}

export function toUncapitalize<T extends string>(str: T): Uncapitalize<T> {
return str.charAt(0).toLowerCase() + str.slice(1) as Uncapitalize<T>;
}

export function parseUuid<K extends string>(uuid: string): Result<Brand<K, string>, ZodError<string>> {
const zUuid = string().uuid();
const { success, data, error } = zUuid.safeParse(uuid);

if (!success) {
return err(error);
}

return ok(data as Brand<K, string>);
}

export async function waitMs(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

export function getKeys<T extends Record<string, unknown>>(
obj: T,
): Array<keyof T> {
return Object.keys(obj);
}
export function getValues<T extends Record<string, any>>(
obj: T,
): Array<T[keyof T]> {
return Object.values(obj);
}
export function getEntries<T extends Record<string, unknown>>(
obj: T,
): Entries<T> {
return Object.entries(obj) as Entries<T>;
}
export function fromEntries<T extends Record<string, unknown>>(
entries: Entries<T>,
): T {
return Object.fromEntries(entries) as T;
}
63 changes: 63 additions & 0 deletions app/utils/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { DatabaseResult } from '@/types/database';
import type {
AnyModel,
ModelMetadata,
ModelMetadataOf,
ModelSchemaOf,
} from '@/types/model';
import type { Brand } from '@/types/utils';
import type { Prisma, PrismaClient } from '@prisma/client';
import { Database } from '@/services/database.server';
import { toUncapitalize } from '@/utils';

export abstract class Model<
Metadata extends ModelMetadata<any, 'CATCH_ALL'>,
Schema extends object,
> {
public constructor(public data: Schema, protected metadata: Metadata) {}

/**
* {@link Database.transformError} の部分適用.
* `Model` を継承しているので, 最初の引数を省略できる.
*/
protected transformError(caller: string) {
return (
...rest: Parameters<typeof Database.transformError> extends [
any,
any,
...infer R,
]
? R
: never
) => Database.transformError(this.metadata, caller, ...rest);
}

protected transformResult = Database.transformResult;

protected static getFactories<
B extends Brand<string, unknown>,
M extends AnyModel,
>(
client: PrismaClient,
) {
return (
Model: new (data: ModelSchemaOf<M>) => M,
metadata: ModelMetadataOf<M>,
) => ({
from: (id: B): DatabaseResult<M> => {
const { modelName, primaryKeyName } = metadata as ModelMetadata<Prisma.ModelName, 'CATCH_ALL'>;
const modelNameUnCapitalize = toUncapitalize (modelName);

const result = Database.transformResult<ModelSchemaOf<M>>(
// @ts-expect-error: `primaryKeyName` が `any` になってしまうため, 型エラーが発生する
// eslint-disable-next-line ts/no-unsafe-argument
client[modelNameUnCapitalize].findUniqueOrThrow({ where: { [primaryKeyName]: id } }),
);

return result
.map((data) => new Model(data))
.mapErr((e) => Database.transformError(metadata, 'from', e));
},
});
}
}
Loading

0 comments on commit 00d643a

Please sign in to comment.