Skip to content

Commit

Permalink
Merge pull request #32 from cp-20/replace-prisma-with-drizzle-orm
Browse files Browse the repository at this point in the history
PrismaをDrizzle ORMでリプレイス
  • Loading branch information
cp-20 authored Nov 4, 2023
2 parents 30e00ae + b018bbf commit 4fc9e69
Show file tree
Hide file tree
Showing 15 changed files with 724 additions and 170 deletions.
3 changes: 3 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@
"@supabase/supabase-js": "^2.26.0",
"@tabler/icons-react": "^2.23.0",
"cheerio": "1.0.0-rc.12",
"drizzle-orm": "^0.28.6",
"jotai": "^2.2.1",
"next": "^13.4.8",
"postgres": "^3.4.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-toastify": "^9.1.3",
Expand All @@ -54,6 +56,7 @@
"@typescript-eslint/eslint-plugin": "^5.59.7",
"@typescript-eslint/parser": "^5.59.7",
"@vitejs/plugin-react-swc": "^3.3.1",
"drizzle-kit": "^0.19.13",
"eslint": "^8.41.0",
"eslint-config-google": "^0.14.0",
"eslint-config-next": "^13.4.4",
Expand Down
52 changes: 0 additions & 52 deletions packages/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -68,55 +68,3 @@ model tag {
article article @relation(fields: [articleId], references: [id])
articleId Int
}

// Drizzle ORMの定義も書いてみたけどちょっと機能不足感が否めなかったのでここで供養
// 採用することになったらまた復活させる
//
// import {
// pgTable,
// serial,
// text,
// varchar,
// date,
// char,
// integer,
// } from 'drizzle-orm/pg-core';

// export const users = pgTable('users', {
// id: serial('id').primaryKey(),
// name: varchar('name', { length: 32 }).notNull(),
// email: text('email').notNull(),
// created_at: date('created_at', { mode: 'date' }).notNull().defaultNow(),
// updated_at: date('updated_at', { mode: 'date' }).notNull(),
// });

// export const clips = pgTable('clips', {
// id: serial('id').primaryKey(),
// status: char('status', { enum: ['unread', 'progress', 'done'] }).notNull(),
// progress: integer('progress').notNull(),
// comment: text('comment'),
// articleId: integer('article_id')
// .notNull()
// .references(() => articles.id),
// authorId: integer('author_id')
// .notNull()
// .references(() => users.id),
// });

// export const articles = pgTable('articles', {
// id: serial('id').primaryKey(),
// url: text('url'),
// title: text('title'),
// body: text('body'),
// summary: text('summary'),
// created_at: date('created_at', { mode: 'date' }).notNull(),
// updated_at: date('updated_at', { mode: 'date' }).notNull(),
// });

// export const tags = pgTable('tags', {
// id: serial('id').primaryKey(),
// name: text('name'),
// articleId: integer('article_id')
// .notNull()
// .references(() => articles.id),
// });
12 changes: 12 additions & 0 deletions packages/web/src/features/database/drizzleClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as models from './models';

const connectionString = process.env.DATABASE_URL;
if (connectionString === undefined) {
throw new Error('DATABASE_URL is not defined');
}

const client = postgres(connectionString);

export const db = drizzle(client, { schema: models });
89 changes: 89 additions & 0 deletions packages/web/src/features/database/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 全体的にテーブル設計を見直したい
// 特にDB側はスネークケースで統一したい

import { relations } from 'drizzle-orm';
import {
pgTable,
serial,
text,
varchar,
integer,
timestamp,
unique,
primaryKey,
} from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
id: varchar('id').primaryKey(),
email: text('email').notNull(),
name: varchar('name', { length: 1024 }).notNull(),
displayName: varchar('displayName', { length: 1024 }),
avatarUrl: text('avatarUrl'),
createdAt: timestamp('createdAt', { mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updatedAt', { mode: 'date' }).notNull(),
});

export const clips = pgTable(
'clips',
{
id: serial('id').primaryKey(),
status: integer('status').notNull(),
progress: integer('progress').notNull(),
comment: text('comment'),
articleId: integer('articleId')
.notNull()
.references(() => articles.id),
// authorIdにしたい
authorId: varchar('userId')
.notNull()
.references(() => users.id),
createdAt: timestamp('createdAt', { mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updatedAt', { mode: 'date' }).notNull(),
},
(t) => ({
unique: unique('article_and_author').on(t.articleId, t.authorId),
}),
);

export const clipRelations = relations(clips, ({ one }) => ({
article: one(articles, {
fields: [clips.articleId],
references: [articles.id],
}),
}));

export const articles = pgTable('article', {
id: serial('id').primaryKey(),
url: text('url').notNull().unique(),
title: text('title').notNull(),
body: text('body').notNull(),
ogImageUrl: text('ogImageUrl'),
summary: text('summary'),
createdAt: timestamp('createdAt', { mode: 'date' }).notNull().defaultNow(),
// updatedAt: timestamp('updatedAt', { mode: 'date' }).notNull(),
});

export const articleRefs = pgTable(
'articleRef',
{
referFrom: integer('referFrom')
.notNull()
.references(() => articles.id),
referTo: integer('referTo')
.notNull()
.references(() => articles.id),
createdAt: timestamp('createdAt', { mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updatedAt', { mode: 'date' }).notNull(),
},
(t) => ({
unique: primaryKey(t.referFrom, t.referTo),
}),
);

export const tags = pgTable('tags', {
id: serial('id').primaryKey(),
name: text('name').unique(),
articleId: integer('articleId')
.notNull()
.references(() => articles.id),
});
3 changes: 3 additions & 0 deletions packages/web/src/features/supabase/supabaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export const useSupabase = () => {
const loginWithGitHub = async () => {
const { error } = await supabaseClient.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: location.origin,
},
});
if (error) throw error;
};
Expand Down
2 changes: 0 additions & 2 deletions packages/web/src/schema/clipSearchQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { z } from 'zod';

export const ClipSearchQuerySchema = z.object({
unreadOnly: z.boolean().optional(),
title: z.string().optional(),
body: z.string().optional(),
});

export type ClipSearchQuery = z.infer<typeof ClipSearchQuerySchema>;
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Prisma } from '@prisma/client';
import type { NextApiHandler } from 'next';
import { z } from 'zod';
import { prisma } from '@/features/database/prismaClient';
import { db } from '@/features/database/drizzleClient';
import { requireAuthWithUserMiddleware } from '@/server/middlewares/authorize';

const deleteUserClipByIdSchema = z.object({
Expand All @@ -16,20 +15,12 @@ export const deleteUserClipById: NextApiHandler =
}
const { clipId } = query.data;

try {
const clip = await prisma.clips.delete({
where: {
id: clipId,
},
});
return res.status(200).json({ clip });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2015') {
res.status(404).json({ message: 'Not Found' });
}

// TODO: 他のエラーコードのときのエラーハンドリング
}
const clip = await db.query.clips.findFirst({
where: (fields, { eq }) => eq(fields.id, clipId),
});
if (clip === undefined) {
return res.status(404).json({ message: 'Not found' });
}

return res.status(200).json({ clip });
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { NextApiHandler } from 'next';
import { z } from 'zod';
import { prisma } from '@/features/database/prismaClient';
import { db } from '@/features/database/drizzleClient';
import { requireAuthWithUserMiddleware } from '@/server/middlewares/authorize';

const getUserClipByIdSchema = z.object({
Expand All @@ -16,11 +16,9 @@ export const getUserClipById: NextApiHandler = requireAuthWithUserMiddleware()(
}
const { id, clipId } = query.data;

const clip = await prisma.clips.findFirst({
where: {
authorId: id,
id: clipId,
},
const clip = await db.query.clips.findFirst({
where: (fields, { and, eq }) =>
and(eq(fields.authorId, id), eq(fields.id, clipId)),
});

return res.status(200).json({ clip });
Expand Down
29 changes: 11 additions & 18 deletions packages/web/src/server/handlers/users/[id]/clips/[clipId]/patch.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Prisma } from '@prisma/client';
import { eq } from 'drizzle-orm';
import type { NextApiHandler } from 'next';
import { z } from 'zod';
import { prisma } from '@/features/database/prismaClient';
import { db } from '@/features/database/drizzleClient';
import { clips } from '@/features/database/models';
import { requireAuthWithUserMiddleware } from '@/server/middlewares/authorize';

const updateUserClipByIdQuerySchema = z.object({
Expand Down Expand Up @@ -36,22 +37,14 @@ export const updateUserClipById: NextApiHandler =
return res.status(400).json({ message: 'clipId is not a number' });
}

try {
const clip = await prisma.clips.update({
where: {
id: clipId,
},
data: patchClip,
});
return res.status(200).json({ clip });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2015') {
return res.status(404).json({ message: 'Not Found' });
}
const clip = await db
.update(clips)
.set(patchClip)
.where(eq(clips.id, clipId));

// TODO: 他のエラーコードのときのエラーハンドリング
}
return res.status(500).json({ message: 'Internal Server Error' });
if (clip.length === 0) {
return res.status(404).json({ message: 'Not found' });
}

return res.status(200).json({ clip });
});
59 changes: 27 additions & 32 deletions packages/web/src/server/handlers/users/[id]/clips/get.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { NextApiHandler } from 'next';
import { z } from 'zod';
import { prisma } from '@/features/database/prismaClient';
import { db } from '@/features/database/drizzleClient';
import { ClipSearchQuerySchema } from '@/schema/clipSearchQuery';
import { requireAuthWithUserMiddleware } from '@/server/middlewares/authorize';
import { excludeFalsy } from '@/shared/lib/excludeFalsy';
import { parseInt } from '@/shared/lib/parseInt';

const getUserClipsSchema = z.object({
Expand All @@ -29,41 +30,35 @@ export const getUserClips: NextApiHandler = requireAuthWithUserMiddleware()(
return res.status(400).json({ error: searchQuery.error });
}

const cursorOption = cursor !== -1 ? { cursor: { id: cursor } } : undefined;
const cursorTimestamp =
cursor !== -1
? (
await db.query.clips.findFirst({
columns: {
updatedAt: true,
},
where: (fields, { and, eq }) =>
and(eq(fields.authorId, id), eq(fields.id, cursor)),
})
)?.updatedAt
: undefined;

const queryOption = {
...(searchQuery.data.unreadOnly
? {
status: {
in: [0, 1],
},
}
: undefined),
// bodyとtitleも一応使えるけど、日本語検索が怪しい
...(searchQuery.data.body
? { article: { body: { contains: searchQuery.data.body } } }
: undefined),
...(searchQuery.data.title
? { article: { title: { contains: searchQuery.data.title } } }
: undefined),
};

const clips = await prisma.clips.findMany({
where: {
authorId: id,
AND: {
...queryOption,
},
},
orderBy: {
updatedAt: 'desc',
const clips = await db.query.clips.findMany({
where: (fields, { and, inArray, lt, eq }) => {
const filters = excludeFalsy([
searchQuery.data.unreadOnly && inArray(fields.status, [0, 1]),
cursorTimestamp !== undefined &&
lt(fields.updatedAt, cursorTimestamp),
]);
return and(eq(fields.authorId, id), ...filters);
},
include: {
orderBy: (clips, { desc }) => desc(clips.updatedAt),
// ...cursorOption,
limit: Math.min(100, limit),
offset: cursor !== -1 ? 1 : 0,
with: {
article: true,
},
...cursorOption,
take: Math.min(100, limit),
skip: cursor !== -1 ? 1 : 0,
});

return res.status(200).json({ clips });
Expand Down
Loading

0 comments on commit 4fc9e69

Please sign in to comment.