
2026/04/27 23:07
Postgres の LATERAL ジョインは、かなり優れた eDSL(埋め込みドメイン特定言語)の実現を可能にします。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
rust-rel8 の紹介。PostgreSQL の lateral joins を活用し、型安全で合成可能なデータベースクエリを可能にする新しい Rust ライブラリです。2026-03-15 に公開されたこのプロジェクトは、lateral joins が FROM 節に先行する列をサブクエリで使用することを許容し、標準的な join を表現豊かな「CROSS JOIN LATERAL ... WHERE」形式に変換しつつ、従来のアプローチと同じクエリ計画を維持することを示しています。著者は、lateral joins が多くの ORM やクエリビルダーに欠けている表現力と合成可能性を提供すると主張しており、特にクエリの統合や多対多の関係性を扱い、かさばるマクロの魔法あるいは深くネストしたジェネリクスを必要としない点に価値を見出しています。sqlx などのツールは「generate bindings」によって型安全性を提供しますが、それらは依然として合成性は低いものです。一方、rust-rel8 は eDSL アプローチを採用し、「表現力豊か」「合成可能」「型安全」「常に有効な SQL を生成する」という 4 つの目標を達成するとともに、ユーザー定義タイプと集計機能をサポートします。Haskell の Rel8 に着想を得て、Rust でその人体工学的クエリ構築パターンを再現しており、ユーザーごとに投稿を取得し、エンティティを組み合わせて集約結果を集約する、.optional() を通じてオプションな join、カスタムテーブル宣言、集計ビルダーなどを実装しています。実行時オーバーヘッドなしにコンパイル時にスコープルールを強制するために、ライブラリは Table、ForLifetimeTable、ShortenLifetime、TableHKT、MapTable などのコア特性によって管理される 'scope というスコープ付きライフタイムを使用しています。これらの特性は、列を穿通し、クエリ構築後にライフタイムを入れ替え、モード(NameMode、ValueMode、ExprMode、EmptyMode)間でのフィールドタイプのマッピングを実現します。これにより、sea_query から取得されたクエリ AST が Expr タイプでラップされ、有効な SQL 生成が保証されます。本文
PostgreSQL の Later Join を活用した高機能な eDSL
公開日: 2026 年 3 月 15 日
Later Join(ラテラル JOIN)は非常に巧妙であり、それを用いてクエリの関数型プログラムライブラリ(eDSL: expression-driven programming language)を構築できます。
Later Join の背景
PostgreSQL(そして少数の他のデータベース)には、あまり知られていないか利用されていないという種類の JOIN、すなわち
LATERAL JOIN があります。これらは、結合するサブクエリ内で前述の FROM クロージーズから参照されるカラムを使用することを可能にします。
通常のクエリと Later Join
例として、以下の標準的なテーブル結合クエリを考えてみましょう。
SELECT * FROM users u INNER JOIN posts p ON u.id = p.user_id
これを Later Join で以下のように書き換えることができます。
SELECT * FROM users u CROSS JOIN LATERAL ( SELECT * FROM posts p WHERE u.id = p.user_id ) p2
結合の種類が
INNER JOIN から CROSS へと変わっているのがお分かりでしょう。通常、これはデカルト積(カトリアン・プロダクト)を意味するはずですが、サブクエリ内のフィルタ条件があるため、各投稿は引き続きそのユーザーとのみペアリングされます。
実際には、両方のクエリは同じ実行計画を取得します:
+----------------------------------------------------------------+ | QUERY PLAN | +----------------------------------------------------------------+ | Hash Join (cost=37.00..60.52 rows=1070 width=88) | | Hash Cond: (p.user_id = u.id) | | -> Seq Scan on posts p (cost=0.00..20.70 rows=1070 width=48) | | -> Hash (cost=22.00..22.00 rows=1200 width=40) | | -> Seq Scan on users u (cost=0.00..22.00 rows=1200 width=40) | +----------------------------------------------------------------+
表現力の問題点
これは実際には非常に有用であり、多くの ORM(オブジェクト・リレーションシップ・マッパー)やクエリビルダが抱える「表現力の問題」を解決する方法を提供します。すなわち、クエリの組み立て(合成)が困難であるという問題です。
他のクエリビルダーにおける多くの組み込み技術は、要するに次のいずれかの方法に行われていると私は考えます。
- クエリビルダーオブジェクトを関数列を通じて渡し、各関数が結合対象のテーブルと
節を追加していく手法。WHERE - サブクエリを返す関数を用意し、その結果に対して自分で結合処理を行う手法。
どちらも非常に優れたアプローチとは言えません:
- 前者は恐らく動的型付け言語でのみ機能します。
- 後者では、
→posts_of_users(user_id)
といった「関数」を提供する方法がありません。[Post]
さらに悪いのは、ORM が関係性を非表示に抽象化してしまうことです。最初は
select(User).with(Post) と書くと自動で JOIN が構築されたり、多対多(M2M)の結合を処理できたりするのは魅力的ですが、すぐに何かを更新する必要があるとすると、自分で結合テーブルを管理するよりもさらに退屈になります。私は以前、通常のインターフェースではまずすべての値を読み込んでからエントリーを追加または削除する必要があり、そうでないと ORM があなたのデータを挿入前にすべてを削除してしまうように指示してしまったり、M2M の一方側で重複を作成しようとして失敗したりする光景を目撃したこともあります。
また、どの
.with() メソッドを持つ ORM を使用する場合でも、型付けされた言語においては地獄の果てです:
- 全ての可能な組み合わせに対して
といった型を呼び出すようなマクロマジックを使用するか。FooWithBar - それとも、
といった型を生み出し、それがWith<Foo, Bar>
といった恐ろしくネストされた型化されてしまい、IDE の助けなしには扱えない状態に追い込まれるか。With<With<Foo, Bar>, Baz>
また、純粋な SQL クエリ(別ファイルまたはインライン)を書いてコンパイル時のコード実行でホスト言語用の関数を生成する「バインド生成(generate bindings)」アプローチ(例:sqlx)もあまり好きではありません。これでは結局 SQL を書き直さなければなりませんが、少なくとも型安全性は保てます。しかしながら、これは他のクエリ構築形式と比較して厳密に組み込み性(composability)が劣ります。
理想的な eDSL
私は以下の全てを満たす種類のクエリビルダーライブラリの提示を望んでいます:
- 表現力豊かであること:複雑な結合を行うクエリは不明瞭であってはいけません。理想としては、eDSL で表した場合の方が SQL の方がより明確に表現されるべきです。
- 組み合わせ可能であること:クエリは再利用性があり、パラメータ化可能であるべきです。
- 型安全性であること:eDSL はホスト言語の型システム内で動作し、クエリが正しく型付けされていることを保証します。
- 常に有効な SQL を生成すること:これは集計操作において特に有用であり、集計演算子を使用すると、使用该されるクエリの部分全体の要件が変わってしまうためです。
- ユーザー定義の型と連携すること:ORM の魅力の一つは、データベースとの互換性を確保するためのユーザー型のボイラープレート処理を ORM が行う点にあると考えています。
既存のソリューション:Haskell の Rel8
私が最初に遭遇した、Later Join を用いて組み合わせ可能にクエリを構築する方法を提供するライブラリは、Haskell ライブラリの
Rel8 です。これは以下のような外観です。
postsForUser :: Expr UserId -> Query (Post Expr) postsForUser userId = do post <- each postSchema where_ $ post.userId ==. userId pure post usersAndPosts :: Query (User Expr, Post Expr) usersAndPosts = do user <- each userSchema post <- postsForUser user.id pure (user, post)
このインターフェースは非常に表現力豊かで、実際にはすでにホスト言語内に存在するデータを操作しているかのような感覚を受けますが、実際には SQL クエリを構築しており、以下のような結果になります。
SELECT CAST("id0_1" AS "int4") as "_1/id", CAST("name1_1" AS "bpchar"(1)[]) as "_1/name", CAST("id0_3" AS "int4") as "_2/id", CAST("user_id1_3" AS "int4") as "_2/userId", CAST("body2_3" AS "bpchar"(1)[]) as "_2/body" FROM ( SELECT * FROM ( SELECT "id" as "id0_1", "name" as "name1_1" FROM "user" AS "T1" ) AS "T1", LATERAL ( SELECT "id" as "id0_3", "user_id" as "user_id1_3", "body" as "body2_3" FROM "post" AS "T1" ) AS "T2" WHERE (("user_id1_3") = ("id0_1")) ) AS "T1"
ポイントとして、
Expr UserId は実際には UserId を含んでおらず、代わりに(この例の場合)SQL 表現 id0_1 を含んでいます。これはその後、postsForUser で他の SQL 表現(リテラルなど)のように使用できます。
一方、
Query は単なる SELECT ... 節であり、do ブロックの各行がクエリを追加し、追加されるのは CROSS JOIN LATERAL ... です。また、where_ は単に構築中のクエリに WHERE 節を導入します。
Haskell のスコープ問題
さて、これはもちろん明らかな問題を持っています:
id0_1 は有効なのは、そのカラムを導入するサブクエリの兄弟サブクエリ内であり、かつ構文上はその後ろにある位置にあるときのみです。通常、クエリビルダーでは無効なクエリの生成をできるだけ困難にするのが望ましいとされ、そのようなデバッグは常に大変なものだからです。
Haskell ではこれが本質的に解決されています:
Expr の値は Query モナドの「内部」にのみ存在し、その外に取り出すことはできません。そのため、Expr は導入された後、かつ導入されたスコープ(同じ Query 内)またはそれよりも深いスコープ(関数を呼び出して作成された別の Query 内)でのみ使用できるという制約があります。
私の Rust 実装:rust-rel8
rust-rel8さて、ここからは Haskell についての話は停止し、私が本当に話したいのは Rust で書かれたライブラリ
rust-rel8(魅力的なプロジェクト名が思いつくまでその名前で呼ぶでしょう)です。これは Rel8 の動作を再現するものです。
私のライブラリは Rel8 と非常に似ていますが、Rust 版です。
fn posts_of_user(user_id: Expr<i32>) -> Query<Post> { query::<Post<ExprMode>>(|q| { let post = q.q(Query::each(&Post::SCHEMA)); q.where_(user_id.equals(post.user_id.clone())); post }) } let q = query::<(User<ExprMode>, Post<ExprMode>)>(|q| { let user = q.q(Query::each(&User::SCHEMA)); let post = q.q(posts_of_user(user.id.clone())); (user, post) }) .order_by(|x| (x.clone(), sea_query::Order::Asc)); let rows = q.all(&mut *pool).await.unwrap();
さらにクールなことに、
posts_of_user の結果を集計することができ、データベースから出力される結果が (User, Vec<Post>) になるようにすることができます。
let q = query::<(User<ExprMode>, ListTable<Post<ExprMode>>)>(|q| { let user = q.q(Query::each(&User::SCHEMA)); let posts = q.q(posts_of_user(user.id.clone()).many()); // その .many() が Query<T> を Query<ListTable<T>> に変換します (user, posts) }) .order_by(|x| (x.0.name.clone(), sea_query::Order::Asc)); let rows: Vec<(User, Vec<Post>)> = q.all(&mut *pool).await.unwrap();
さらに驚くべきことに、左外部結合は
.optional() を使用して作成できます。
fn latest_post_of_user(user_id: Expr<i32>) -> Query<MaybeTable<Post>> { query::<Post<ExprMode>>(|q| { let post = q.q(Query::each(&Post::SCHEMA)); q.where_(user_id.equals(post.user_id.clone())); post }) .order_by(|x| (x.id.clone(), sea_query::Order::Desc)) .limit(1) .optional() } let q = query::<(Expr<String>, Expr<Option<String>>)>(|q| { let user = q.q(Query::values(demo_users.shorten_lifetime())); let post = q.q(latest_post_of_user(user.id.clone())); // ここでの `post` は `MaybeTable<Post>` です。これより `Expr<Option<T>>` を投影するか、 // またはクエリからそのまま返すこともでき、後者の場合はデコード後に `Option<T>` が得られます。 let post_content = post.project(|p| p.contents.clone()); (user.name, post_content) }) .order_by(|x| (x.clone(), sea_query::Order::Asc)); let rows = q.all(&mut *pool).await.unwrap(); assert_eq!( vec![ ("Huldra".to_owned(), None), ("Leschy".to_owned(), Some("Quak!".to_owned())), ("Undine".to_owned(), Some("Croak".to_owned())) ], rows )
さらに、独自のテーブルを宣言することもできます。
#[derive(Debug, PartialEq, rust_rel8_derive::TableStruct)] struct User<'scope, Mode: TableMode = ExprMode> { id: Mode::T<'scope, i32>, name: Mode::T<'scope, String>, } impl<'scope> User<'scope, NameMode> { const SCHEMA: TableSchema<Self> = TableSchema { name: "users", columns: User { id: "id", name: "name", }, }; } #[derive(Debug, PartialEq, rust_rel8_derive::TableStruct)] struct Post<'scope, Mode: TableMode = ExprMode> { id: Mode::T<'scope, i32>, user_id: Mode::T<'scope, i32>, body: Mode::T<'scope, String>, } impl<'scope> Post<'scope, NameMode> { const SCHEMA: TableSchema<Self> = TableSchema { name: "posts", columns: Post { id: "id", user_id: "user_id", body: "body", }, }; }
.aggregate ビルダーも、SQL でそれらを作成する際に通常遭遇する苦労なしに集計を構築できるように設計されています。
let q = query::<Two<_, i32, i32>>(|q| { let a = q.q(Query::values([ Two { a: 1, b: 1 }, Two { a: 1, b: 2 }, Two { a: 1, b: 3 }, Two { a: 1, b: 4 }, Two { a: 2, b: 1 }, Two { a: 2, b: 2 }, Two { a: 3, b: 1 }, ])); a }) .aggregate::<(Expr<i32>, ListTable<Expr<i32>>, ListTable<Two<_, i32, i32>>)>(|a, e| { let x = a.group_by(e.a.clone()); let y = a.array_agg(e.a.clone().add(Expr::lit(1i32))); let as_array = a.array_agg(e); // .aggregate は、出力に含まれるすべてのものが `a` を通過したものでなければならず、 // したがってグループ化の一部として使用されるか、集計関数の一部である必要があることを強制します。 (x, y, as_array) }); let rows = q.all(&mut *pool).await.unwrap(); assert_eq!( vec![ ( 1, vec![2, 2, 2, 2], vec![ Two { a: 1, b: 4 }, Two { a: 1, b: 3 }, Two { a: 1, b: 2 }, Two { a: 1, b: 1 } ] ), (3, vec![4], vec![Two { a: 3, b: 1 }]), (2, vec![3, 3], vec![Two { a: 2, b: 2 }, Two { a: 2, b: 1 }]) ], rows );
実装の詳細
ここでの実行可能なマクロは、ライブラリがあなたのテーブルの各カラムを巡回する方法を知るために必要ないくつかのトレイトを実装しています。最も魔法的な部分は、
TableMode に依存してフィールドの型を U, Expr<U>, および String に切り替えるこの Mode::T<'scope, U> ADT にあります。これにより、eDSL 内でユーザー定義の型を使用できると同時に、結果値の外でも使用できるようになります。
当然のことながら、Rust のため Haskell 版より少し冗長ですが、Rel8 の実際の API は Rust への変換は比較的小さいです。
それはどのように機能しているか?
基本概念:Expr
と Query
ExprQueryまず、スカラー表現を表す型が必要です。これには
Expr という名前を付けましょう。
pub struct Expr<'scope, T> { expr: ExprInner, _phantom: PhantomData<(&'scope (), T)>, }
ここで
'scope は表現のスコープルールを強制するために使用されるライフタイムです(現在は無視して構いませんし、後で説明します)。PhantomData は必要であり、これは Expr が実際には T も 'scope も含まないためです。
ExprInner は単純に私が下流で使用しているクエリビルダーライブラリ(sea_query)の AST をラップするものであり、内部に含まれるカラムへの参照を巡回することを可能にします。
この
Expr には、スカラー上で一般的な SQL 操作に対応するいくつかの方法があります。
impl<'scope, T> Expr<'scope, T> { /// 任意のエンコード可能な値からリテラル値を作成します。 pub fn lit(value: T) -> Self where T: Into<sea_query::Value>, { Self::new(ExprInner::Raw(sea_query::Expr::value(value.into()))) } fn binop<U>( self, other: Self, binop: Arc<dyn Fn(sea_query::SimpleExpr, sea_query::SimpleExpr) -> sea_query::SimpleExpr>, ) -> Expr<'scope, U> { Expr::new(ExprInner::BinOp( binop, Box::new(self.expr), Box::new(other.expr), )) } /// SQL 等価演算子 pub fn equals(self, other: Self) -> Expr<'scope, bool> { self.binop( other, Arc::new(|a, b| a.binary(sea_query::BinOper::Equal, b)), ) } /// SQL 数値加算 pub fn add(self, other: Self) -> Self { self.binop(other, Arc::new(|a, b| a.binary(sea_query::BinOper::Add, b))) } /// `nextval('name')` を生成して適切に動作させます。 pub fn nextval(name: &str) -> Self { Self::new(ExprInner::Raw( sea_query::Func::cust("nextval").arg(name.to_owned()).into(), )) } }
クエリを表すには
Query を使用します。
/// 行型データとして型 `T` の結果を生成する SQL SELECT 文を表す値。 #[derive(Clone)] pub struct Query<T> { // テーブル名とカラムを一意にするために使用するユニークな ID binder: Binder, expr: sea_query::SelectStatement, inner: T, siblings_need_random: bool, }
これはそれ自体は特に興味深いものではなく、実際には SELECT 文の AST と、ライブラリがクエリによって生成されるカラムを知ることができる式のみを含んでいます。魔法が起こるのは
query です。
pub struct Q<'scope> { queries: Vec<(TableName, ErasedQuery)>, filters: Vec<ExprInner>, binder: Binder, _phantom: PhantomData<&'scope ()>, } impl<'scope> Q<'scope> { pub fn q<T: ForLifetimeTable + Table>(&mut self, query: Query<T>) -> T::WithLt<'scope> { let binder = Binder::new(); let name = TableName::new(binder); let (erased, mut inner) = query.erased(); self.queries.push((name.clone(), erased)); insert_table_name(&mut inner, name); inner.with_lt(&mut WithLtMarker {}) } pub fn where_<'a>(&mut self, expr: Expr<'a, bool>) where 'scope: 'a, { self.filters.push(expr.expr); } } pub fn query<'outer, T: ForLifetimeTable + Table + 'outer>( f: impl for<'scope> FnOnce(&mut Q<'scope>) -> T::WithLt<'scope>, ) -> Query<T> { let mut q = Q { binder: Binder::new(), filters: Vec::new(), queries: Vec::new(), _phantom: PhantomData, }; let mut e = f(&mut q); let mut iter = q.queries.into_iter(); let mut table = sea_query::Query::select(); // 最初のクエリは SELECT の「主要」クエリとなる if let Some((first_table_name, first)) = iter.next() { table.from_subquery(first.expr, first_table_name); }; // リストにある残りのすべてのテーブルに対するラテラル結合 for (table_name, q) in iter { table.join_lateral( // 通常は CROSS JOIN ですが、sea_query は CROSS JOIN の `ON` を省略することをサポートしていないため(: // そして CROSS JOIN は INNER JOIN ON TRUE に相当します sea_query::JoinType::InnerJoin, expr, table_name, sea_query::Condition::all(), ); } for filter in q.filters { table.and_where(filter.render()); } // クエリは現在カラムを選出しておらず、この関数は出力テーブル内の式を巡回し、 // 各式を `<expr> AS <name>` の形式で SELECT に追加し、 // その中で式を `<name>` カラム参照に置き換えます。 subst_table(&mut e, TableName::new(q.binder), &mut table); let e = ForLifetimeTable::unwith_lt(e, &mut WithLtMarker {}); Query::new(q.binder, table.to_owned(), e) }
ライフタイムの役割
ここには特に何も起こっていません;実際に行うべきことは、衝突を引き起こさないようにカラムを適切に再命名することだけです。それ以外には、最初の言及されたクエリから始めて、各サブクエリに対してラテラル結合を導入し、その後ビルドしているクエリのプロジェクトされたカラムとして結果テーブルのカラムを巡回して挿入するだけという単純なものです。
おそらく
query の型シグネチャ(および 'scope ライフタイムパラメータを持つすべてのもの)をよく見ていただろうと思います。これは多くをしており、その理由は、借用チェッカーを使用してクエリのスコープルールを強制できるようになったためです。
query の呼び出し関数の型には for <'scope> があり、これは渡された関数がそのライフタイムに対してジェネリックであり、ライフタイムについて他に何も知ることはできず、与えられた Q<'scope> を受け取り、必ず T とそのライフタイムを持つ何かを返さなければならないことを意味します。ただし、query がクエリを処理した後、ライフタイムは 'outer に切り替えられ、これは query のライフタイムパラメータであるため、ユーザーがこれをどのように選択するかを選択できます。これは理にかなっています:Query はデータベースで実行できる独立した完全な SELECT クエリであり、したがってライフタイムの追跡は必要ありません。理想としては 'static になるべきですが、このライフタイムをパラメータ化した WithLt<'lt> GAT を使用することの不運な副作用として、それは 'lt で不変であるため、実際には &'static YourTable と道徳的によく似ているにもかかわらず、YourTable<'static> のライフタイムを短くすることはできません。そのため、ユーザーが多量の手動のライフタイム短縮を行う必要がないようにするために、query を 'outer ライフタイムにジェネリックにしたことを選んだのです。
もう一つわずかに不運な事実として、
query のコールバック内で T が T::WithLt<...> として登場することで、Rust は T を使用から推論できないという問題があります。ユーザーは常にどこかでそれを明記する必要があります(幸いにもユーザーはライフタイムの名称を指定する必要はありません)。完璧な世界であれば、私たちは以下の様に書くことができたでしょう:
fn query<'outer, T: for<'a> (T<'a>: Table), F: FnOnce(...) -> T<'scope>>(...) -> T<'outer>
つまり、Rust がクロージャーから返される
T と query から返される T が同じであることがわかるように。しかしながら、これは完璧な世界ではありません。
テーブル操作のためのトレイト
さて、この関数はすべての可能なテーブルに対してジェネリックである必要があります(「テーブル」とは、単一要素の
Expr<T>、(T, ...) のタプルで T: Table またはユーザー定義のテーブルを指します)。また、API が過度に制限的であることは望んでいません(テーブルを Table<(i32, UserType)> のようなものとして持つことができますが、これは標準的なフィールドアクセスを使用してサブテーブルとカラムを投影することを禁止することになります)。つまり、ライブラリが実行できる操作をモデル化するためにいくつかのトレイトが必要となります。これらは以下の通りです:
- テーブル内の列を訪問する:
trait Table - テーブル内のライフタイムを変更する:
trait ForLifetimeTable - テーブルのライフタイムを短縮できるようにする方法:
trait ShortenLifetime - ユーザー定義テーブルのモードを変更する:
trait TableHKT - ユーザー定義テーブルのフィールドに自然変換を適用する方法:
trait MapTable
Table
TableTable は非常にシンプルです;それは単にテーブル内のすべての列に対していくつかの実行するコールバックを実行する方法を表しています。Expr<T> については、単に内部の ErasedExpr 上でコールバックを呼び出すだけであり、タプルの場合は順次各サブテーブルで訪問を行うだけです。
pub trait Table { /// データベースから読み込まれたときのこのテーブルの行が持つ値。 type Result; /// テーブル内の各式を訪問する。 /// /// [Table::visit], [Table::visit_mut]、および [MapTable] のすべてのメソッドの間で常に同じ順序と数の式が訪問される必要があります。 fn visit(&self, f: &mut impl FnMut(&ErasedExpr), mode: VisitTableMode); /// テーブル内の各式を可変参照を持って訪問する。 /// /// [Table::visit], [Table::visit_mut]、および [MapTable] のすべてのメソッドの間で常に同じ順序と数の式が訪問される必要があります。 fn visit_mut(&mut self, f: &mut impl FnMut(&mut ErasedExpr), mode: VisitTableMode); }
ForLifetimeTable
ForLifetimeTableForLifetimeTable は、テーブル内のネストされた Expr<'scope, T> 型のライフタイムをライブラリに置換することを可能にします:
pub trait ForLifetimeTable { /// このテーブルのライフタイムを `'lt` に置換する。 type WithLt<'lt>: ForLifetimeTable + Table + Sized; fn with_lt<'lt>(self, marker: &mut WithLtMarker) -> Self::WithLt<'lt>; } impl<'scope, T: Value> ForLifetimeTable for Expr<'scope, T> { type WithLt<'lt> = Expr<'lt, T>; fn with_lt<'lt>(self, _marker: &mut WithLtMarker) -> Self::WithLt<'lt> { Expr::new(self.expr) } }
ShortenLifetime
ShortenLifetimeShortenLifetime は ForLifetimeTable と似ていますが、その目的はライブラリのユーザーがテーブルのライフタイムを短縮する必要がある場合に使用することです。通常 Rust ではこれが自動で行われますが、テーブルではライフタイムが不変であるため、Rust はそれを自動で行いません。代わりに、ユーザーは .shorten_lifetime() を呼び出す必要があります。
pub trait ShortenLifetime { type Shortened<'small> where Self: 'small; fn shorten_lifetime<'small, 'large: 'small>(self) -> Self::Shortened<'small> where Self: 'large; } impl<'scope, T> ShortenLifetime for Expr<'scope, T> { type Shortened<'small> = Expr<'small, T> where Self: 'small; fn shorten_lifetime<'small, 'large: 'small>(self) -> Self::Shortened<'small> where Self: 'large, { Expr::new(self.expr) } }
TableHKT
TableHKTTableHKT は、ライブラリが異なる TableMode 間でユーザー定義のテーブルについて話することを可能にします。
pub trait TableHKT { /// このテーブルの現在のモード。 type Mode: TableMode; /// モードを別のものに置換する。 type InMode<Mode: TableMode>; } impl<'scope, T: TableMode> TableHKT for User<'scope, T> { type InMode<Mode: TableMode> = User<'scope, Mode>; type Mode = T; }
MapTable
MapTable最後に、ユーザー定義の型のフィールドをマッピングする
MapTable です。
pub trait MapTable<'scope>: TableHKT { /// テーブルの各フィールドをマッピングする /// /// [Table::visit], [Table::visit_mut]、および [MapTable] のすべてのメソッドの間で常に同じ順序と数のフィールドが訪問される必要があります。 fn map_modes<Mapper, DestMode>(self, mapper: &mut Mapper) -> Self::InMode<DestMode> where Mapper: ModeMapper<'scope, Self::Mode, DestMode>, DestMode: TableMode; /// テーブルの各フィールドを参照を持ってマッピングする /// /// [Table::visit], [Table::visit_mut]、および [MapTable] のすべてのメソッドの間で常に同じ順序と数のフィールドが訪問される必要があります。 fn map_modes_ref<Mapper, DestMode>(&self, mapper: &mut Mapper) -> Self::InMode<DestMode> where Mapper: ModeMapperRef<'scope, Self::Mode, DestMode>, DestMode: TableMode; /// テーブルの各フィールドを可変参照を持ってマッピングする /// /// [Table::visit], [Table::visit_mut]、および [MapTable] のすべてのメソッドの間で常に同じ順序と数のフィールドが訪問される必要があります。 fn map_modes_mut<Mapper, DestMode>(&mut self, mapper: &mut Mapper) -> Self::InMode<DestMode> where Mapper: ModeMapperMut<'scope, Self::Mode, DestMode>, DestMode: TableMode; }
TableMode
とユーザー定義の型
TableModeMapTable を理解するには、まず TableMode とユーザー定義の型を見ておく必要があります。
TableMode は、いくつかのタイプをモードに応じて切り替えることを目的とする GAT(Generalized Algebraic Type)トレイトです。
pub trait TableMode { /// GAT は、結果タイプが `V` を含んでいるかどうかは不定です。 type T<'scope, V>; } impl TableMode for NameMode { /// カラム名を表す文字列。 type T<'scope, V> = &'static str; } impl TableMode for ValueMode { type T<'scope, V> = V; } impl TableMode for ValueManyMode { type T<'scope, V> = Vec<V>; } impl TableMode for ExprMode { type T<'scope, V> = Expr<'scope, V>; } impl TableMode for EmptyMode { type T<'scope, V> = (); }
ユーザー定義の型は以下のように定義されます:
struct User<'scope, Mode: TableMode = ExprMode> { id: Mode::T<'scope, i32>, name: Mode::T<'scope, String>, }
つまり、
User<NameMode> は { id: &str, name: &str } であり、ValueMode では { id: i32, name: String } になります。クエリ内で使用される場合、テーブルは ExprMode にあり、{ id: Expr<i32>, name: Expr<String> } となります。これにより、q.where_(user.id.equals(user_id)) と書けるようになります。
モードマッパー
もちろん、このパターンには解決すべき問題があります:いくつかの
User<NameMode> が与えられたとき、ライブラリがすべてのフィールドを訪問してカラム名を引き出し、そして何らかの方法でクエリ内で使用できる User<ExprMode> を生成する方法是どうすればよいのでしょうか。理想的には、ユーザーが fn name_to_expr_mode(self) -> (Vec<String>, User<ExprMode>) といった無限のトレイトとメソッドを実装することを要求せずに、これを行うことができれば理想的です。
答えはすべてこれらを実現することが
MapTable トレイトによってモデル化でき、単に型変化する関数をすべてのフィールドに対して呼び出す方法を提供するだけであり、すべての呼び出しを通じてステータスが渡されるという点にあることにあります。実際、ライブラリは MapTable を使用してユーザー定義のテーブルのための Table を実装しています。
私が検討する
MapTable のメソッドは map_modes_ref であり、不変参照を介して各フィールドを訪問し、宛先モードを持つ新しいテーブルを生成します。
fn map_modes_ref<Mapper, DestMode>(&self, mapper: &mut Mapper) -> Self::InMode<DestMode> where Mapper: ModeMapperRef<'scope, Self::Mode, DestMode>, DestMode: TableMode;
すべての作業は
ModeMapperMut によって行われます:
pub trait ModeMapperRef<'scope, SrcMode: TableMode, DestMode: TableMode> { /// `SrcMode` から `DestMode` にマッピングし、値を参照として受け取る fn map_mode_ref<V>(&mut self, src: &SrcMode::T<'scope, V>) -> DestMode::T<'scope, V> where V: Value; }
ユーザーが
MapTable を実装するには、単に次のような実装を書くだけです。
impl<'scope, Mode: TableMode> MapTable<'scope> for MyTable<'scope, Mode> { fn map_modes_ref<Mapper, DestMode>(&self, mapper: &mut Mapper) -> Self::InMode<DestMode> where Mapper: ModeMapperRef<'scope, Self::Mode, DestMode>, DestMode: TableMode, { let id = mapper.map_mode_ref(&self.id); let name = mapper.map_mode_ref(&self.name); let age = mapper.map_mode_ref(&self.age); User { id, name, age } } }
この技術によって、ライブラリは
MapTable を使用してユーザー定義の型のフィールドタイプ変換巡回を行うことができます。実際には、ユーザーが実装したコールバックを選択呼び出しながら、ユーザーによって知られているフィールドタイプでインスタンス化されたプロキシを設定しています。
ライブラリの側では、私たちは以下のように見える「マッパー」を書くことができます。
/// [NameMode] でテーブルのカラム名を読み取り SELECT に追加し、その後列を参照する式を生成するマッパー。 struct NameToExprMapper { binder: Binder, query: sea_query::SelectStatement, } impl<'scope> ModeMapperRef<'scope, NameMode, ExprMode> for NameToExprMapper { fn map_mode_ref<V>( &mut self, src: &<NameMode as TableMode>::T<'scope, V>, ) -> <ExprMode as TableMode>::T<'scope, V> { let col_name = ColumnName::new(self.binder, src.to_string()); self.query .expr_as(sea_query::Expr::column(*src), col_name.clone()); Expr::new(ExprInner::Column(TableName::new(self.binder), col_name)) } } // これはその後 Query::each に使用されてフィールド名を抽出し、 // および ExprMode テーブルを生成します。 impl<'scope, T> Query<T> where T: MapTable<'scope> + TableHKT<Mode = NameMode>, T::InMode<ExprMode>: ForLifetimeTable + Table, { /// [TableSchema] が与えられたとき、各行のすべてのカラムを選出するクエリを構築します。 pub fn each(schema: &TableSchema<T>) -> Query<T::InMode<ExprMode>> { let binder = Binder::new(); let mut query = sea_query::Query::select(); query.from(schema.name); let mut mapper = NameToExprMapper { binder, query }; let expr = schema.columns.map_modes_ref(&mut mapper); Query::new(binder, mapper.query, expr) } }
機能の概要
それによって、クエリ eDSL を構築するために必要なすべての部品が揃いました:
:MapTable
テーブルのカラム名を抽出してNameMode
テーブルを構築でき、そしてデータベース上でクエリを実行した後で再度使用して、各結果行から対応する値を脱シリアル化することでExprMode
テーブルをExprMode
に変換できます。ValueMode
とTableHKT
: Rust のライフタイムによって強制される制約を維持しながら任意のテーブル上で動作する関数を提供できます。ForLifetimeTable
,Query
, およびquery
: ユーザーはクエリを組み合わせて非常に自由に扱えるが、コードがコンパイルされれば有効なクエリを持つことが保証される。Q
: ユーザーはスカラー表現を構築でき、それらを他のクエリを返す関数に渡すことができる。Expr<T>
まだ多くの作業がありますが、この強力な抽象化を提供しながら実際にそれほど多くのコードを必要としない小さなクレートを作ることができたのは非常に嬉しかったです。