
【2026年8月版】
Alopex DB v0.8.8チュートリアル 第2回:SQLで記事を検索する
公開日: 2026/08/27
読了時間: 約 20分
第1回では、キーと値を保存しました。article:1のようなキーを決めて、そのキーで1件ずつ取り出す使い方です。
この回のSQLは、v0.8.8の公開版Pythonライブラリで確認しています。第5回では、ここで使うSQLを組み込み・HTTP・gRPC・cluster-awareの各入口から同じデータへ実行します。
この方法には限界があります。「閲覧数が800以上の記事」を探そうとすると、全部のキーを引いて、値を見て、条件に合うものを自分で選ぶことになります。第2回では、同じデータベースにテーブルを作り、条件をSQLで書きます。
1. 環境
第1回と同じです。
| 項目 | 値 |
|---|---|
| Alopex DB | 0.8.8(PyPI / crates.io 公開版) |
| Python | 3.11.11 |
| Rust | 1.96.0 |
| OS | Linux(WSL2、glibc 2.35) |
| 確認日 | 2026年8月17日 |
2. テーブルを作る
execute_sql()にSQLを渡します。第1回で使ったputやgetと違い、トランザクションを開かずにそのまま呼べます。
from alopex import Database
db = Database.new()
db.execute_sql("""CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT,
author TEXT,
views INTEGER,
rating REAL
)""")このチュートリアルでは、以降ずっとこのarticlesテーブルを使います。5件のデータを入れます。
rows = [
"(1, 'Rustで書くLSMツリー', 'mio', 1200, 3.5)",
"(2, 'SQLパーサをNimで実装する', 'mio', 800, 4.5)",
"(3, 'ベクトル検索の基礎', 'ren', 1500, 4.5)",
"(4, '組み込みDBの選び方', 'ren', 600, 4.0)",
"(5, 'WALとクラッシュ復旧', 'sora', 300, NULL)",
]
for row in rows:
db.execute_sql(f"INSERT INTO articles VALUES {row}")id = 5のratingはNULLにしてあります。まだ評価が付いていない記事という想定で、NULLの扱いはこの先の章で何度か出てきます。
3. SELECT
print(db.execute_sql("SELECT id, title, views FROM articles ORDER BY id"))[{'id': 1, 'title': 'Rustで書くLSMツリー', 'views': 1200},
{'id': 2, 'title': 'SQLパーサをNimで実装する', 'views': 800},
{'id': 3, 'title': 'ベクトル検索の基礎', 'views': 1500},
{'id': 4, 'title': '組み込みDBの選び方', 'views': 600},
{'id': 5, 'title': 'WALとクラッシュ復旧', 'views': 300}]結果は辞書のリストで返ります。列名がキーになるので、row["title"]のように読めます。
ORDER BY idを書いているのは、並び順を決めるためです。書かない場合の順序は保証されないので、順序に意味があるときは必ず書きます。
4. WHEREとORDER BY
冒頭に挙げた「閲覧数が800以上の記事」を書きます。
print(db.execute_sql(
"SELECT title, views FROM articles WHERE views >= 800 ORDER BY views DESC"
))[{'title': 'ベクトル検索の基礎', 'views': 1500},
{'title': 'Rustで書くLSMツリー', 'views': 1200},
{'title': 'SQLパーサをNimで実装する', 'views': 800}]WHEREで条件を書き、ORDER BY ... DESCで降順に並べました。第1回のやり方では全キーを引いて自分で選ぶ必要があった処理が、1文になります。
5. INNER JOINとLEFT JOIN
著者の所属を別のテーブルで持ちます。
db.execute_sql("CREATE TABLE authors (name TEXT, team TEXT)")
db.execute_sql("INSERT INTO authors VALUES ('mio', 'コア')")
db.execute_sql("INSERT INTO authors VALUES ('ren', '検索')")soraは登録していないので、次の結合で違いが出ます。
記事と所属を突き合わせます。
print(db.execute_sql("""
SELECT articles.title, authors.team
FROM articles
INNER JOIN authors ON articles.author = authors.name
ORDER BY articles.id
"""))[{'title': 'Rustで書くLSMツリー', 'team': 'コア'},
{'title': 'SQLパーサをNimで実装する', 'team': 'コア'},
{'title': 'ベクトル検索の基礎', 'team': '検索'},
{'title': '組み込みDBの選び方', 'team': '検索'}]5件あったはずの記事が4件になります。soraがauthorsにいないので、id = 5の行は結果に入りません。INNER JOINは、両方のテーブルで相手が見つかる行だけを返します。
記事を全部残したい場合はLEFT JOINにします。
print(db.execute_sql("""
SELECT articles.id, articles.author, authors.team
FROM articles
LEFT JOIN authors ON articles.author = authors.name
ORDER BY articles.id
"""))[{'id': 1, 'author': 'mio', 'team': 'コア'},
{'id': 2, 'author': 'mio', 'team': 'コア'},
{'id': 3, 'author': 'ren', 'team': '検索'},
{'id': 4, 'author': 'ren', 'team': '検索'},
{'id': 5, 'author': 'sora', 'team': None}]5件すべてが返り、相手が見つからなかったteamはNoneになります。左側のテーブルの行を残すのがLEFT JOINです。
どちらを使うかは、相手が見つからない行を残したいかどうかで決めます。
6. GROUP BYと集約関数
著者ごとの本数と閲覧数の合計を出します。
print(db.execute_sql("""
SELECT author, COUNT(*) AS n, SUM(views) AS total
FROM articles
GROUP BY author
ORDER BY author
"""))[{'author': 'mio', 'n': 2, 'total': 2000},
{'author': 'ren', 'n': 2, 'total': 2100},
{'author': 'sora', 'n': 1, 'total': 300}]GROUP BY authorで著者ごとにまとめ、COUNT(*)で本数、SUM(views)で合計を出しています。AS nのように別名を付けると、結果のキーがその名前になります。
集約関数はCOUNT、SUM、AVG、MIN、MAXなどが使えます。
7. 副問い合わせ
「平均より閲覧数が多い記事」を探します。平均値を先に計算しておく必要がありますが、SQLの中に書けます。
print(db.execute_sql("""
SELECT title
FROM articles
WHERE views > (SELECT AVG(views) FROM articles)
ORDER BY id
"""))[{'title': 'Rustで書くLSMツリー'},
{'title': 'ベクトル検索の基礎'}]括弧の中のSELECT AVG(views) FROM articlesが先に評価され、その値がviews >の右辺になります。5件の平均は880なので、1200と1500の2件が残ります。
このような書き方を副問い合わせと呼びます。IN、EXISTSと組み合わせる形も使えます。
8. SQLのトランザクション
この章までのSQLはdb.execute_sql()をそのまま呼んでいて、第1回のようなbegin()とcommit()を書いていません。execute_sql()が内部でトランザクションを開き、成功したときに自動でコミットするためです。トランザクションの制御はAPIで行い、範囲を暗黙に任せるか、db.begin()で明示するかを選びます。
暗黙: execute_sqlの自動コミット
一つの呼び出しが、一つのトランザクションになります。セミコロンで区切って複数の文を渡しても同じです。
from alopex import Database
db = Database.new()
db.execute_sql("CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, author TEXT, views INTEGER, rating REAL)")
db.execute_sql("""
INSERT INTO articles VALUES (1, 'Rustで書くLSMツリー', 'mio', 1200, 3.5);
INSERT INTO articles VALUES (2, 'SQLパーサをNimで実装する', 'mio', 800, 4.5);
""")
print(db.execute_sql("SELECT id, title FROM articles ORDER BY id"))[{'id': 1, 'title': 'Rustで書くLSMツリー'}, {'id': 2, 'title': 'SQLパーサをNimで実装する'}]途中の文が失敗すると、その呼び出しで書いた分はすべて取り消されます。次の例は、2文目が主キーの重複で失敗します。
try:
db.execute_sql("""
INSERT INTO articles VALUES (3, 'ベクトル検索の基礎', 'ren', 1500, 4.5);
INSERT INTO articles VALUES (1, '重複するid', 'sora', 100, 3.0);
""")
except Exception as e:
print(e)
print(db.execute_sql("SELECT id, title FROM articles ORDER BY id"))error[ALOPEX-E999]: constraint violation: PRIMARY KEY constraint violated on columns: ["id"], value: None
[{'id': 1, 'title': 'Rustで書くLSMツリー'}, {'id': 2, 'title': 'SQLパーサをNimで実装する'}]1文目のid = 3も残っていません。1文ずつではなく、呼び出し全体でコミットするためです。
明示: beginで範囲を決める
db.begin()で開いたトランザクションからもexecute_sql()を呼べます。commit()かrollback()を呼ぶまでが一つの範囲で、その間は何度execute_sql()を呼んでも構いません。
from alopex import Database, TxnMode
with db.begin(TxnMode.READ_WRITE) as txn:
txn.execute_sql("UPDATE articles SET views = 1300 WHERE id = 1")
txn.execute_sql("INSERT INTO articles VALUES (3, 'ベクトル検索の基礎', 'ren', 1500, 4.5)")
txn.commit()
print(db.execute_sql("SELECT id, title, views FROM articles ORDER BY id"))[{'id': 1, 'title': 'Rustで書くLSMツリー', 'views': 1300},
{'id': 2, 'title': 'SQLパーサをNimで実装する', 'views': 800},
{'id': 3, 'title': 'ベクトル検索の基礎', 'views': 1500}]UPDATEとINSERTが一つの範囲に入ります。commit()をrollback()に変えると、両方とも取り消されます。
with db.begin(TxnMode.READ_WRITE) as txn:
txn.execute_sql("UPDATE articles SET views = 9999 WHERE id = 1")
txn.execute_sql("DELETE FROM articles WHERE id = 2")
print(txn.execute_sql("SELECT id, views FROM articles ORDER BY id"))
txn.rollback()
print(db.execute_sql("SELECT id, views FROM articles ORDER BY id"))[{'id': 1, 'views': 9999}, {'id': 3, 'views': 1500}]
[{'id': 1, 'views': 1300}, {'id': 2, 'views': 800}, {'id': 3, 'views': 1500}]トランザクションの中ではviewsが9999になり、id = 2が消えています。rollback()の後は両方とも元に戻ります。範囲の中の変更は、確定するまでその範囲からしか見えません。
commit()を呼ばずにブロックを抜けると、変更は破棄されます。rollback()を呼んだのと同じ結果です。実行した文が失敗するわけではなく、範囲を閉じる時点で捨てられます。
READ_ONLYで開いた場合は、書き込む文がその時点で失敗します。トランザクションを閉じるまで待たずに、execute_sql()が例外を返します。読み取りは続けられます。
BEGIN・COMMIT・ROLLBACK・SAVEPOINTをSQLの文として書く方式は、現時点では未対応です。
9. KVとSQLを組み合わせる
データの読み出しには、性質の違う二つがあります。条件を書いて複数件を絞り込む読み出しと、キーが分かっている1件を取り出す読み出しです。前者は問い合わせの最適化が要りますが、後者に要るのは目的の場所へ直接届くことだけです。
一般的なRDBMSでは、後者もSELECT ... WHERE id = ?と書きます。SQLパーサーを通り、実行計画が作られ、結果セットから値を取り出します。キーで直接引く経路はありません。
Alopex DBは、この二つを同じデータベースの中で使い分けられます。第1回のget()はSQLパーサーを通らず、キーを渡してバイト列を受け取ります。記事のメタデータをテーブルに、本文をキーバリューに置くと、それぞれに向いた経路で読めます。
from alopex import Database, TxnMode
db = Database.new()
db.execute_sql("CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, author TEXT, views INTEGER, rating REAL)")
db.execute_sql("INSERT INTO articles VALUES (1, 'Rustで書くLSMツリー', 'mio', 1200, 3.5)")
with db.begin(TxnMode.READ_WRITE) as txn:
txn.put(b"body:1", "LSMツリーは書き込みをメモリ上のテーブルへ集める。".encode())
txn.commit()
row = db.execute_sql("SELECT id, title FROM articles WHERE views >= 1000")[0]
print(row)
with db.begin(TxnMode.READ_ONLY) as txn:
print(txn.get(f"body:{row['id']}".encode()).decode()){'id': 1, 'title': 'Rustで書くLSMツリー'}
LSMツリーは書き込みをメモリ上のテーブルへ集める。SQLで条件に合う記事を見つけ、そのIDでKVから本文を取り出しました。本文のような大きな値を列に入れると、一覧を出すだけのSELECTでも読み込まれます。キーで引ける場所へ分けておけば、必要になった記事の本文だけを取りにいけます。
二つをまとめて書く
メタデータと本文を分けて置くので、書き込む操作は二つになります。片方だけが確定すると、本文のない記事や、記事のない本文ができます。8章のdb.begin()で、二つの操作を一つの範囲に入れます。
with db.begin(TxnMode.READ_WRITE) as txn:
txn.execute_sql("INSERT INTO articles VALUES (2, 'SQLパーサをNimで実装する', 'mio', 800, 4.5)")
txn.put(b"body:2", "Nimのマクロで構文木を組み立てる。".encode())
txn.rollback()
print(db.execute_sql("SELECT id FROM articles WHERE id = 2"))
with db.begin(TxnMode.READ_ONLY) as txn:
print(txn.get(b"body:2"))[]
Nonerollback()で、テーブルの行とキーの両方が取り消されました。commit()なら両方が残ります。操作は二つのままで、確定するかどうかが揃います。
ここが、この回で最も重要な実行結果です。v0.8.8でSQLの行とKVの値を同じトランザクションへ入れ、rollback()した後に、SQLの検索結果が[]、KVの読み出しがNoneになることを録画しています。二つのデータモデルを別々に戻すのではなく、一つの確定単位で扱えることが分かります。
ここで効いているのは、リレーショナルとキーバリューという異なるデータモデルに、一つのトランザクションが効いている点です。行とキーは別のスキーマで、読み書きするAPIも違います。それでもcommit()とrollback()の単位は共通です。
テーブルとキーバリューを別々の製品に置けば、同じ範囲に入れることはできません。片方が失敗したときにもう片方を打ち消す処理をアプリ側に書き、その打ち消しが失敗する場合も考えることになります。
10. Database.openによる永続化
第1回のKVと同じく、Database.open()にすればテーブルもファイルに残ります。ここでは第1回で作った./notes-dbをそのまま開きます。このディレクトリには、第1回で書いたgreetingのキーが残っています。
from alopex import Database
db = Database.open("./notes-db")
db.execute_sql("""CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT,
author TEXT,
views INTEGER,
rating REAL
)""")
db.execute_sql("INSERT INTO articles VALUES (1, 'Rustで書くLSMツリー', 'mio', 1200, 3.5)")
db.execute_sql("INSERT INTO articles VALUES (2, 'SQLパーサをNimで実装する', 'mio', 800, 4.5)")別のプロセスから開き直し、テーブルとキーの両方を読みます。
from alopex import Database, TxnMode
db = Database.open("./notes-db")
print(db.execute_sql("SELECT id, title, views FROM articles ORDER BY id"))
with db.begin(TxnMode.READ_ONLY) as txn:
print(txn.get(b"greeting").decode())[{'id': 1, 'title': 'Rustで書くLSMツリー', 'views': 1200},
{'id': 2, 'title': 'SQLパーサをNimで実装する', 'views': 800}]
helloCREATE TABLEもSELECTも、メモリ上で書いたものと同じです。変えたのはnew()をopen("./notes-db")にした一行だけです。
第1回でKVとして書いたgreetingは、テーブルを作った後も残っています。キーバリューストアとテーブルは、同じデータディレクトリの中に同居します。片方のために別の製品を用意したり、データを二か所で持ったりする必要はありません。
11. RustからSQLを実行する
第1回でKV操作をRustで書きました。SQLも同じDatabaseから実行します。
[dependencies]
alopex-embedded = "=0.8.8"execute_sql()が返すのはSqlResultです。SELECTのときはSqlResult::Queryに列と行が入るので、そこから取り出して表示します。
use alopex_embedded::{Database, SqlResult};
use std::path::Path;
fn show(db: &Database, sql: &str) -> Result<(), Box<dyn std::error::Error>> {
if let SqlResult::Query(q) = db.execute_sql(sql)? {
let names: Vec<&str> = q.columns.iter().map(|c| c.name.as_str()).collect();
println!("{}", names.join(" | "));
for row in &q.rows {
let cells: Vec<String> = row.iter().map(|v| format!("{v:?}")).collect();
println!("{}", cells.join(" | "));
}
}
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::open(Path::new("./notes-db"))?;
db.execute_sql("CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, author TEXT, views INTEGER, rating REAL)")?;
db.execute_sql("INSERT INTO articles VALUES (1, 'Rustで書くLSMツリー', 'mio', 1200, 3.5)")?;
db.execute_sql("INSERT INTO articles VALUES (2, 'SQLパーサをNimで実装する', 'mio', 800, 4.5)")?;
show(&db, "SELECT id, title, views FROM articles ORDER BY id")?;
Ok(())
}id | title | views
Integer(1) | Text("Rustで書くLSMツリー") | Integer(1200)
Integer(2) | Text("SQLパーサをNimで実装する") | Integer(800)SQLの文はPythonと同じです。違うのは結果の受け取り方です。Pythonは辞書のリストを返し、キーを列名にします。Rustは列の定義(q.columns)と行の並び(q.rows)を分けて返します。
行の各要素はSqlValueという列挙型です。Integer(1200)のように型の名前が付いて表示されるのは、この型が値と一緒に型を持っているためです。列の型を確かめたいときはそのまま表示し、値だけが必要なときはSqlValue::Integer(n) => nのように分岐して取り出します。
SqlResultにはQueryのほかにSuccessとRowsAffectedがあります。CREATE TABLEはSuccessを、INSERTはRowsAffected(1)を返します。上のshowはQueryだけを扱うので、この二つは表示されずに素通りします。
12. ここまでで使ったSQL
| 構文 | 役割 |
|---|---|
CREATE TABLE | テーブルを作る |
INSERT INTO ... VALUES | 行を追加する |
SELECT ... FROM | 列を取り出す |
WHERE | 条件で行を絞る |
ORDER BY ... [DESC] | 並び順を決める |
INNER JOIN | 両方に相手がある行だけを返す |
LEFT JOIN | 左のテーブルの行を残す |
GROUP BY と COUNT/SUM | まとめて数える |
(SELECT ...) | 問い合わせの中で値を計算する |
db.execute_sql(sql) | 一つの呼び出しを一つのトランザクションとして実行し、自動でコミットする |
txn.execute_sql(sql) | db.begin()で開いたトランザクションの中でSQLを実行する |
次回は、条件分岐、集合演算、共通テーブル式、ウィンドウ関数を扱います。