以前、SQLのCTEを使って階層構造を持つデータから、祖先のデータを取得してみました。
django-cteと共通テーブル式(CTE)を用いた再帰クエリにより、階層構造を持つテーブルからデータを取得する - メモ的な思考的な
このときは django-cte の利用がメインだったため、祖先は1つ (りんごの種子親) だけを扱いました。
ただ、現実のりんごの祖先は単一ではなく、
- 花粉親 (父)
- 種子親 (母)
がそれぞれ存在します。また、当然ながら、それ以前の代のりんごも存在します。
例えば、シナノリップの場合、祖先をたどると以下のような感じになります。
- シナノリップ
- (父) シナノレッド
- (父) ビスタベラ
- (父) 不明
- (母) 不明
- (母) つがる
- (父) 紅玉
- (母) ゴールデンデリシャス
- (父) ビスタベラ
- (母) 千秋
- (父) ふじ
- (父) デリシャス
- (母) 国光
- (母) 東光
- (父) 印度
- (母) ゴールデンデリシャス
- (父) ふじ
- (父) シナノレッド
そこで今回、「1つのりんごを指定したときに、曽祖父母までのりんご名をすべて取得して横持ちにする」を実装してみます。
例えば、シナノリップを指定した場合は、以下のような形で取得します。
りんご | 父 | 父父 | 父父父 | 父父母 | ... | 母母母 |
---|---|---|---|---|---|---|
シナノリップ | シナノレッド | ビスタベラ | 不明 | 不明 | ... | ゴールデンデリシャス |
目次
- 環境
- drizzle-ormまわりのセットアップ
- テーブル設計
- drizzle-ormでのschema定義
- マイグレーションファイルの生成と適用
- seedファイルの生成と実行
- 生SQLを用いたdrizzle-ormでの実装
- 動作確認
- ソースコード
環境
まず、データの保管先とその取得方法ですが、今回は
とします。
なお、テーブルの主キーに意味を持たせたほうがわかりやすそうと感じたため、テーブルの主キーは自然キーとします。
次に、動作確認を容易にしたいため、何かしらのORMを用意します。
そこで、自然キーを主キーにできるORMを調べてみたところ、 drizzle-orm
が自然キーを主キーにできそうでした。
Drizzle ORM - Indexes & Constraints
そこで、ORMは drizzle-orm
を使うことにします。
ところで、drizzle-ormを使うときには何かしらのdriverが必要になります。
ただ、実行環境としてBunを選択すると、driverを追加することなくSQLiteを扱えます。
https://orm.drizzle.team/docs/get-started-sqlite#bun-sqlite
そこで、今回は実行環境としてBunを選択します。
以上を踏まえた、今回の環境は次の通りです。
- Windows11 WSL2
- SQLite
- drizzle-orm 0.33.0
- drizzle-kit 0.24.2
- Bun 1.1.27
drizzle-ormまわりのセットアップ
SQLを試す前に、必要な環境をセットアップします。
# Bunのバージョンを確認 $ bun -v 1.1.27 # Bunプロジェクトを作成 $ bun init bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit package name (ringo_genealogies_with_drizzle): ringo_genealogies_with_drizzle entry point (index.ts): Done! A package.json file was saved in the current directory. + index.ts + .gitignore + tsconfig.json (for editor auto-complete) + README.md To get started, run: bun run index.ts # drizzle-ormをインストール $ bun add drizzle-orm bun add v1.1.27 (267afa29) installed drizzle-orm@0.33.0 1 package installed [342.00ms] # マイグレーションコマンドが使えるようになる、drizzle-kitもインストール $ bun add -D drizzle-kit bun add v1.1.27 (267afa29) installed drizzle-kit@0.24.2 with binaries: - drizzle-kit 16 packages installed [1.88s]
次に、drizzle-orm のSQLite向け設定ファイルを drizzle.config.ts
として作成します。
import { defineConfig } from "drizzle-kit" export default defineConfig({ dialect: "sqlite", schema: "./src/schema/*", out: "./drizzle", })
続いて、Bun用の設定ファイルを作成します。というのも、drizzle-orm の migrate コマンドについてはBunでそのまま使えないためです。
今回はBunの公式ドキュメントに従い、Bun向けのマイグレーションコマンドファイルを作成します。
https://bun.sh/guides/ecosystem/drizzle
これでBunでもマイグレーションが実行できるようになります。
import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { Database } from "bun:sqlite"; const sqlite = new Database("sqlite.db"); const db = drizzle(sqlite); await migrate(db, { migrationsFolder: "./drizzle" });
テーブル設計
今回は
- りんご情報を持つ
apples
テーブル - 親子関係を階層構造として持つ
genealogies
テーブル
の2つを用意します。
続いて、 genealogies
の構造ですが、父母情報を縦持ち・横持ちのどちらにするかを考えます。
縦持ちの場合はこんな感じです。relationに 父
か 母
的な値を入れ、1つの子りんごに対し、2レコードで父母情報を管理します。
sqliteTable("genealogies", { parent_name: text('parent_name').notNull().references(() => apples.name), child_name: text('child_name').notNull().references(() => apples.name), relation: text('relation').notNull() }, (table) => { return { pk: primaryKey({ columns: [table.parent_name, table.child_name, table.relation] }) }
一方、横持ちはこんな感じで、1レコードで父母情報を管理します。
sqliteTable("genealogies", { child_name: text('child_name').notNull().references(() => apples.name), pollen_name: text('pollen_name').notNull().references(() => apples.name), seed_name: text('seed_name').notNull().references(() => apples.name), }, (table) => { return { pk: primaryKey({ columns: [table.child_name, table.pollen_name, table.seed_name] }) } })
では、どうするか考えます。
初期データを用意する際に、前者だとデータ量が多くなる & うっかり親子関係を間違えやすそうと感じました。
また、一度りんごの親子関係を作ってしまえば、基本的に親子関係は不変と考えました*1。
そこで、今回は後者の「1レコードで父母情報を管理」とするテーブル設計としました。
ちなみに、これは以下の記事にある 閉包テーブル
のような感じになりました。
MySQLで階層構造を扱うための再帰的なクエリの実装方法と実用例
drizzle-ormでのschema定義
テーブル設計ができたので、次はdrizzle-ormのschemaとして実装します。
まずは apples
テーブルを表す apples.ts
をディレクトリ scr/schema
の中に用意します。
import {sqliteTable, text} from "drizzle-orm/sqlite-core"; export const apples = sqliteTable("apples", { name: text('name').primaryKey(), display_name: text('display_name').unique().notNull(), color: text('color').notNull() })
続いて、親子関係を持つ genealogies
テーブルを表す genealogies.ts
も用意します。
import {primaryKey, sqliteTable, text} from "drizzle-orm/sqlite-core"; import {apples} from "./apples.ts"; export const genealogies = sqliteTable("genealogies", { child_name: text('child_name').notNull().references(() => apples.name), pollen_name: text('pollen_name').notNull().references(() => apples.name), seed_name: text('seed_name').notNull().references(() => apples.name), }, (table) => { return { pk: primaryKey({ columns: [table.child_name, table.pollen_name, table.seed_name] }) } })
マイグレーションファイルの生成と適用
schemaファイルができたので、マイグレーションファイルを生成します。
$ bun run generate
続いて、マイグレーションファイルを適用します。Bunの場合は先ほど作成した migrate.ts
を使って適用します。
$ bun run migrate.ts
sqlite.db
が生成されました。中を見てみるとテーブルができていました。
seedファイルの生成と実行
毎回データ投入を行うのは手間なので、seedファイル( seed.ts
)を用意します。
内容は以下ですが、記事の都合上一部を省略しています。全体像は以下のファイルを参照してください。
https://github.com/thinkAmi-sandbox/ringo_genealogies_with_drizzle/blob/main/seed.ts
import { db } from "./db"; import {apples} from "./src/schema/apples.ts"; import {genealogies} from "./src/schema/genealogies.ts"; await db.insert(apples).values([ { name: "unknown", display_name: "不明", color: "不明" }, { name: "shinano_sweet", display_name: "シナノスイート", color: "黃" }, // 略 ]); await db.insert(genealogies).values([ { child_name: "unknown", pollen_name: "unknown", seed_name: "unknown" }, { child_name: "shinano_sweet", pollen_name: "tsugaru", seed_name: "fuji" }, { child_name: "tsugaru", pollen_name: "kougyoku", seed_name: "golden_delicious" }, { child_name: "kougyoku", pollen_name: "unknown", seed_name: "unknown" }, { child_name: "golden_delicious", pollen_name: "unknown", seed_name: "unknown" }, // 略 ]) console.log(`Seeding complete.`);
続いて、seedを投入します。
$ bun run seed.ts
生SQLを用いたdrizzle-ormでの実装
再掲となりますが、今回は「1つのりんごを指定したときに、曽祖父母までのりんご名をすべて取得して横持ちにする」を実現したいです。これはSQLのCTE (共通テーブル式) を使えば良さそうです。
ただ、drizzle-orm ではまだCTEをサポートしていません。
drizzle-ormでは sql
テンプレートを使って生SQLも扱えます。
Drizzle ORM - Magic sql`` operator
そこで、まずは以下のような生SQLを用意します。
WITH RECURSIVE ancestry AS ( -- 最初に、検索したいりんごの情報を取得 SELECT g.child_name, a.display_name as child_display_name, g.pollen_name, g.seed_name, 0 AS generation FROM genealogies g INNER JOIN apples a ON g.child_name = a.name WHERE child_name = ${targetName} UNION ALL -- 再帰的に父母や祖父母、曽祖父母を取得 SELECT g.child_name as child_name, apples.display_name as child_display_name, g.pollen_name as pollen_name, g.seed_name as seed_name, a.generation + 1 FROM genealogies g INNER JOIN ancestry a ON g.child_name = a.pollen_name OR g.child_name = a.seed_name INNER JOIN apples ON g.child_name = apples.name WHERE a.generation < 3 -- 曽祖父母まで取得する ) -- 結果を横持ちで表示(重複して取得できてしまうので、DISTINCTが必要) SELECT DISTINCT a.child_display_name AS "Apple Name", p.child_display_name AS "Father", pp.child_display_name AS "Grandfather (Father's side)", ppp.child_display_name AS "Great-Grandfather (Father's Father's side)", pps.child_display_name AS "Great-Grandmother (Father'sFather's side)", ps.child_display_name AS "Grandmother (Father's side)", psp.child_display_name AS "Great-Grandfather (Father's Mother's side)", pss.child_display_name AS "Great-Grandmother (Father's Mother's side)", s.child_display_name AS "Mother", sp.child_display_name AS "Grandfather (Mother's side)", spp.child_display_name AS "Great-Grandfather (Mother's side)", sps.child_display_name AS "Great-Grandmother (Mother's Father's side)", ss.child_display_name AS "Grandmother (Mother's side)", ssf.child_display_name AS "Great-Grandfather (Mother's Mother's side)", sss.child_display_name AS "Great-Grandmother (Mother's Mother's side)" FROM ancestry a -- 父系 -- 父親を結合 LEFT JOIN ancestry p ON p.child_name = a.pollen_name AND p.generation = 1 -- 父親の父親(祖父)を結合 LEFT JOIN ancestry pp ON pp.child_name = p.pollen_name AND pp.generation = 2 -- 父親の母親(祖母)を結合 LEFT JOIN ancestry ps ON ps.child_name = p.seed_name AND ps.generation = 2 -- 父親の父親の父親(曽祖父)を結合 LEFT JOIN ancestry ppp ON ppp.child_name = pp.pollen_name AND ppp.generation = 3 -- 父親の父親の母親(曽祖母)を結合 LEFT JOIN ancestry pps ON pps.child_name = pp.seed_name AND pps.generation = 3 -- 父親の母親の父親(曽祖父)を結合 LEFT JOIN ancestry psp ON psp.child_name = ps.pollen_name AND psp.generation = 3 -- 父親の母親の母親(曽祖母)を結合 LEFT JOIN ancestry pss ON pss.child_name = ps.seed_name AND pss.generation = 3 -- 母系 -- 母親を結合 LEFT JOIN ancestry s ON s.child_name = a.seed_name AND s.generation = 1 -- 母親の父親(祖父)を結合 LEFT JOIN ancestry sp ON sp.child_name = s.pollen_name AND sp.generation = 2 -- 母親の母親(祖母)を結合 LEFT JOIN ancestry ss ON ss.child_name = s.seed_name AND ss.generation = 2 -- 母親の父親の父親(曽祖父)を結合 LEFT JOIN ancestry spp ON spp.child_name = sp.pollen_name AND spp.generation = 3 -- 母親の父親の母親(曽祖母)を結合 LEFT JOIN ancestry sps ON sps.child_name = sp.seed_name AND sps.generation = 3 -- 母親の母親の父親(曽祖父)を結合 LEFT JOIN ancestry ssf ON ssf.child_name = ss.pollen_name AND ssf.generation = 3 -- 母親の母親の母親(曽祖母)を結合 LEFT JOIN ancestry sss ON sss.child_name = ss.seed_name AND sss.generation = 3 WHERE a.generation = 0;
この生SQLをdrizzle-ormで実行するために、以下のような index.ts
を用意します。
なお、 <上記のSQLをそのまま貼る>
の記載など、全体を見たい場合は以下のソースコードを参照してください。
https://github.com/thinkAmi-sandbox/ringo_genealogies_with_drizzle/blob/main/index.ts
import {db} from "./db.ts"; import {sql} from "drizzle-orm"; # ここではシナノリップの系統データを取得する const targetName = 'shinano_lip' const result = await db.all( sql`<上記のSQLをそのまま貼る>` ) console.log(result)
動作確認
先ほど実装した index.ts
を実行したところ、シナノリップの祖先を取得できました。
$ bun run index.ts [ { "Apple Name": "シナノリップ", Father: "シナノレッド", "Grandfather (Father's side)": "ビスタベラ", "Great-Grandfather (Father's Father's side)": "不明", "Great-Grandmother (Father'sFather's side)": "不明", "Grandmother (Father's side)": "つがる", "Great-Grandfather (Father's Mother's side)": "紅玉", "Great-Grandmother (Father's Mother's side)": "ゴールデンデリシャス", Mother: "千秋", "Grandfather (Mother's side)": "ふじ", "Great-Grandfather (Mother's side)": "デリシャス", "Great-Grandmother (Mother's Father's side)": "国光", "Grandmother (Mother's side)": "東光", "Great-Grandfather (Mother's Mother's side)": "印度", "Great-Grandmother (Mother's Mother's side)": "ゴールデンデリシャス", } ]
奥州ロマンの場合はこんな感じです。こちらも良さそうです。
[ { "Apple Name": "奥州ロマン", Father: "つがる", "Grandfather (Father's side)": "紅玉", "Great-Grandfather (Father's Father's side)": "不明", "Great-Grandmother (Father'sFather's side)": "不明", "Grandmother (Father's side)": "ゴールデンデリシャス", "Great-Grandfather (Father's Mother's side)": "不明", "Great-Grandmother (Father's Mother's side)": "不明", Mother: "シナノゴールド", "Grandfather (Mother's side)": "千秋", "Great-Grandfather (Mother's side)": "ふじ", "Great-Grandmother (Mother's Father's side)": "東光", "Grandmother (Mother's side)": "ゴールデンデリシャス", "Great-Grandfather (Mother's Mother's side)": "不明", "Great-Grandmother (Mother's Mother's side)": "不明", } ]
ソースコード
GitHubに上げました。
https://github.com/thinkAmi-sandbox/ringo_genealogies_with_drizzle
今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/ringo_genealogies_with_drizzle/pull/1
*1:稀に、遺伝子の検査などで親子の見直しが発生することもあるようです