使用 Neon Postgres 的 Todo 应用

本教程演示了如何使用 Drizzle ORM、Neon 数据库和 Next.js 构建 Todo app

This guide assumes familiarity with:
  • 你应该拥有一个现有的 Next.js 项目,或者使用以下命令创建一个新项目:
npx create-next-app@latest --typescript
  • 你应该已经安装了 Drizzle ORM 和 Drizzle 套件。你可以通过运行以下命令来实现:
npm
yarn
pnpm
bun
npm i drizzle-orm -D drizzle-kit
npm
yarn
pnpm
bun
npm i @neondatabase/serverless
  • 你应该已经安装了用于管理环境变量的 dotenv 包。
npm
yarn
pnpm
bun
npm i dotenv
IMPORTANT

如果你在安装过程中遇到解决依赖的问题:

如果你没有使用 React Native,强制安装 --force--legacy-peer-deps 应该可以解决此问题。如果你使用的是 React Native,则需要使用与你的 React Native 版本兼容的 React 版本。

设置 Neon 和 Drizzle ORM

创建新的 Neon 项目

登录 Neon 控制台 并导航到“项目”部分。选择一个项目或点击 New Project 按钮创建一个新项目。

你的 Neon 项目附带一个名为 neondb 的即用型 Postgres 数据库。我们将在本教程中使用它。

设置连接字符串变量

导航到项目控制台中的“连接详细信息”部分以查找你的数据库连接字符串。它应该类似于以下内容:

postgres://username:password@ep-cool-darkness-123456.us-east-2.aws.neon.tech/neondb

DATABASE_URL 环境变量添加到你的 .env.env.local 文件中,你将使用该文件连接到 Neon 数据库。

DATABASE_URL=NEON_DATABASE_CONNECTION_STRING

将 Drizzle ORM 连接到你的数据库

src/db 文件夹中创建一个 drizzle.ts 文件并设置数据库配置:

src/db/drizzle.ts
import { config } from "dotenv";
import { drizzle } from 'drizzle-orm/neon-http';

config({ path: ".env" }); // or .env.local

export const db = drizzle(process.env.DATABASE_URL!);

声明待办事项模式

src/db/schema.ts
import { integer, text, boolean, pgTable } from "drizzle-orm/pg-core";

export const todo = pgTable("todo", {
  id: integer("id").primaryKey(),
  text: text("text").notNull(),
  done: boolean("done").default(false).notNull(),
});

这里我们使用 Drizzle ORM 中的数据类型,定义了包含字段 idtextdonetodo 表。

设置 Drizzle 配置文件

Drizzle 配置 - Drizzle 套件 使用的配置文件,包含有关数据库连接、迁移文件夹和模式文件的所有信息。

在项目根目录中创建一个 drizzle.config.ts 文件并添加以下内容:

drizzle.config.ts
import { config } from 'dotenv';
import { defineConfig } from "drizzle-kit";

config({ path: '.env' });

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

应用更改到数据库

你可以使用 drizzle-kit generate 命令生成迁移,然后使用 drizzle-kit migrate 命令运行它们。

生成迁移:

npx drizzle-kit generate

这些迁移存储在 drizzle/migrations 目录中,具体位置请参见你的 drizzle.config.ts 文件。此目录将包含更新数据库架构所需的 SQL 文件,以及一个 meta 文件夹,用于存储不同迁移阶段的架构快照。

生成的迁移示例:

CREATE TABLE IF NOT EXISTS "todo" (
	"id" integer PRIMARY KEY NOT NULL,
	"text" text NOT NULL,
	"done" boolean DEFAULT false NOT NULL
);

运行迁移:

npx drizzle-kit migrate

或者,你可以使用 Drizzle 套件推送命令 将更改直接推送到数据库:

npx drizzle-kit push
IMPORTANT
Push command is good for situations where you need to quickly test new schema designs or changes in a local development environment, allowing for fast iterations without the overhead of managing migration files.

建立服务器端函数

在此步骤中,我们在 src/actions/todoAction.ts 文件中创建服务器端函数,以处理待办事项的关键操作:

  1. getData

    • 从数据库中获取所有现有的待办事项。
  2. addTodo

    • 使用提供的文本向数据库添加新的待办事项。

    • 使用 revalidatePath("/") 启动主页的重新验证。

  3. deleteTodo

    • 根据待办事项的唯一 ID 从数据库中删除该待办事项。

    • 触发主页的重新验证。

  4. toggleTodo

    • 切换待办事项的完成状态,并相应地更新数据库。

    • 操作后重新验证主页。

  5. editTodo

    • 修改数据库中由其 ID 标识的待办事项文本。

    • 启动主页的重新验证。

src/actions/todoAction.ts
"use server";
import { eq, not } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db/drizzle";
import { todo } from "@/db/schema";

export const getData = async () => {
  const data = await db.select().from(todo);
  return data;
};

export const addTodo = async (id: number, text: string) => {
  await db.insert(todo).values({
    id: id,
    text: text,
  });
};

export const deleteTodo = async (id: number) => {
  await db.delete(todo).where(eq(todo.id, id));

  revalidatePath("/");
};

export const toggleTodo = async (id: number) => {
  await db
    .update(todo)
    .set({
      done: not(todo.done),
    })
    .where(eq(todo.id, id));

  revalidatePath("/");
};

export const editTodo = async (id: number, text: string) => {
  await db
    .update(todo)
    .set({
      text: text,
    })
    .where(eq(todo.id, id));

  revalidatePath("/");
};
Expand

使用 Next.js 设置主页

定义 TypeScript 类型

src/types/todoType.ts 中为待办事项定义一个 TypeScript 类型,并包含以下三个属性:id 属于 number 类型,text 属于 string 类型,done 属于 boolean 类型。此类型名为 todoType,表示应用中典型待办事项的结构。

src/types/todoType.ts
export type todoType = {
  id: number;
  text: string;
  done: boolean;
};

为待办事项应用创建主页

  1. src/components/todo.tsx:创建一个代表单个待办事项的 Todo 组件。它包含显示和编辑待办事项文本、使用复选框将其标记为已完成,以及提供编辑、保存、取消和删除待办事项的操作。
  2. src/components/addTodo.tsxAddTodo 组件提供了一种简单的表单,用于向 Todo 应用添加新的待办事项。它包含一个用于输入待办事项文本的输入字段和一个用于触发添加新待办事项的按钮。
  3. src/components/todos.tsx:创建 Todos 组件,用于表示 Todo 应用的主界面。它管理待办事项的状态,提供创建、编辑、切换和删除待办事项的功能,并使用 Todo 组件渲染单个待办事项。
todo.tsx
addTodo.tsx
todos.tsx
"use client";
import { ChangeEvent, FC, useState } from "react";
import { todoType } from "@/types/todoType";

interface Props {
  todo: todoType;
  changeTodoText: (id: number, text: string) => void;
  toggleIsTodoDone: (id: number, done: boolean) => void;
  deleteTodoItem: (id: number) => void;
}

const Todo: FC<Props> = ({
  todo,
  changeTodoText,
  toggleIsTodoDone,
  deleteTodoItem,
}) => {
  // State for handling editing mode
  const [editing, setEditing] = useState(false);

  // State for handling text input
  const [text, setText] = useState(todo.text);

  // State for handling "done" status
  const [isDone, setIsDone] = useState(todo.done);

  // Event handler for text input change
  const handleTextChange = (e: ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  // Event handler for toggling "done" status
  const handleIsDone = async () => {
    toggleIsTodoDone(todo.id, !isDone);
    setIsDone((prev) => !prev);
  };

  // Event handler for initiating the edit mode
  const handleEdit = () => {
    setEditing(true);
  };

  // Event handler for saving the edited text
  const handleSave = async () => {
    changeTodoText(todo.id, text);
    setEditing(false);
  };

  // Event handler for canceling the edit mode
  const handleCancel = () => {
    setEditing(false);
    setText(todo.text);
  };

  // Event handler for deleting a todo item
  const handleDelete = () => {
    if (confirm("Are you sure you want to delete this todo?")) {
      deleteTodoItem(todo.id);
    }
  };

  // Rendering the Todo component
  return (
    <div className="flex items-center gap-2 p-4 border-gray-200 border-solid border rounded-lg">
      {/* Checkbox for marking the todo as done */}
      <input
        type="checkbox"
        className="text-blue-200 rounded-sm h-4 w-4"
        checked={isDone}
        onChange={handleIsDone}
      />
      {/* Input field for todo text */}
      <input
        type="text"
        value={text}
        onChange={handleTextChange}
        readOnly={!editing}
        className={`${
          todo.done ? "line-through" : ""
        } outline-none read-only:border-transparent focus:border border-gray-200 rounded px-2 py-1 w-full`}
      />
      {/* Action buttons for editing, saving, canceling, and deleting */}
      <div className="flex gap-1 ml-auto">
        {editing ? (
          <button
            onClick={handleSave}
            className="bg-green-600 text-green-50 rounded px-2 w-14 py-1"
          >
            Save
          </button>
        ) : (
          <button
            onClick={handleEdit}
            className="bg-blue-400 text-blue-50 rounded w-14 px-2 py-1"
          >
            Edit
          </button>
        )}
        {editing ? (
          <button
            onClick={handleCancel}
            className="bg-red-400 w-16 text-red-50 rounded px-2 py-1"
          >
            Close
          </button>
        ) : (
          <button
            onClick={handleDelete}
            className="bg-red-400 w-16 text-red-50 rounded px-2 py-1"
          >
            Delete
          </button>
        )}
      </div>
    </div>
  );
};

export default Todo;
Expand

更新 src/app 文件夹中的 page.tsx 文件,以便从数据库获取待办事项并渲染 Todos 组件:

src/app/page.tsx
import { getData } from "@/actions/todoAction";
import Todos from "@/components/todos";

export default async function Home() {
  const data = await getData();
  return <Todos todos={data} />;
}

基本文件结构

本指南使用以下文件结构:

📦 <project root>
 ├ 📂 migrations
 │  ├ 📂 meta
 │  └ 📜 0000_heavy_doctor_doom.sql
 ├ 📂 public
 ├ 📂 src
 │  ├ 📂 actions
 │  │  └ 📜 todoActions.ts
 │  ├ 📂 app
 │  │  ├ 📜 favicon.ico
 │  │  ├ 📜 globals.css
 │  │  ├ 📜 layout.tsx
 │  │  └ 📜 page.tsx
 │  ├ 📂 components
 │  │  ├ 📜 addTodo.tsx
 │  │  ├ 📜 todo.tsx
 │  │  └ 📜 todos.tsx
 │  └ 📂 db
 │  │  ├ 📜 drizzle.ts
 │  │  └ 📜 schema.ts
 │  └ 📂 types
 │     └ 📜 todoType.ts
 ├ 📜 .env
 ├ 📜 .eslintrc.json
 ├ 📜 .gitignore
 ├ 📜 drizzle.config.ts
 ├ 📜 next-env.d.ts
 ├ 📜 next.config.mjs
 ├ 📜 package-lock.json
 ├ 📜 package.json
 ├ 📜 postcss.config.mjs
 ├ 📜 README.md
 ├ 📜 tailwind.config.ts
 └ 📜 tsconfig.json