sell整合實戰
share

D1 的關聯與 JOIN 查詢

用關聯式的方式設計 使用者、貼文、標籤,再用一句 JOIN 把它們重新拼回來。

3關聯資料表
1:N一對多
M:N多對多
1 txn原子交易
insights

我們要建立什麼模型?

真實的應用程式很少把所有東西塞進一張大表。我們會把資料拆成各司其職的小表,再用「關聯」把它們連起來(關聯 = 一條規則,說明某張表的列和另一張表的列怎麼對應)。這篇我們會做一個迷你部落格:使用者(users)會發表貼文(posts),而貼文會被貼上標籤(tags)。

兩種關聯幾乎就涵蓋了大部分情境:一對多(一位使用者寫很多篇貼文)以及多對多(一篇貼文有很多標籤,一個標籤也貼在很多貼文上)。資料拆開之後,我們再用 JOIN(合併查詢)在一次查詢裡把它們黏回來。

family_history

把它想成…

一棵家族樹。每個人(使用者)可以有很多小孩(貼文)——這條分支就是一對多。但「朋友關係」是雙向的:你有很多朋友、每個朋友也有很多朋友——這張網就是多對多,而你需要一份額外的名單來記錄「誰和誰是朋友」。

schema部落格資料一覽

一位使用者寫多篇貼文

每篇貼文有多個標籤

每個標籤貼在多篇貼文上

使用者

貼文

post_tags 中介表

標籤

account_tree

兩種關聯的形狀

在寫任何 SQL 之前,先抓住這兩種模式的感覺。訣竅永遠是問一句:「這一邊的一列,對應到另一邊的幾列?」

call_split

一對多(1:N)

一位使用者寫很多篇貼文,但每篇貼文剛好只有一位作者。由「多」的那一邊(貼文)存一個欄位,指回「一」的那一邊。

hub

多對多(M:N)

一篇貼文有很多標籤,一個標籤也貼在很多貼文上——兩邊都是「多」。任何一張表都無法靠自己記下這個連結。

grid_view

多對多要用中介表

多對多需要第三張中介表(post_tags),每一個連結存成一列:貼文 10 ↔ 標籤 'sql'。這是唯一誠實記錄它的方式。

report

別把清單塞進一個欄位

你可能會想把標籤存成一個 posts 欄位裡的文字 'sql,cloudflare'。別這樣。它無法建索引、無法 JOIN,也無法有效率地問「哪些貼文有 sql 標籤?」請改用中介表。

schema

資料模型(ER 圖)

ER 圖(實體關聯圖)把資料表畫成方塊、把關聯畫成連線。那個像鳥腳的分叉符號(crow's-foot)標示「多」的那一端。這裡 users 對 posts 是一對多,posts 對 tags 是多對多——並透過 post_tags 拆成兩段一對多。

schemausers · posts · tags

撰寫

擁有

標記

USERS

int

id

PK

text

email

text

name

POSTS

int

id

PK

int

user_id

FK

text

title

POST_TAGS

int

post_id

FK

int

tag_id

FK

TAGS

int

id

PK

text

name

key

主鍵(PK)

每張表的 id 用來唯一指認某一列,像身分證字號。沒有兩列會重複。

link

外鍵(FK)

posts.user_id 是外鍵:它的值必須等於某個 users.id。這個指標就是建立一對多連結的關鍵。

grid_view

複合主鍵

post_tags 把 (post_id, tag_id) 兩欄合起來當主鍵,所以同一組貼文-標籤配對不會被連結兩次。

join_inner

JOIN 到底在做什麼

JOIN 把兩張表的列拿出來,依照配對規則把相符的縫成一條更寬的列。你用 ON 條件告訴它怎麼配對——通常就是「外鍵等於主鍵」。就這樣:它是一個發生在資料庫內部、而不是在你程式碼裡的查表動作。

schema兩列進,一列出

posts 列 (user_id = 1)

JOIN ON posts.user_id = users.id

users 列 (id = 1)

合併成一列:標題 + 作者姓名

join_inner

INNER JOIN(內部合併)

只保留兩邊都配對成功的列。找不到對應作者的貼文,就會直接從結果裡消失。

join_left

LEFT JOIN(左側合併)

保留左表的每一列,就算沒配對到也留著——很適合讓「零標籤的貼文」依然出現(標籤欄會是 NULL)。

rule

ON 配對條件

ON posts.user_id = users.id 就是配對列的規則。ON 寫錯就會多出重複列或漏列,所以務必再三確認。

construction

從頭到尾動手做

現在把整條線串起來:建立資料表(含帶外鍵的中介表)、塞入資料、跑兩種 JOIN、用批次交易做原子寫入,最後從 Worker 與網頁讀出來。

  1. 綁定資料庫

    跑完 'wrangler d1 create blog-db' 後,加上這個綁定,讓 Worker 能用 env.DB 存取它。

    json
    {
      "d1_databases": [
        {
          "binding": "DB",
          "database_name": "blog-db",
          "database_id": "<paste-your-id-here>"
        }
      ]
    }
  2. 建立資料表結構(含外鍵)

    post_tags 就是中介表。它有兩個外鍵和一個複合主鍵。ON DELETE CASCADE 代表刪掉一篇貼文時,會自動把它的連結列一併清掉。

    sql
    -- users: one row per author
    CREATE TABLE users (
      id    INTEGER PRIMARY KEY,
      email TEXT NOT NULL UNIQUE,
      name  TEXT NOT NULL
    );
    
    -- posts: each post belongs to exactly ONE user  (one-to-many)
    CREATE TABLE posts (
      id      INTEGER PRIMARY KEY,
      user_id INTEGER NOT NULL,
      title   TEXT NOT NULL,
      body    TEXT,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    
    -- tags: one row per reusable label
    CREATE TABLE tags (
      id   INTEGER PRIMARY KEY,
      name TEXT NOT NULL UNIQUE
    );
    
    -- post_tags: the JOIN TABLE linking posts and tags  (many-to-many)
    CREATE TABLE post_tags (
      post_id INTEGER NOT NULL,
      tag_id  INTEGER NOT NULL,
      PRIMARY KEY (post_id, tag_id),
      FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
      FOREIGN KEY (tag_id)  REFERENCES tags(id)  ON DELETE CASCADE
    );
    
    -- index the foreign-key columns so JOINs stay fast
    CREATE INDEX idx_posts_user_id    ON posts(user_id);
    CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id);
  3. 塞入一些資料

    兩位使用者、三篇貼文、三個標籤,以及把貼文連到標籤的連結列。

    sql
    INSERT INTO users (id, email, name) VALUES
      (1, 'mei@example.com',  'Mei'),
      (2, 'alex@example.com', 'Alex');
    
    INSERT INTO posts (id, user_id, title) VALUES
      (10, 1, 'Hello D1'),
      (11, 1, 'JOINs explained'),
      (12, 2, 'Edge databases');
    
    INSERT INTO tags (id, name) VALUES
      (100, 'sql'),
      (101, 'cloudflare'),
      (102, 'beginner');
    
    -- the many-to-many link rows
    INSERT INTO post_tags (post_id, tag_id) VALUES
      (10, 100), (10, 101),
      (11, 100), (11, 102),
      (12, 101);
  4. 查一對多(貼文 + 作者)

    用外鍵把 posts JOIN 到 users。現在每一列結果都帶著從 users 表取來的作者姓名。

    sql
    -- One-to-many: every post WITH its author's name.
    SELECT posts.id, posts.title, users.name AS author
    FROM posts
    JOIN users ON posts.user_id = users.id
    WHERE users.id = ?
    ORDER BY posts.id DESC;
  5. 查多對多(貼文 + 標籤)

    從 posts → post_tags → tags 一路跳過去。因為一篇貼文會配對到很多標籤,用 GROUP BY + GROUP_CONCAT 把標籤摺回成「每篇貼文一列」。

    sql
    -- Many-to-many: every post with a comma list of its tags.
    SELECT posts.id,
           posts.title,
           GROUP_CONCAT(tags.name) AS tags
    FROM posts
    LEFT JOIN post_tags ON post_tags.post_id = posts.id
    LEFT JOIN tags      ON tags.id = post_tags.tag_id
    GROUP BY posts.id
    ORDER BY posts.id;
    
    -- The other direction: which posts carry the 'sql' tag?
    SELECT posts.title
    FROM posts
    JOIN post_tags ON post_tags.post_id = posts.id
    JOIN tags      ON tags.id = post_tags.tag_id
    WHERE tags.name = ?;
  6. 用 batch() 做原子寫入

    建立貼文「以及」它的標籤連結必須是「全有或全無」。env.DB.batch() 會把所有語句依序放進同一個交易執行;last_insert_rowid() 會回傳同一條連線上剛插入那篇貼文的 id。

    js
    // Create a post AND attach two tags as ONE atomic transaction.
    // If any statement fails, D1 rolls the whole batch back.
    const results = await env.DB.batch([
      env.DB
        .prepare("INSERT INTO posts (user_id, title, body) VALUES (?, ?, ?)")
        .bind(userId, title, body),
      env.DB
        .prepare("INSERT INTO post_tags (post_id, tag_id) VALUES (last_insert_rowid(), ?)")
        .bind(sqlTagId),
      env.DB
        .prepare("INSERT INTO post_tags (post_id, tag_id) VALUES (last_insert_rowid(), ?)")
        .bind(cfTagId),
    ]);
  7. 從 Worker 回傳合併好的資料

    一個端點、一次查詢:在伺服器端就把作者和標籤合好,讓瀏覽器收到可以直接渲染的列。

    js
    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
    
        // GET /api/users/:id/posts -> each post joined with author + tags
        if (request.method === "GET" && url.pathname.startsWith("/api/users/")) {
          const userId = url.pathname.split("/")[3];
    
          const { results } = await env.DB
            .prepare(
              `SELECT posts.id,
                      posts.title,
                      users.name              AS author,
                      GROUP_CONCAT(tags.name) AS tags
               FROM posts
               JOIN users          ON posts.user_id    = users.id
               LEFT JOIN post_tags ON post_tags.post_id = posts.id
               LEFT JOIN tags      ON tags.id           = post_tags.tag_id
               WHERE users.id = ?
               GROUP BY posts.id
               ORDER BY posts.id DESC`
            )
            .bind(userId)
            .all();
    
          return Response.json(results);
        }
    
        return new Response("Not found", { status: 404 });
      },
    };
  8. 在瀏覽器渲染

    前端只要 fetch JSON。每一列都已經帶著作者和標籤——不用額外請求,也不用在前端自己合併。

    js
    // Browser: load one author's posts. Each row already carries the
    // author name and tag list, thanks to the JOINs on the server.
    const res = await fetch(`/api/users/${userId}/posts`);
    const posts = await res.json();
    
    posts.forEach((post) => {
      // e.g. 'Hello D1 - by Mei - sql,cloudflare'
      console.log(`${post.title} - by ${post.author} - ${post.tags}`);
    });
schema批次 = 一個交易
D1WorkerD1Worker所有語句在同一個交易中執行alt[每一句都成功][任一句失敗]batch([插入貼文, 連結標籤1, 連結標籤2])提交並回傳 results整批回滾
bolt

一次查詢勝過很多次

沒有 JOIN 的話,你得先抓貼文,再用迴圈一筆筆去抓每個作者、每個標籤——這就是經典的「N+1」問題。一句 JOIN 讓資料庫在貼近資料的地方一次把配對做完。

school

重點術語

call_split

一對多

一筆父列擁有很多子列;每筆子列指回唯一的父列(posts → users)。

hub

多對多

兩邊都互相對應到很多筆(posts ↔ tags);沒有中介表就存不下來。

grid_view

中介表

一張小表(post_tags),唯一的工作就是為兩張表之間的每個連結存一列。

join_inner

JOIN 合併查詢

一種 SELECT,依 ON 條件配對兩張表的列,再把它們合併成更寬的列。

key

外鍵(FK)

一個必須對應到另一張表主鍵的欄位——資料庫可以替你強制檢查這件事。

lock

交易(transaction)

一組寫入,要嘛全部成功、要嘛全部失敗,讓資料保持一致。batch() 就給你一個交易。

tips_and_updates

陷阱與小提示

fact_check

D1 會強制檢查外鍵

先插父、再插子:一筆 posts 列需要它對應的 users 列先存在。若批次裡的語句彼此相依,可以加上 PRAGMA defer_foreign_keys = true; 把檢查延到交易提交時再做。

  • 一定要在外鍵欄位(user_id、tag_id)上建索引,JOIN 才不會掃整張表。
  • JOIN 可能讓列數倍增(一篇貼文 × 多個標籤);用 GROUP BY + GROUP_CONCAT 摺回成每篇貼文一列。
  • 當左側就算沒配對到也要出現時(零標籤的貼文),請用 LEFT JOIN。
  • batch() 是原子且全有全無的——最適合把「建立貼文 + 它的標籤連結」綁在一起。
  • D1 依讀取與寫入列數計費;少讀幾列的精準 JOIN 也比較省錢。
  • 把 CREATE TABLE 語句放進 schema.sql,再用 wrangler d1 execute --file=./schema.sql 套用。