🐘

JOIN(一人自作RDBMS Advent Calendar 2025 17日目)

に公開

この記事は「一人自作RDBMS Advent Calendar 2025」17日目の記事です。

本日の実装はGitHubにあります。昨日からの差分は以下のコマンドで確認できます。

git diff --no-index day16 day17

今日のゴール

昨日システムカタログを実装し、複数テーブルを扱えるようになりました。今日はJOINを実装し、複数テーブルを結合したクエリを実行できるようにします。

JOINアルゴリズム

JOINの実装方法には主に3つのアルゴリズムがあります。

Nested Loop Join

最も単純なアルゴリズムです。外側テーブル(outer)の各行に対して、内側テーブル(inner)を全スキャンします。

Nested Loop Join

図のように、外側テーブルの各行に対して内側テーブルを毎回全スキャンします。2行×3行=6回の比較が必要で、マッチするのは3組です。

for each row r in outer:
    for each row s in inner:
        if r and s satisfy join condition:
            output (r, s)

計算量はO(N×M)で、データ量が増えると比較回数が爆発的に増加します。内側テーブルにIndexがあれば、全スキャンをIndex検索に置き換えてO(N log M)に改善できます(Index Nested Loop Join)。

Hash Join

小さい方のテーブル(build側)で結合キーのハッシュテーブルを構築し、大きい方のテーブル(probe側)をスキャンしながらハッシュルックアップします。

Hash Join

図の左側がBuild Phase、右側がProbe Phaseです。Build Phaseでは小さいテーブルからハッシュテーブルを構築します。Probe Phaseでは大きいテーブルの各行について、結合キーをハッシュ関数にかけて直接該当バケットにジャンプします。

// Build phase
for each row r in build_table:
    insert r into hash_table[hash(r.join_key)]

// Probe phase
for each row s in probe_table:
    for each row r in hash_table[hash(s.join_key)]:
        if r.join_key == s.join_key:
            output (r, s)

計算量はO(N+M)です。Nested Loop Joinのような全スキャンが不要なため高速ですが、ハッシュテーブル構築にO(N)のメモリが必要です。

Sort Merge Join

両テーブルを結合キーでソートしてからマージします。

Sort Merge Join

図のように、左右それぞれにポインタがあり、キーを比較して小さい方を進めます。キーが一致したら結果を出力します。ポインタは常に前進するだけで後戻りしないため、各行は最大1回しか参照されません。

sort outer by join_key
sort inner by join_key
merge sorted tables

計算量はO(N log N + M log M)です。既にソート済みの場合(Indexがある場合など)はソートをスキップでき、O(N+M)になります。

今回の選択

今回はNested Loop Joinを実装します。O(N×M)と効率は良くありませんが、実装が最も単純です。将来Indexを実装したらIndex Nested Loop Joinに拡張できます。

また、t1 JOIN t2 ON ... JOIN t3 ON ...のような複数JOINの連鎖と、users.idのようなテーブル修飾付きカラム参照もサポートします。

実装

FROM句のパース

FROM句をJOINを含む形にパースできるよう拡張します。

pub enum FromClause {
    Table(TableRef),
    Join {
        left: Box<FromClause>,
        right: TableRef,
        join_type: JoinType,
        condition: Expr,
    },
}

Box<FromClause>を使うことで、複数JOINを再帰的に表現できます。例えば以下のSQL:

SELECT * FROM users
JOIN orders ON users.id = orders.user_id
JOIN products ON orders.product_id = products.id;

は以下のような木構造のASTになります(左結合):

          Join
         /    \
      Join   products
     /    \     ON orders.product_id = products.id
  users  orders
           ON users.id = orders.user_id

カラム参照の解決

JOINでは複数テーブルに同名のカラムがある場合があります(例:users.idとorders.id)。Analyzerでテーブル修飾付きカラム参照を解決できるようにします。

pub enum Expr {
    Column {
        table: Option<String>,  // テーブル名(省略可)
        name: String,
    },
    // ...
}

テーブル名が指定されていればそのテーブルから、省略されていれば全テーブルから一意に解決します(複数テーブルに同名カラムがあれば曖昧としてエラー)。

Nested Loop Join Executor

Executorは以下の状態を持ちます。

変数 説明
current_left 現在処理中の左タプル(None なら次の左タプルを取得)
left_matched 現在の左タプルが右テーブルのいずれかとマッチしたか

主要な操作:

  • left.next() / right.next(): 左/右のExecutorから次のタプルを取得
  • right.open(): 右テーブルを先頭から再スキャン(左の行が変わるたびに呼ぶ)

これらを使った実行フローです。

fn next(&mut self) -> Result<Option<Tuple>> {
    loop {
        if self.current_left.is_none() {
            match self.left.next()? {
                Some(left_tuple) => {
                    self.current_left = Some(left_tuple);
                    self.left_matched = false;  // 新しい左行、まだマッチしていない
                    self.right.open()?;         // 右テーブルを最初から再スキャン
                }
                None => return Ok(None),  // 左テーブル終了
            }
        }

        let left_tuple = self.current_left.as_ref().unwrap().clone();

        match self.right.next()? {
            Some(right_tuple) => {
                let combined = self.combine_tuples(&left_tuple, &right_tuple);
                if evaluate_predicate(&self.condition, &combined)? {
                    self.left_matched = true;  // マッチした
                    return Ok(Some(combined));
                }
                // マッチしなければ次の右行へ
            }
            None => {
                // 右テーブルを全部見終わった
                if self.join_type == JoinType::Left && !self.left_matched {
                    // LEFT JOIN かつ マッチしなかった → NULLで埋めて出力
                    let right_null = self.make_right_null_tuple();
                    let combined = self.combine_tuples(&left_tuple, &right_null);
                    self.current_left = None;
                    return Ok(Some(combined));
                }
                self.current_left = None;  // 次の左行へ
            }
        }
    }
}

動作確認

cargo run -- --init
psql -h localhost -p 5433

テストデータの準備

CREATE TABLE users (id INT, name VARCHAR);
CREATE TABLE orders (id INT, user_id INT, amount INT);

INSERT INTO users VALUES (1, 'Alice');
INSERT INTO users VALUES (2, 'Bob');
INSERT INTO users VALUES (3, 'Charlie');

INSERT INTO orders VALUES (100, 1, 500);
INSERT INTO orders VALUES (101, 1, 300);
INSERT INTO orders VALUES (102, 2, 1000);

INNER JOIN

SELECT * FROM users JOIN orders ON users.id = orders.user_id;
 id | name  | id  | user_id | amount
----+-------+-----+---------+--------
  1 | Alice | 100 |       1 |    500
  1 | Alice | 101 |       1 |    300
  2 | Bob   | 102 |       2 |   1000
(3 rows)

LEFT JOIN

SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id;
 id | name    | id  | user_id | amount
----+---------+-----+---------+--------
  1 | Alice   | 100 |       1 |    500
  1 | Alice   | 101 |       1 |    300
  2 | Bob     | 102 |       2 |   1000
  3 | Charlie |     |         |
(4 rows)

テーブル別名

SELECT u.name, o.amount FROM users u JOIN orders o ON u.id = o.user_id;
 name  | amount
-------+--------
 Alice |    500
 Alice |    300
 Bob   |   1000
(3 rows)

今後の課題

  • Index Nested Loop Join: Indexを実装したら、内側テーブルの全スキャンをIndex検索に置き換えてO(N log M)に改善可能

次回予告

今日でJOINを実装し、複数テーブルを結合したクエリを実行できるようになりました。

次回はAggregate(GROUP BY / COUNT / SUM など) を実装し、集計クエリに対応します。

GitHubで編集を提案
株式会社primeNumber

Discussion