AIと一緒にORMを開発した話
時間に余裕ができたので、AIと一緒にORMを開発してみることにした。メモ代わりに、その時の話をまとめてみる。書いてみたものの、どこかで聞いたことのある話ばかりで特に目新しい内容はなかったけど、まぁいいや。
https://github.com/asaokamei/DecaORM
なぜORM?
- 前に何度か作って、二度と作らないぞと思ったのがORM。ではAIを使うと簡単になるか試したかった。
- 担当プロジェクトが、ORMどころかWhere句もOrder句も限定的にしか使えなくて、自作でHydratorみたいなのを作る羽目になったので。ちょっと自作してみたくなった。
- AIを試すなら、ゼロから作ってみたかった。
利用したAIは?
- Cursor:AIコーディング用
- PhpStorm:手修正や全体像の把握
- Gemini 3:壁打ち
長年愛用してきたPhpStormは応援したい(頑張らないとまずいかも)。
Cursorは便利だけど、PlugInが足りてないのかPHP開発は少し手間がかかる。あとAIが追加したコードとGitの差分の違いで混乱することが何度かあった。慣れの問題か、UIが進歩したら解決するか、どちらにせよ大きな問題ではない。
Geminiと壁打ちは楽しい。自己肯定感をマックスにしてくれる。
方針
まずは、どんなORMを作ってみたいかを決めないといけない。
人間に残された数少ない担当部分。
作るのは「シンプルなORM」
シンプルとは?
シンプルとは構造のことであり、必ずしも使い勝手ではない。ただし、使う側からみて何ができないかが明確で、必要な処理がわかりやすいのが目標。
構造がシンプルでわかりやすい、というのは人間(自分のこと)でも理解できる複雑さにしたいから。ちょっと背伸びしたぐらいのコードがちょうどいい温度感かな。
そのためにクラスの機能は単純にして、小さめのクラスを組み合わせて使う。逆に、クラスの入力と出力、そして副作用が明確であれば、中のコードが複雑でもなんとかなるだろうと。
具体的に
- DBテーブルに対して、エンティティとリポジトリを対応させる。
- エンティティはDoctrine風にして、アトリビュートで色々設定したい。サポートが長期間になると、この形のほうが思い出しやすいと感じてるため。
- リレーションは、OneToMany、OneToOne、ManyToManyの3種類。
- エンティティはPOPO(Plain Ordinary PHP Object)にしたいけど、手を抜いて最低限の共通機能を実装してもらう(EntityInterfaceとEntityTraitを実装してもらう)ことにした。
何をしないか
もしかすると、こっちのほうが大事かもしれない。
- UnitOfWorkは実装しない。
- AI曰く、絶対にやめとけ。正解の存在しない世界で迷子になりそうだったので。
- エンティティを保存する順番は利用者側で判断してもらう。
- リレーションの読み込みではJOINを使わない。
- その代わりにIDのIN句を使う。
- そして読み込みは明示的に(裏で勝手に読み込んだりしない)。
- 独自言語は絶対に対応しない。
- 単純なSQLビルダーを実装しておく。
- エンティティはプロパティ名を利用し、SQLではDBのコラム名で統一。
- この2つの世界を橋渡しするのがHydratorであり、Attribute。
- 例外あり。
感想
AIすごい。
「何をか言わんや」
EntityMap、DirtyTrackingなど、欲しい機能をいえば、一発で対応してくれた。
設計方針が大事なのだろうなと
最初は快調にコーディングしてくれたAIだったが、リレーションの実装になると生成コードの質がガクンと落ちた気がする。提案したコードを全部捨てて、書き直してもらうことが増えた。
理由を考えてみたが、設計方針が、一つのDBテーブルに一つのレポジトリだからだろう。リレーションは複数のテーブルとリポジトリが出てくるので、方針と矛盾してしまう。なので、どう実装するのが最適なのか判断がつかないと。
つまりリレーション用の指針が必要になるのだけど、うまく表現できず伝えられなかったという感じがする。
なんとなく考えてたリレーションでの方針を説明してみると、やはりレポジトリとテーブルを一対一で対応させるというものだ。基本の方針のまま実装すると、こんな感じ。
class User {
private array $posts;
}
class Post {
private ?User $user;
}
$user = $userRepo->findById($id);
$postRepo->loadForUser($user);
loadForUser内では、$userのidから対応するPostsデータを読み込めばいい。
ここからスタートして、$postRepoが必要なこと、loadForUserとリレーションごとにメソッドが必要なこと、という問題を解決すればいいのだろうと思ってた。結果としては、こんなコードになった。
class User {
#[HasMany(targetClass: Post::class, mappedBy: “user”]
private array $posts;
}
class Post {
#[BelongsTo(targetClass: User::class, foreignKey: “user_id”)]
private ?User $user;
}
$user = $userRepo->findById($id);
$userRepo->load($user, ‘posts’);
動きとしては、
-
User::postsのHasManyアトリビュートを取得、 - targetClassから、お相手のレポジトリを取得、
- お相手のリポジトリのloadメソッドを呼び出すと、
-
Post::userのBelongsTo情報を読み込んで、 - 必要なpostデータをDBから取得して、
-
$user->postsに取得したデータを差し込む。
実際のコードはGitHubで確認できる。
ハックは苦手?
特にハックのような機能は苦手かもしれない。
例えば、リレーションに何でもできちゃう「loader」機能を追加しようとしたとき。
class User {
#[HasMany(targetClass: Post::class, mappedBy: “user”, loader: “loadActivePosts”]
private array $activePosts;
}
見事に力技で実装しようとして笑ってしまいました。
だって、「それは素晴らしいアイディアですね! お任せください」ぐらいの勢いだったから任せたのに。書いてる途中で、こりゃ違うな、とか思わないのだろうか…
ハックって、今の設計に針の穴を通すように抜け穴を作る作業で、最小の労力で最大の結果を得るようなコードだと思う。ところがAIは真面目で、しかも優秀で何でもできちゃうから、ガラッと設計を変えたくなるような実装をしてしまうのかも。
ちなみに試行錯誤の結果のコードはこちら。今見ると、何の変哲もない普通のコードだけど、ここに落ち着くのに何度も作り直してる。
これもAIが進化したら問題なくなるかもしれないが。
一方で、AIがいろんなコードを書いてくれたので、実装方法を思いついたというところもある。なのでコードはたくさん書くのが大事、というのは真理かもしれない。
結論のようなもの
久々にゼロからライブラリを作ったが、今までなら半年はかかったと思うのが1ヶ月でできて楽しかった。将来どうなるのかはわからないが、今は楽しむしかないのだろうなと思った。