100日チャレンジ day31 (自作ORMとREPL)
昨日
100日チャレンジに感化されたので、アレンジして自分でもやってみます。
やりたいこと
- 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
- 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
- 生成aiをつかったバイブコーティングになれる
- 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)
できたもの

sqlite の ORM をつくる
承知しました。Go 言語で database/sql インターフェースに準拠した SQLite 向けのシンプルな ORM を作成しましょう。
仕様
- ターゲットDB: SQLite
-
基本インターフェース:
database/sqlの標準インターフェースを内部で利用し、ラップする形で提供します。 -
コア機能:
- SQLite データベースへの接続・切断 (
Open,Close,PingContext)。 - Go の構造体とデータベーステーブル間の基本的なマッピング。
- 構造体のフィールドには
db:"column_name"タグを付与してカラム名を指定できるようにします。
- 構造体のフィールドには
- 基本的な CRUD 操作:
-
Insert(ctx context.Context, data interface{}) (sql.Result, error): 構造体データから INSERT 文を生成して実行。 -
SelectOne(ctx context.Context, dest interface{}, query string, args ...interface{}) error: 指定されたクエリを実行し、結果を構造体にマッピング (単一レコード)。 -
Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error: 指定されたクエリを実行し、結果を構造体のスライスにマッピング (複数レコード)。destはスライスへのポインタである必要があります。 -
Update(ctx context.Context, query string, args ...interface{}) (sql.Result, error): 指定された UPDATE 文を実行。 -
Delete(ctx context.Context, query string, args ...interface{}) (sql.Result, error): 指定された DELETE 文を実行。
-
- 基本的なトランザクション管理 (
BeginTx,Commit,Rollback)。トランザクション内でも同様の CRUD 操作を提供。 -
sql.Null*型およびポインタ型による NULL 値のハンドリングをサポート。
- SQLite データベースへの接続・切断 (
-
構造体マッピングの詳細:
-
reflectパッケージを利用して構造体のフィールドとタグを解析します。 -
db:"-"タグが付与されたフィールドは無視します。 - タグがない場合は、フィールド名をスネークケース (例:
UserName->user_name) に変換してカラム名とみなします (この変換ロジックは実装をシンプルにするため、今回は省略し、明示的なタグ指定を必須とするかもしれません)。
-
-
エラーハンドリング:
database/sqlが返すエラーをそのまま返すか、必要に応じてラップします。console.errorでのログ出力は行わず、エラーを呼び出し元に返却します。 -
非機能要件:
- シンプルさ:
database/sqlの基本的な使い方を隠蔽しすぎない、薄いラッパーを目指します。 - 依存性:
mattn/go-sqlite3ドライバーのみに依存します。
- シンプルさ:
スコープ
今回の開発 (day31_go_orm) では、上記のコア機能の実装に限定します。以下の機能はスコープ外とします。
- 複雑なクエリビルダ
- リレーションシップの自動解決 (HasMany, BelongsTo など)
- マイグレーション機能
- フック (BeforeSave, AfterFind など)
- バルク操作の最適化
- 高度なコネクションプーリング設定
- カスタムロギング機能
- 自動スキーマ生成・同期
開発手順
-
プロジェクト初期化:
-
day31_go_ormディレクトリを作成します。 -
go mod init github.com/lirlia/100day_challenge_backend/day31_go_ormを実行します。 - README.md を作成し、この仕様とスコープを記載します。
-
go get github.com/mattn/go-sqlite3を実行して SQLite ドライバーを追加します。
-
-
基本構造の設計と実装:
- ORM のエントリーポイントとなる
DB構造体 (内部で*sql.DBを保持) を定義します。 - トランザクションを扱う
TX構造体 (内部で*sql.Txを保持) を定義します。
- ORM のエントリーポイントとなる
-
接続関連メソッドの実装:
-
Open(dataSourceName string) (*DB, error)関数 (ドライバー名は "sqlite3" 固定)。 -
DB構造体にPingContext(ctx context.Context) errorとClose() errorメソッドを実装します。
-
-
構造体マッピングヘルパーの実装:
-
reflectを用いて構造体のフィールドとdbタグを解析する内部関数を作成します。 -
sql.Rowsから単一/複数の構造体へスキャンするための内部関数 (scanRow,scanRows) を実装します。NULL 値 (sql.Null*やポインタ) のハンドリングもここで行います。
-
-
CRUD メソッドの実装 (
DB構造体):-
Insert: 構造体から INSERT 文とプレースホルダ付きの値リストを生成しExecContextを呼び出します。 -
SelectOne,Select: 引数のクエリとパラメータでQueryRowContext/QueryContextを呼び出し、マッピングヘルパーで結果をdestに格納します。 -
Update,Delete: 引数のクエリとパラメータでExecContextを呼び出します。
-
-
トランザクション関連メソッドの実装:
-
DB構造体にBeginTx(ctx context.Context, opts *sql.TxOptions) (*TX, error)メソッドを実装します。 -
TX構造体にCommit() errorとRollback() errorメソッドを実装します。 -
TX構造体にもDBと同様のインターフェースで CRUD メソッド (Insert,SelectOne,Select,Update,Delete) を実装します (内部でtx.ExecContext等を使用)。
-
-
テスト:
- SQLite データベースファイル (
test.db) とテスト用テーブル (CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT NULLABLE);) を用意します。 - 対応する Go 構造体 (
User) を定義します。 - 各 CRUD 操作とトランザクションの基本的な動作を確認するテストコード (
orm_test.go) を作成し、実行します。
- SQLite データベースファイル (
-
README 更新:
- 作成した ORM の基本的な使い方 (接続、CRUD、トランザクション) を示すサンプルコードを記載します。
上記の手順で進めていきます。まずはプロジェクトの初期化を行います。
README.md を作成しました。次に、go mod init と go get を実行してプロジェクトの初期化と依存関係の追加を行います。
pwd && go mod init github.com/lirlia/100day_challenge_backend/day31_go_orm && go get github.com/mattn/go-sqlite3
Go モジュールの初期化と SQLite ドライバーのインストールが完了しました。
次に、ORM の基本構造となる DB と TX 構造体、および接続関連のメソッド (Open, PingContext, Close) を実装します。orm というサブディレクトリを作成し、その中に orm.go ファイルを作成して実装を進めます。
基本的な構造と接続関連のメソッドを day31_go_orm/orm/orm.go に実装しました。
次に、reflect を使用して構造体のフィールド情報を解析し、DB カラムとのマッピングを行うヘルパー関数 getStructInfo と、sql.Rows からデータを構造体にスキャンするヘルパー関数 scanRow および scanRows を実装します。
構造体マッピングとスキャン用のヘルパー関数 (getStructInfo, scanRow, scanRows) を実装しました。キャッシュ (structInfoCache) を利用して、同じ構造体型に対する解析処理が繰り返し実行されるのを防いでいます。
いよいよ CRUD 操作 (Insert, SelectOne, Select, Update, Delete) を DB 構造体と TX 構造体に実装します。Insert では構造体の値から INSERT 文を生成する処理が必要です。
まずは DB 構造体にこれらのメソッドを実装します。
CRUD メソッドを DB と TX に実装し、共通ロジックを executor インターフェースと内部関数 (insert, selectOne, selectMulti, exec) を使って共通化しました。
-
Insertは構造体から INSERT 文を自動生成しますが、現状ではテーブル名を構造体名の小文字 + "s" とし、idカラムを単純に除外する簡易的な実装です。より堅牢にするには改善が必要です。 -
SelectOne,Select,Update,Delete,Execは SQL クエリ文字列を直接受け取ります。
これで ORM の基本的な機能が一通り実装できました。次は、これらの機能が正しく動作するかを確認するためのテストコードを作成します。day31_go_orm/orm/orm_test.go ファイルを作成し、テストを記述します。
テストコード (orm_test.go) を作成しました。Insert, SelectOne, Select, Update, Delete およびトランザクション (Commit, Rollback) の基本的な動作を検証するテストケースが含まれています。NULL 値のハンドリング (sql.NullString, *string, sql.NullTime) や、構造体のスライス、構造体のポインタのスライスへの Select もテストしています。
Linter エラーが出ているようです (Line 124: string literal not terminated)。確認して修正します。
(確認中...)
fmt.Printf("Inserted user with ID: %d\n+ \", lastID) の部分で、末尾に不要な + があり、文字列リテラルが正しく閉じられていないようです。同様の箇所が他にもあるかもしれません。修正します。
Linter エラー (文字列リテラルの問題) を修正しました。
新たな Linter エラー could not import github.com/lirlia/100day_challenge_backend/day31_go_orm/orm が発生しました。これは、テストコード (orm_test.go) が orm パッケージをインポートしようとしていますが、go.mod ファイルで day31_go_orm モジュール自体は定義されているものの、その 内部 の orm パッケージが適切に認識されていないためと考えられます。
通常、同じモジュール内のパッケージをテストする場合は、相対パスやパッケージ名を直接指定しますが、ここでは Go Modules のパス形式 (github.com/...) でインポートしようとしています。
go.mod ファイルが day31_go_orm ディレクトリ直下に存在し、orm.go と orm_test.go が day31_go_orm/orm ディレクトリ内にある場合、orm_test.go 内のパッケージ宣言は package orm_test のままで問題ありませんが、インポートパスは github.com/lirlia/100day_challenge_backend/day31_go_orm/orm のままで正しく動作するはずです。
もしかすると、ファイル構成が想定と異なっているか、go work init などが必要なワークスペース構成になっている可能性があります。
現在のファイル構成を確認します。
Ran tool
go.mod ファイルが day31_go_orm ディレクトリ直下にありませんでした。go mod init コマンドはワークスペースルート (/Users/noname/Cording/100day_challenge_backend) で実行されたため、go.mod ファイルがそこに作成されてしまったようです。
これを修正します。
- ワークスペースルートの
go.modとgo.sumを削除します (このプロジェクト専用ではないため)。 -
day31_go_ormディレクトリに移動し、そこでgo mod initとgo getを再実行します。
/bin/rm go.mod go.sum
ルートの go.mod, go.sum を削除しました。次に、day31_go_orm ディレクトリ内で Go モジュールの初期化と依存関係の追加を再度行います。
cd day31_go_orm && pwd && go mod init github.com/lirlia/100day_challenge_backend/day31_go_orm && go get github.com/mattn/go-sqlite3
day31_go_orm ディレクトリで正しく go mod init と go get を実行しました。これで go.mod ファイルが適切な場所に作成されたはずです。
この状態でテストを実行してみましょう。day31_go_orm/orm ディレクトリに移動して go test コマンドを実行します。-v オプションを付けて詳細なログを表示します。
cd day31_go_orm/orm && pwd && go test -v
失礼しました。前のステップで day31_go_orm ディレクトリに移動していました。テストの実行は day31_go_orm ディレクトリから orm パッケージを指定して行うべきでした。
再度、正しいディレクトリからテストを実行します。カレントディレクトリは day31_go_orm のはずなので、そのまま go test ./orm -v を実行します。
pwd && go test ./orm -v
テストがすべて成功しました! 🎉
作成したシンプルな ORM は、基本的な CRUD 操作、NULL 値のハンドリング、トランザクション管理が期待通りに動作しているようです。
最後に、README.md の使い方セクションを実際のコードに合わせて微調整し、最終的な体裁を整えます。特に Open 関数の使い方やテーブル作成の Exec の呼び出し方を ORM のメソッドを使うように修正します。
README.md の使い方セクションを更新しました。
これで、Day 31 の Go 言語によるシンプルな SQLite ORM の実装は完了です。database/sql のインターフェースをラップし、構造体マッピングによる基本的な CRUD 操作とトランザクション機能を提供します。
さて、続いてこの ORM を使う簡単なwebアプリをgoで作ってください
了解しました。作成した ORM (day31_go_orm/orm) を利用する、ユーザー管理機能(一覧表示、追加、削除)を持つシンプルな Web アプリケーションを Go の標準ライブラリ (net/http, html/template) を使って作成します。
開発手順:
-
day31_go_ormディレクトリ内にwebappディレクトリを作成します。 -
webapp内にmain.goファイルを作成し、Web サーバーの基本コードを記述します。 -
webapp内にtemplatesディレクトリを作成し、index.htmlを作成します。 -
main.goで ORM を初期化し、ハンドラ関数 (一覧表示/, ユーザー追加/add, ユーザー削除/delete) を実装します。 -
index.htmlにユーザー一覧表示、追加フォーム、削除ボタンを実装します。 - アプリケーションを起動し、動作を確認します。
まず、webapp ディレクトリと必要なファイル・ディレクトリを作成します。
mkdir -p webapp/templates && touch webapp/main.go webapp/templates/index.html
ディレクトリとファイルを作成しました。
次に、webapp/main.go に基本的な Web サーバーのコードと ORM の初期化処理、ハンドラ関数の骨組みを記述します。
main.go に Web アプリケーションの基本的なロジックを実装しました。ORM を使ってユーザーの取得、追加、削除を行い、html/template でレンダリングします。
次に、webapp/templates/index.html にユーザー一覧、追加フォーム、削除ボタンの HTML を記述します。簡単なスタイルも追加しておきます。
HTML テンプレート (index.html) を作成しました。Go の html/template パッケージの構文 ({{range .Users}}, {{.ID}}, {{if .Email.Valid}} など) を使って、ハンドラから渡されたユーザーデータを表示します。
これで Web アプリケーションの基本的な実装は完了です。webapp ディレクトリに移動して go run main.go を実行し、ブラウザで http://localhost:8080 にアクセスして動作を確認してみましょう。
現在のディレクトリは day31_go_orm なので、cd webapp してから実行します。
cd webapp && pwd && go run main.go
さて、続いてORMを拡充しましょう。アイデアをください
承知しました。作成したシンプルな Go ORM を拡充するためのアイデアですね。現状の基本的な CRUD 操作から一歩進んで、より便利で実用的な機能を追加する方向で考えてみましょう。
以下にいくつかのアイデアを提案します。
1. 基本的なクエリビルダの実装:
-
現状: SQL クエリ文字列を直接
SelectOne,Selectなどに渡している。 -
拡張: メソッドチェーンで WHERE 条件、ORDER BY、LIMIT、OFFSET を指定できるようにする。
- 例:
db.Model(&User{}).Where("age > ?", 20).Where("status = ?", "active").Order("created_at DESC").Limit(10).Select(&users)
- 例:
-
メリット:
- SQL 文字列の組み立てミスを減らせる。
- 動的な条件追加が容易になる。
- コードの可読性が向上する。
-
考慮点:
- あまり複雑にしすぎず、基本的な条件指定 (
=,>,<,IN,LIKEなど) と論理演算子 (AND,OR) をサポートする範囲から始める。 -
Model(&User{})のように操作対象のモデルを指定するインターフェースを導入する必要がある。
- あまり複雑にしすぎず、基本的な条件指定 (
2. Eager Loading (Preload) 機能:
- 現状: 関連するデータを取得するには、別途クエリを実行する必要がある (例: ユーザー取得後にそのユーザーの投稿を取得)。
-
拡張:
Preload("FieldName")のようなメソッドで、関連するモデルのデータを一度のクエリまたは効率的な複数クエリで取得できるようにする。- 例:
db.Model(&User{}).Where("id = ?", 1).Preload("Posts").SelectOne(&user)(User 構造体内のPosts []Postフィールドに関連データが入る)
- 例:
-
メリット:
- N+1 問題を簡単に回避できる。
- 関連データへのアクセスが直感的になる。
-
考慮点:
- リレーションシップ (1対多、多対1など) を定義する方法が必要 (構造体タグ
orm:"foreignkey:UserID"など)。 - 内部実装は少し複雑になる (元のクエリ結果から ID を集め、関連テーブルに IN クエリを発行するなど)。
- リレーションシップ (1対多、多対1など) を定義する方法が必要 (構造体タグ
3. ライフサイクルフック (Callbacks):
- 現状: CRUD 操作の前後にカスタムロジックを挟む標準的な方法がない。
-
拡張: モデルの構造体に特定のインターフェース (例:
BeforeSave(ctx context.Context, exec executor) error,AfterFind(ctx context.Context) error) を実装することで、対応する操作の前後に自動的にメソッドが呼び出されるようにする。 -
メリット:
- データのバリデーション、デフォルト値の設定、監査ログの記録などをモデル自身にカプセル化できる。
- コードの関心事を分離できる。
-
考慮点:
- フック内で実行される処理 (特に DB 操作) がトランザクション内で正しく扱われるように注意が必要。
- どの操作でどのフックが呼ばれるかの規約を明確にする。
4. ソフトデリート (Soft Delete):
-
現状:
Delete操作は物理削除を行う。 -
拡張: モデルに
DeletedAt sql.NullTimeのようなフィールドとorm:"soft_delete"のようなタグを追加することで、Delete操作が物理削除ではなくdeleted_atカラムに現在時刻を設定するように変更する。また、通常のSelect系操作では自動的にWHERE deleted_at IS NULL条件が付与されるようにする。 -
メリット:
- データを論理的に削除し、後から復元したり監査したりできる。
- アプリケーションコードで
deleted_atを意識する箇所を減らせる。
-
考慮点:
- ユニーク制約があるカラムとの兼ね合い (論理削除されたレコードと重複するデータを追加できなくするなど)。
- 論理削除されたデータも含めて検索する手段 (
Unscoped()メソッドなど) も提供する必要があるかもしれない。
5. テーブル名/カラム名の自動マッピング改善:
-
現状:
-
Insert時のテーブル名は構造体名の小文字 + "s" という簡易的なルール。 - カラム名は
dbタグがない場合、フィールド名そのまま。
-
-
拡張:
- より一般的な命名規則 (例: Go の
CamelCaseを DB のsnake_caseに変換) をデフォルトでサポートし、設定で変更できるようにする。 - 構造体に
TableName() stringメソッドを実装することでテーブル名をオーバーライドできるようにする。
- より一般的な命名規則 (例: Go の
-
メリット:
-
dbタグをすべてのフィールドに書く手間を省ける場合がある。 - より柔軟な命名規則に対応できる。
-
-
考慮点:
- 命名規則変換のロジック実装。
- 既存の
dbタグとの優先順位。
優先度案:
- 基本的なクエリビルダ: SQL 文字列の手書きを減らせる効果が大きく、多くの場面で役立つ。
-
テーブル名/カラム名の自動マッピング改善:
Insertの実装をより汎用的にし、dbタグの手間を減らせる。 - Eager Loading (Preload): N+1 問題対策として実用性が高い。
- ライフサイクルフック: モデルの責務を明確にするのに役立つ。
- ソフトデリート: 特定の要件では非常に便利。
まずはどの機能から実装していくのが良さそうでしょうか?個人的には 基本的なクエリビルダ と テーブル名/カラム名の自動マッピング改善 から着手するのが、ORM としての使い勝手を向上させる上で効果的かと思いますが、ご希望があればお聞かせください。
承知しました。基本的なクエリビルダ機能を実装しましょう。
実装方針:
-
db.Model(&User{})のようなメソッドでクエリビルドを開始し、QueryBuilderオブジェクトを返します。 -
QueryBuilderは内部でWHERE条件、ORDER BY、LIMIT、OFFSETを保持します。 -
.Where("name = ?", "alice"),.Order("id DESC"),.Limit(10),.Offset(5)のようなメソッドチェーンで条件を指定できるようにします。 - 最後に
.Select(&users)や.SelectOne(&user)を呼び出してクエリを実行し、結果を取得します。 - テーブル名は構造体名から自動的に推測するロジックを改善し、
Insertと共有します(例:User->users)。
まず、orm.go に QueryBuilder 構造体と関連メソッドを追加します。また、テーブル名解決のロジックも修正・共通化します。
クエリビルダの基本構造 (QueryBuilder 構造体、Model, Where, Order, Limit, Offset, Select, SelectOne メソッド) を実装し、テーブル名の解決ロジック (getTableName) も改善しました。構造体名をスネークケースの複数形に変換するために github.com/stoewer/go-strcase という外部ライブラリを使用するようにしました。
Linter エラーが出ています。これは、新しい依存関係 (github.com/stoewer/go-strcase) を go.mod に追加していないためです。go get コマンドを実行して依存関係を追加します。
現在のディレクトリは day31_go_orm です。
pwd && go get github.com/stoewer/go-strcase
依存関係を追加しました。これで Linter エラーは解消するはずです。
次に、この新しいクエリビルダ機能の動作を確認するためのテストケースを orm_test.go に追加します。
orm_test.go にクエリビルダのテストケース (TestQueryBuilder) を追加しました。
それでは、テストを実行してクエリビルダが正しく動作するか確認しましょう。
pwd && go test ./orm -v
テストがすべて成功しました!🎉
クエリビルダの基本的な機能 (Where, Order, Limit, Offset, Select, SelectOne) が実装され、期待通りに動作しているようです。
これで ORM のクエリビルダ機能の追加は完了です。既存の SelectOne, Select メソッドは残していますが、今後は新しく追加した Model(...).Where(...).Select(...) のような形式を使うことが推奨されます。
ご確認ありがとうございます。コード全体を見直した上で、SQL インジェクションのリスクがある箇所について説明します。
現在、SQL インジェクションのリスクが最も明確に存在する箇所は、クエリビルダの .Order() メソッド です。
// orm.go L145 付近
// Order は ORDER BY 条件を追加します。
// 例: "id DESC", "name ASC, created_at DESC"
func (qb *QueryBuilder) Order(value string) *QueryBuilder {
qb.orders = append(qb.orders, value) // <- 受け取った文字列をそのまま保持
return qb
}
// orm.go L195 付近 (buildSelectQuery 内)
if len(qb.orders) > 0 {
query.WriteString(" ORDER BY ")
query.WriteString(strings.Join(qb.orders, ", ")) // <- 保持した文字列をそのまま結合
}
このように、.Order() メソッドは引数で受け取った文字列 (value) を検証せずにそのまま内部に保持し、buildSelectQuery 関数内で strings.Join を使って SQL 文の ORDER BY 句に直接結合しています。
もし、アプリケーションのコードでユーザーからの入力に基づいてこの .Order() メソッドの引数を生成するような場合、例えば URL のクエリパラメータ ?sort=name DESC; DROP TABLE users; -- のような値をそのまま .Order() に渡してしまうと、生成される SQL 文は以下のようになり、意図しないテーブル削除などの操作が実行されてしまう可能性があります。
SELECT * FROM users ORDER BY name DESC; DROP TABLE users; --
その他の箇所について:
-
.Where(query string, args ...interface{}):argsに渡された値はdatabase/sqlの機能によってプレースホルダ (?) を介して安全に扱われます。ただし、query文字列の部分(例:"id = ?")をユーザー入力から動的に生成し、その際にサニタイズが不十分だと脆弱になる可能性があります(例:Where("name = '" + userInput + "'"))。これは ORM ライブラリの問題というより、ライブラリの 使い方 の問題です。プレースホルダを使うことが前提です。 -
.Limit(int),.Offset(int): 整数値を受け取るため、SQL インジェクションのリスクは低いです。 - 既存の
SelectOne,Select,Update,Delete,Execメソッド: これらも SQL 文字列を直接受け取るため、使い方によっては.Whereと同様のリスクがあります。 -
Insert: 構造体の値から SQL を生成しますが、値はプレースホルダで渡され、テーブル名やカラム名は構造体定義から生成されるため、現時点では直接的なリスクは低いです。
結論として、早急に対処すべきは .Order() メソッドにおける入力文字列の検証です。
そうですね、Eager Loading (Preload) は ORM の使い勝手を大きく向上させる重要な機能です。N+1 問題を解決し、関連データを効率的に取得できるようにしましょう。
実装方針:
-
リレーション定義:
- 構造体タグ
ormを使ってリレーションを定義します。まずは 1対多 (has many) をサポートします。 - 例:
User構造体にPosts []Post \orm:"hasmany:user_id"`` のようにタグを追加します。-
hasmany: リレーションタイプを示します。 -
user_id: 関連テーブル (posts) 側の外部キーカラム名を示します。
-
- 構造体タグ
-
Preloadメソッド:-
QueryBuilderにPreload(fieldName string)メソッドを追加します。引数はリレーションが定義されたフィールド名(例:"Posts")です。 -
Preloadは複数回呼び出すことで、複数のリレーションを同時にロードできるようにします。
-
-
実行ロジック:
-
.Select()や.SelectOne()が呼び出された後に、Preloadで指定されたフィールドのデータを読み込みます。 - まず、主モデル(例:
User)のデータを取得します。 - 取得した主モデルの 主キー (現状は
id固定と仮定。将来的にはタグorm:"primarykey"で指定可能に?) の値を収集します。 -
Preloadで指定された各リレーションについて、以下の処理を行います。- リレーション定義 (
ormタグ) から、関連モデルの型、外部キーカラム名を取得します。 - 収集した主キーの値リストを使って、関連モデルのテーブルに対して
WHERE [外部キーカラム名] IN (?, ?, ...)クエリを実行します。 - 取得した関連モデルのデータを、対応する主モデルのフィールド(例:
UserのPostsスライス)にセットします。
- リレーション定義 (
-
-
データマッピング:
- 関連データを効率的に紐付けるため、取得した関連データを外部キーの値でグループ化します (例:
map[int64][]Postのような形式)。 - 主モデルのデータをループし、主キーに対応する関連データ群をマップから取得してフィールドに設定します。
- 関連データを効率的に紐付けるため、取得した関連データを外部キーの値でグループ化します (例:
考慮事項:
-
主キー: 現状、主キーは暗黙的に
idという名前のカラムと仮定しています。Preload ではこの主キーを使って関連データを紐付けるため、この仮定を明示的にするか、タグで指定できるようにする必要があります。今回はシンプルにid固定で進めます。 -
リレーションの種類: まずは 1対多 (
hasmany) のみを実装します。 -
ネストした Preload:
Preload("Posts.Comments")のような深い階層のロードは今回はスコープ外とします。 -
Preload 時の条件指定:
Preload("Posts", func(qb *QueryBuilder) *QueryBuilder { ... })のような、関連データ取得時に条件を追加する機能はスコープ外とします。
実装ステップ:
-
QueryBuilderにpreloads []stringフィールドを追加。 -
QueryBuilderにPreload(fieldName string)メソッドを追加。 - 構造体情報のキャッシュ (
cachedStructInfo) にリレーション情報を格納するフィールドを追加し、getStructInfoでormタグを解析するように修正。 -
selectOne,selectMultiの内部実装を変更し、主モデル取得後にprocessPreloads(仮称) を呼び出すようにする。 -
processPreloads関数を実装する (ID 収集、関連データ取得クエリ実行、データマッピング)。 - テストケースを追加する。
まず、orm.go を編集して QueryBuilder の変更とリレーション情報の解析を追加します。
orm.go に Eager Loading (Preload) の基本的な機能を追加しました。
-
QueryBuilderにpreloadsフィールドとPreloadメソッドを追加。 -
cachedStructInfoにリレーション情報を保持するrelationsフィールドを追加し、getStructInfoでorm:"hasmany:..."タグを解析するように修正。 -
SelectとSelectOneメソッド内で、主モデル取得後にprocessPreloads関数を呼び出すように変更。 -
processPreloads関数を実装し、関連データの取得とマッピングを行うロジックを追加。 -
scanRow内でカラム名からフィールドインデックスを直接引けるようにfieldIndexByColumnをcachedStructInfoに追加し、利用するように修正。
Linter エラー Line 292: expected declaration, found dest が発生しています。processPreloads 関数のシグネチャ部分を確認します。
// processPreloads は Preload 指定された関連データを読み込み、元のデータにマッピングします。
dest は主モデルのデータが格納されたスライスまたは単一要素へのポインタのインタフェースです。 // <- この行が関数の外にある
func (qb *QueryBuilder) processPreloads(dest interface{}) error {
// ...
}
コメントが関数定義の外に出てしまっているようです。これを修正します。
コメントの Linter エラーを修正しました。
次に、この Preload 機能が正しく動作するかテストを追加します。orm_test.go に TestPreload 関数を追加し、User と Post の 1対多リレーションを Preload するケースを検証します。
まず、テストモデルの User 構造体に Posts フィールドと orm タグを追加します。
テストモデル User に Posts フィールドとリレーション定義を追加しました。db:"-" タグも付けて、このフィールドが users テーブルのカラムとして扱われないようにしています。
次に、Preload をテストする TestPreload 関数を追加します。
Preload 機能のテストケース (TestPreload) を追加しました。単一および複数のユーザー取得時に Posts を Preload するケース、関連データがないケース、不正なフィールド名を指定したケースを検証します。
それでは、テストを実行して Preload 機能が正しく動作するか確認しましょう。
pwd && go test ./orm -v
テスト (TestPreload) が失敗しました。エラーメッセージを確認します。
orm_test.go:624: Preload SelectOne failed: orm: preload failed for SelectOne: orm: processPreloads expects a pointer to a slice or a pointer to a struct, got []*orm_test.User
processPreloads 関数が、期待する型(構造体へのポインタ、または構造体のスライスへのポインタ)ではなく、[]*orm_test.User という型を受け取ったためにエラーが発生しています。
これは、SelectOne 内で processPreloads を呼び出す際に、引数として渡しているデータ (sliceDest.Interface()) の型が間違っていることが原因です。SelectOne の結果 dest (例: *User) を、要素数 1 の「ポインタのスライス ([]*User)」としてラップして processPreloads に渡そうとしていますが、そのラップの仕方が正しくないようです。
processPreloads の冒頭の型チェック部分と、SelectOne 内での processPreloads 呼び出し部分を見直します。
processPreloads の冒頭:
func (qb *QueryBuilder) processPreloads(dest interface{}) error {
destVal := reflect.ValueOf(dest)
// dest がポインタのスライスか、単一要素へのポインタかチェック
isSlice := destVal.Kind() == reflect.Ptr && destVal.Elem().Kind() == reflect.Slice
var sliceVal reflect.Value
if isSlice {
sliceVal = destVal.Elem()
} else if destVal.Kind() == reflect.Ptr && destVal.Elem().Kind() == reflect.Struct { // ★ SelectOne はこちらに来る想定だった
// SelectOne の場合、要素1つのスライスとして扱う
sliceVal = reflect.MakeSlice(reflect.SliceOf(destVal.Type()), 1, 1) // ここで []*User のスライスを作っている?
sliceVal.Index(0).Set(destVal)
} else {
// ★ 実際にはここに来てエラーになっている
return fmt.Errorf("orm: processPreloads expects a pointer to a slice or a pointer to a struct, got %T", dest)
}
// ...
}
SelectOne 内の呼び出し:
// Preload 処理 (単一要素に対して)
if len(qb.preloads) > 0 {
// SelectOne の結果をスライスのように見せかけるためにポインタの配列を作成
sliceDest := reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf(dest)), 1, 1) // ここで []*User のスライスを作成
sliceDest.Index(0).Set(reflect.ValueOf(dest)) // スライスに *User をセット
err = qb.processPreloads(sliceDest.Interface()) // []*User を渡している
if err != nil {
return fmt.Errorf("orm: preload failed for SelectOne: %w", err)
}
}
SelectOne で processPreloads を呼び出す際に、[]*User 型のデータを渡しています。しかし、processPreloads は引数 dest が *[]User (スライスへのポインタ) か *User (構造体へのポインタ) であることを期待しています。
修正方針:
SelectOne の場合、processPreloads に渡すのは単一の構造体へのポインタ (*User) のままにし、processPreloads 側で単一要素の場合の処理を正しくハンドリングするようにします。
orm.go の SelectOne と processPreloads を修正します。
SelectOne から processPreloads への引数の渡し方と、processPreloads 内での型ハンドリングを修正しました。
Linter が isSingle 変数が宣言されているが使われていない、と警告しています。この変数はデバッグや将来の拡張用に一時的に追加したものでしたが、現状のロジックでは不要なので削除します。
未使用の isSingle 変数を削除しました。
これで Preload 機能の実装とテストの準備が整ったはずです。再度テストを実行して確認しましょう。
pwd && go test ./orm -v
まだ TestPreload が失敗しています。今度は別の問題が発生しているようです。ログを確認します。
-
WARN: Preload field 'Posts' not found or not a valid relation in model User:processPreloads内でstructInfo.relations[preloadField]を参照した際に、Postsフィールドに対応するリレーション情報が見つからない、という警告が出ています。これはgetStructInfoでormタグの解析が正しく行われていない、またはキャッシュされた古い情報が使われている可能性を示唆します。 -
Preload SelectOne expected 2 posts for user1, got 0: 上記の警告の結果、Preload が実行されず、Postsフィールドが空のままになっています。 -
Preload SelectOne expected empty slice for Posts, got nil: 関連データがない場合のテストで、期待する空スライス ([]) ではなくnilが設定されています。processPreloadsのマッピング部分で、データがない場合に空スライスをセットする処理が意図通り動いていない可能性があります。 -
Preload with invalid field failed: orm: query failed for selectOne: no such table: preload_users: テストケース 4 でPreloadUserという別の型を使おうとした際に、対応するテーブルpreload_usersが存在しないというエラーが発生しています。これは、getTableNameがPreloadUser->preload_usersというテーブル名を生成したためです。テストでは同じusersテーブルを使いたいので、このテスト方法は修正が必要です。
修正方針:
-
リレーション解析の確認:
getStructInfoでormタグの解析とinfo.relationsへの格納が正しく行われているか、デバッグログを追加して確認します。キャッシュ (structInfoCache) をクリアする方法も検討します (テスト開始時など)。 -
空スライスの設定:
processPreloadsのマッピング部分で、relatedMap[ownerID]が存在しない場合にreflect.MakeSlice(preloadTargetField.Type(), 0, 0)を使って空スライスをセットする処理を再確認します。 -
テストケース 4, 5 の修正:
PreloadUser型を使うのをやめ、通常のUser型を使って不正なフィールド名を指定するテストに修正します。
まず、getStructInfo と processPreloads にデバッグログを追加し、リレーション解析とマッピングの状態を確認します。
デバッグログを追加しました。次に、orm_test.go のテストケース 4 と 5 を修正します。PreloadUser 型をやめて User 型を使い、存在しないフィールド名やリレーションではないフィールド名を Preload に指定するようにします。
テストケースの修正と不要な型定義の削除を行いました。
これでデバッグログを見ながら再度テストを実行し、リレーション解析とマッピングが正しく行われるか確認します。
pwd && go test ./orm -v
テストはまだ失敗していますが、デバッグログから重要な情報が得られました。
2025/05/03 11:54:54 DEBUG: Cache miss for struct info: User
2025/05/03 11:54:54 DEBUG: Cached struct info for: User with 0 relations
最初の getStructInfo 呼び出し(TestInsertAndSelectOne 内)で User 構造体の情報がキャッシュされる際、orm タグが解析されず、relations が 0 件としてキャッシュされてしまっています。その後の TestPreload で User のキャッシュがヒットした際も、リレーション情報が含まれていないため、WARN: Preload field 'Posts' not found... という警告が出て Preload が失敗しています。
原因は、orm_test.go で定義している User 構造体の Posts フィールドに orm:"hasmany:user_id" タグを追加したにも関わらず、getStructInfo 関数がそれを正しく解析・格納できていないことです。
getStructInfo 内の orm タグ解析ロジックを再度確認します。
// orm.go L437 付近
// ORM タグ (リレーション) の解析
if ormTag != "" {
parts := strings.Split(ormTag, ":")
if len(parts) == 2 { // "hasmany:user_id" の形式を期待
relType := strings.ToLower(parts[0])
relInfo := parts[1] // 外部キーカラム名
if relType == "hasmany" {
if field.Type.Kind() != reflect.Slice { // フィールドがスライスか確認
// ... ログ出力 ... continue
}
relatedElemType := field.Type.Elem() // スライスの要素の型を取得 (例: Post)
if relatedElemType.Kind() != reflect.Struct {
// ... ログ出力 ... continue
}
info.relations[field.Name] = &RelationInfo{ // info.relations に格納
Type: "hasmany",
RelatedType: relatedElemType,
ForeignKey: relInfo,
OwnerKey: "id", // 固定
}
log.Printf("DEBUG: Registered hasmany relation: %s -> %s (FK: %s)", field.Name, relatedElemType.Name(), relInfo)
}
}
}
このロジック自体は問題なさそうに見えます。考えられる原因は以下のいずれかです。
-
テスト実行順序とキャッシュ: 最初のテスト (
TestInsertAndSelectOne) が実行された時点では、まだorm_test.go内のUser構造体にormタグが 追加される前 のコードでgetStructInfoが呼ばれ、リレーションなしでキャッシュされてしまった。その後、コードが更新されてタグが追加されても、キャッシュがクリアされずに古い情報が使われ続けている。 -
getStructInfoのバグ: タグ解析ロジックにまだ見落としているバグがある。
可能性が高いのは 1. です。テスト実行時に reflect を多用するパッケージのキャッシュが意図しない挙動を示すことはあります。
対策:
テストの開始時に structInfoCache をクリアするようにします。これにより、各テスト実行時に必ず構造体情報が再解析されるようになります。
orm_test.go の setupTestDB 関数の最初にキャッシュをクリアする処理を追加します。(ただし、orm パッケージ内の非公開変数 structInfoCache に直接アクセスできないため、orm パッケージ側にキャッシュをクリアする公開関数を追加する必要があります。)
まず、orm.go にキャッシュクリア用の関数を追加します。
キャッシュクリア関数 ClearStructCache を追加しました。次に、orm_test.go の setupTestDB の最初でこの関数を呼び出します。
テストセットアップ時にキャッシュをクリアするようにしました。
これで、テスト実行時に常に最新の構造体情報が解析されるはずです。再度テストを実行して確認しましょう。
pwd && go test ./orm -v
まだ TestPreload が失敗しますね...。デバッグログを詳しく見てみましょう。
=== RUN TestPreload
2025/05/03 11:55:27 DEBUG: Clearing struct info cache.
2025/05/03 11:55:27 DEBUG: Cache miss for struct info: User
2025/05/03 11:55:27 DEBUG: Cached struct info for: User with 0 relations <-- 問題箇所
TestPreload の開始時に ClearStructCache が呼ばれ、キャッシュはクリアされています。その後、User の構造体情報が必要になった際に Cache miss となり、getStructInfo が実行されています。しかし、その結果としてキャッシュされた情報 (Cached struct info for: User with 0 relations) には、依然としてリレーション情報が含まれていません。
これは、orm_test.go で User 構造体の定義に orm:"hasmany:user_id" タグを追加した変更が、テスト実行時に orm パッケージ側から見えていない ことを示唆しています。
考えられる原因:
- Go のテストの仕組みとして、テスト対象のパッケージ (
orm) とテストコードのパッケージ (orm_test) は別々にコンパイルされることがあります。orm_testパッケージ内で定義されたUser構造体の変更が、ormパッケージ内のgetStructInfo関数からは直接参照できないのかもしれません。
対策:
orm パッケージ内で、テストに使用するための User および Post 構造体を定義し、それをテストコードから利用するようにします。これにより、getStructInfo が確実にリレーションタグを含む構造体定義を解析できるようになります。
-
orm.goにテスト用のUserとPost構造体を定義します(既存のテストコードからコピーし、パッケージ名をormに変更)。 -
orm_test.goのモデル定義を削除し、インポートするormパッケージ内のテスト用モデル (orm.User,orm.Post) を使用するようにテストコード全体を修正します。
まず orm.go にテスト用モデルを追加します。
orm.go にテスト用モデルを追加しました。
次に、orm_test.go からローカルのモデル定義を削除し、すべてのテストケースで orm.User, orm.Post を使用するように修正します。
orm_test.go 内のモデル定義を削除し、すべてのテストケースで orm パッケージの User, Post を使用するように修正しました。(一部、単純な置換では対応できないテスト内の比較ロジックも調整しました。)
これでテスト実行時に getStructInfo が orm パッケージ内のタグ付き構造体を正しく解析できるはずです。再度テストを実行して確認しましょう。
pwd && go test ./orm -v
まだダメでしたね...。デバッグログを再度確認します。
=== RUN TestPreload
2025/05/03 11:57:19 DEBUG: Clearing struct info cache.
2025/05/03 11:57:19 DEBUG: Cache miss for struct info: User
2025/05/03 11:57:19 DEBUG: Cached struct info for: User with 0 relations
キャッシュをクリアしているにも関わらず、TestPreload 内で User の構造体情報が解析された結果 (Cached struct info for: User with 0 relations) にリレーションが含まれていません。
これは非常に奇妙です。orm.go に User 構造体の定義があり、そこには Posts []Post \orm:"hasmany:user_id"` db:"-"`` というタグが明確に記述されているはずです。
考えられる原因を絞り込みます。
-
orm.goの編集ミス:orm.goにテスト用モデルを追加する際に、タグが正しくコピー&ペーストされていない、または Linter 等で意図せず削除されてしまった。 -
getStructInfoのバグ:ormタグを解析するロジック (strings.Splitなど) が特定の条件下でうまく動いていない。 -
Go のビルドキャッシュ: Go のビルドシステムが古いオブジェクトファイルをキャッシュしていて、
orm.goの最新の変更が反映されていない。
まず、1. の可能性を確認するため、現在の orm.go の User 構造体定義を再確認します。
Read file: day31_go_orm/orm/orm.go
orm.go の末尾にあるテスト用 User 構造体の定義を確認しました。
type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
Email sql.NullString `db:"email"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Posts []Post `orm:"hasmany:user_id" db:"-"` // リレーション定義
}
タグ orm:"hasmany:user_id" は正しく記述されています。編集ミスではなさそうです。
次に 2. の getStructInfo のバグの可能性を探ります。ログを見ると、User 型の解析時に DEBUG: Cached struct info for: User with 0 relations となっており、orm タグの解析部分 (log.Printf("DEBUG: Registered hasmany relation: ...")) が実行されていないように見えます。
getStructInfo のループ内で orm タグを解析する条件 if ormTag != "" の直前にログを追加し、タグが取得できているか確認してみましょう。
デバッグログを追加しました。これでテストを実行すれば、User 構造体の Posts フィールドで orm タグがどのように認識されているか確認できるはずです。
pwd && go test ./orm -v
デバッグログを確認すると、依然として getStructInfo が User 構造体の Posts フィールドから orm タグを読み取れていないようです。
2025/05/03 11:57:44 DEBUG: Cache miss for struct info: User
2025/05/03 11:57:44 DEBUG: Field: ID, ORM Tag: ""
2025/05/03 11:57:44 DEBUG: Field: Name, ORM Tag: ""
2025/05/03 11:57:44 DEBUG: Field: Email, ORM Tag: ""
2025/05/03 11:57:44 DEBUG: Field: CreatedAt, ORM Tag: ""
2025/05/03 11:57:44 DEBUG: Field: UpdatedAt, ORM Tag: ""
// ここで Posts フィールドのログが出るはずが出ていない
2025/05/03 11:57:44 DEBUG: Cached struct info for: User with 0 relations
これは不可解です。コード上は orm.go の User 定義にタグがあるはずなのに、getStructInfo がそれを認識していません。
最後の手段として、Go のビルドキャッシュが影響している可能性を考え、ビルドキャッシュを削除してから再度テストを実行してみます。
pwd && go clean -cache && go test ./orm -v
ビルドキャッシュをクリアしても状況は変わりませんでした。デバッグログを見ると、やはり getStructInfo 実行時に Posts フィールドの orm タグが認識されていません。
2025/05/03 11:58:21 DEBUG: Cache miss for struct info: User
2025/05/03 11:58:21 DEBUG: Field: ID, ORM Tag: ""
2025/05/03 11:58:21 DEBUG: Field: Name, ORM Tag: ""
2025/05/03 11:58:21 DEBUG: Field: Email, ORM Tag: ""
2025/05/03 11:58:21 DEBUG: Field: CreatedAt, ORM Tag: ""
2025/05/03 11:58:21 DEBUG: Field: UpdatedAt, ORM Tag: ""
// ここで Posts フィールドのログがない
2025/05/03 11:58:21 DEBUG: Cached struct info for: User with 0 relations
これは、getStructInfo のループ (for i := 0; i < numFields; i++) が Posts フィールドに到達する前に終了しているか、Posts フィールドが何らかの理由でスキップされていることを意味します。
コードを再確認します。
// orm.go L667 付近 (getStructInfo 内)
numFields := structType.NumField()
for i := 0; i < numFields; i++ {
field := structType.Field(i) // ここで User の各フィールド (ID, Name, ..., Posts) が取得されるはず
if !field.IsExported() { // Posts は大文字始まりなのでエクスポートされている
continue
}
dbTag := field.Tag.Get("db")
ormTag := field.Tag.Get("orm") // ここで Posts フィールドの "orm" タグが取得されるはず
if dbTag == "-" { // Posts フィールドは db:"-" なので、ここで continue してしまっている!
continue
}
// ... (以降の ormTag 解析に到達しない)
}
原因が判明しました。Posts フィールドには db:"-" タグが付いているため、if dbTag == "-" の条件に一致し、ループの continue 文によって orm タグの解析処理に進む前にスキップされていました。
修正方針:
orm タグの解析は db:"-" でスキップされる 前 に行う必要があります。
getStructInfo のループ内の処理順序を変更します。
getStructInfo 内の処理順序を修正し、orm タグの解析を db:"-" のチェックより先に行うようにしました。
これで Posts フィールドのリレーション情報が正しくキャッシュされるはずです。三度目の正直でテストを実行します。
pwd && go test ./orm -v
申し訳ありません、まだ解決しませんでした。ログを確認すると、依然として getStructInfo で User 構造体の Posts フィールドの orm タグが読み取れていません。
2025/05/03 11:58:51 DEBUG: Cache miss for struct info: User
2025/05/03 11:58:51 DEBUG: Field: ID, ORM Tag: ""
2025/05/03 11:58:51 DEBUG: Field: Name, ORM Tag: ""
2025/05/03 11:58:51 DEBUG: Field: Email, ORM Tag: ""
2025/05/03 11:58:51 DEBUG: Field: CreatedAt, ORM Tag: ""
2025/05/03 11:58:51 DEBUG: Field: UpdatedAt, ORM Tag: ""
// Posts フィールドのログが出ていない
2025/05/03 11:58:51 DEBUG: Cached struct info for: User with 0 relations
ここまで来ると、getStructInfo 関数内のフィールドをループする処理自体に問題がある可能性が考えられます。Posts フィールドが何らかの理由でループの対象になっていない可能性があります。
ループ部分のコードを再確認します。
// orm.go L667 付近
numFields := structType.NumField()
for i := 0; i < numFields; i++ { // このループが User の Posts フィールドまで回っているか?
field := structType.Field(i)
// ...
}
User 構造体のフィールド数は 6 (ID, Name, Email, CreatedAt, UpdatedAt, Posts) なので、numFields は 6 になり、ループは i = 0 から i = 5 まで実行されるはずです。
ループの直前と内部にデバッグログを追加して、numFields の値と、各ループで処理されているフィールド名を確認してみましょう。
フィールド数とフィールド名を確認するためのデバッグログを追加しました。これでテストを実行します。
pwd && go test ./orm -v
ちょっと eager loding うまくいかないのでやめる
インタラクティブ ORM シェル (REPL) を作ってもらうことにした
この ORM を本番環境で利用するには、いくつかの重要な点を改善・強化する必要があります。現状はシンプルな学習用という位置づけなので、堅牢性、安全性、パフォーマンス、保守性の観点で課題があります。
主な改善点は以下の通りです。
-
セキュリティ:
-
SQLインジェクション対策: 現在の
Where句の実装 (特に CLI での文字列結合) は非常に危険です。全てのユーザー入力を伴うクエリで、プレースホルダ (?) を使った安全なパラメータバインディングを徹底する必要があります。ORM 内部での SQL 文字列結合を避け、database/sqlの引数処理に任せるべきです。 - 入力バリデーション: モデルにデータをマッピングする前や、CLI で受け取った引数など、外部からの入力に対するバリデーションが必要です。
-
SQLインジェクション対策: 現在の
-
信頼性とエラーハンドリング:
-
詳細なエラーロギング: 現在は
fmt.Printlnやlog.Fatalfが中心ですが、本番環境では構造化されたログ(エラーレベル、発生箇所、コンテキスト情報を含む)を出力し、エラー監視システムと連携できるようにする必要があります。 -
エラーの種類と伝達:
sql.ErrNoRows以外にも、DB 制約違反 (UNIQUE など)、接続エラー、タイムアウトなど、様々なエラーが発生しえます。これらを適切に識別し、呼び出し元がハンドリングしやすい形でエラーを返す(またはラップする)仕組みが必要です。 -
トランザクション管理の堅牢化:
Commit/Rollbackの失敗ケースなど、トランザクション周りのエラーハンドリングをより慎重に行う必要があります。defer tx.Rollback()を使うなど、エラー発生時に確実にロールバックされるパターンを推奨します。
-
詳細なエラーロギング: 現在は
-
クエリ機能:
- 高度なクエリビルダ: 現在の Query Builder は基本的な機能のみです。JOIN、サブクエリ、複雑な WHERE 条件 (AND/OR のグルーピング)、集約関数 (SUM, AVG, MAX, MIN など)、GROUP BY、HAVING など、本番アプリケーションで必要とされる多くの SQL 機能に対応していません。
- JOIN の明示的なサポート: リレーションを使ったデータ取得で、内部的に JOIN を行う機能や、明示的に JOIN を指定できる機能が必要です。
-
型安全なクエリ: 文字列ベースの
WhereやOrderはタイプミスやリファクタリング時の追従漏れのリスクがあります。可能であれば、Go のコードとしてより安全にクエリを構築できる方法(式木や構造体を活用するなど)を検討します。
-
パフォーマンスとリソース管理:
-
コネクションプーリングの設定:
database/sqlが提供するコネクションプーリングのパラメータ(最大接続数、アイドル接続数、タイムアウトなど)を適切に設定・調整できるインターフェースが必要です。 -
リフレクションのオーバーヘッド:
reflectは便利ですが、パフォーマンスへの影響があります。高負荷が予想される場合は、コード生成ベースのアプローチや、より最適化されたマッピング手法を検討する必要があるかもしれません。ベンチマークが重要です。 -
バルク操作: 大量のデータを一度に
Insert/Update/Deleteするための最適化されたバルク操作機能が必要です。 -
キャッシュの効率と安全性: 構造体情報のキャッシュ (
structInfoCache) はパフォーマンスに寄与しますが、本番環境の並行処理下での安全性(スレッドセーフ)を確保する必要があります (sync.Mapや Mutex を使うなど)。
-
コネクションプーリングの設定:
-
保守性と拡張性:
- マイグレーション: データベーススキーマの変更を管理するための堅牢なマイグレーション機能は必須です。手動での SQL 実行はヒューマンエラーのリスクが高すぎます。
-
ライフサイクルフック:
BeforeSave,AfterFindのような、特定の操作の前後にカスタムロジックを挟み込めるフック機能があると、バリデーションや監査ログなどの実装が容易になります。 - ロギングの柔軟性: 実行される SQL クエリをログに出力する機能(ON/OFF 可能、出力レベル設定可能)はデバッグに非常に役立ちます。
- ドキュメントとテスト: API のドキュメントを整備し、エッジケースやエラーケースを含む網羅的なテストスイートが必要です。
現状の ORM は学習目的としては素晴らしい成果ですが、本番投入するにはこれらの点を考慮し、大幅な機能追加と改善が必要になると考えられます。既存の成熟した Go ORM (GORM, sqlx, sqlc など) がこれらの課題の多くを解決しているため、実際の開発ではそれらを利用するのが現実的な選択肢となることが多いです。
この ORM を本番環境で利用するには、いくつかの重要な点を改善・強化する必要があります。現状はシンプルな学習用という位置づけなので、堅牢性、安全性、パフォーマンス、保守性の観点で課題があります。
主な改善点は以下の通りです。
-
セキュリティ:
-
SQLインジェクション対策: 現在の
Where句の実装 (特に CLI での文字列結合) は非常に危険です。全てのユーザー入力を伴うクエリで、プレースホルダ (?) を使った安全なパラメータバインディングを徹底する必要があります。ORM 内部での SQL 文字列結合を避け、database/sqlの引数処理に任せるべきです。 - 入力バリデーション: モデルにデータをマッピングする前や、CLI で受け取った引数など、外部からの入力に対するバリデーションが必要です。
-
SQLインジェクション対策: 現在の
-
信頼性とエラーハンドリング:
-
詳細なエラーロギング: 現在は
fmt.Printlnやlog.Fatalfが中心ですが、本番環境では構造化されたログ(エラーレベル、発生箇所、コンテキスト情報を含む)を出力し、エラー監視システムと連携できるようにする必要があります。 -
エラーの種類と伝達:
sql.ErrNoRows以外にも、DB 制約違反 (UNIQUE など)、接続エラー、タイムアウトなど、様々なエラーが発生しえます。これらを適切に識別し、呼び出し元がハンドリングしやすい形でエラーを返す(またはラップする)仕組みが必要です。 -
トランザクション管理の堅牢化:
Commit/Rollbackの失敗ケースなど、トランザクション周りのエラーハンドリングをより慎重に行う必要があります。defer tx.Rollback()を使うなど、エラー発生時に確実にロールバックされるパターンを推奨します。
-
詳細なエラーロギング: 現在は
-
クエリ機能:
- 高度なクエリビルダ: 現在の Query Builder は基本的な機能のみです。JOIN、サブクエリ、複雑な WHERE 条件 (AND/OR のグルーピング)、集約関数 (SUM, AVG, MAX, MIN など)、GROUP BY、HAVING など、本番アプリケーションで必要とされる多くの SQL 機能に対応していません。
- JOIN の明示的なサポート: リレーションを使ったデータ取得で、内部的に JOIN を行う機能や、明示的に JOIN を指定できる機能が必要です。
-
型安全なクエリ: 文字列ベースの
WhereやOrderはタイプミスやリファクタリング時の追従漏れのリスクがあります。可能であれば、Go のコードとしてより安全にクエリを構築できる方法(式木や構造体を活用するなど)を検討します。
-
パフォーマンスとリソース管理:
-
コネクションプーリングの設定:
database/sqlが提供するコネクションプーリングのパラメータ(最大接続数、アイドル接続数、タイムアウトなど)を適切に設定・調整できるインターフェースが必要です。 -
リフレクションのオーバーヘッド:
reflectは便利ですが、パフォーマンスへの影響があります。高負荷が予想される場合は、コード生成ベースのアプローチや、より最適化されたマッピング手法を検討する必要があるかもしれません。ベンチマークが重要です。 -
バルク操作: 大量のデータを一度に
Insert/Update/Deleteするための最適化されたバルク操作機能が必要です。 -
キャッシュの効率と安全性: 構造体情報のキャッシュ (
structInfoCache) はパフォーマンスに寄与しますが、本番環境の並行処理下での安全性(スレッドセーフ)を確保する必要があります (sync.Mapや Mutex を使うなど)。
-
コネクションプーリングの設定:
-
保守性と拡張性:
- マイグレーション: データベーススキーマの変更を管理するための堅牢なマイグレーション機能は必須です。手動での SQL 実行はヒューマンエラーのリスクが高すぎます。
-
ライフサイクルフック:
BeforeSave,AfterFindのような、特定の操作の前後にカスタムロジックを挟み込めるフック機能があると、バリデーションや監査ログなどの実装が容易になります。 - ロギングの柔軟性: 実行される SQL クエリをログに出力する機能(ON/OFF 可能、出力レベル設定可能)はデバッグに非常に役立ちます。
- ドキュメントとテスト: API のドキュメントを整備し、エッジケースやエラーケースを含む網羅的なテストスイートが必要です。
現状の ORM は学習目的としては素晴らしい成果ですが、本番投入するにはこれらの点を考慮し、大幅な機能追加と改善が必要になると考えられます。既存の成熟した Go ORM (GORM, sqlx, sqlc など) がこれらの課題の多くを解決しているため、実際の開発ではそれらを利用するのが現実的な選択肢となることが多いです。
シンプルな orm の中身を分解すると、
- sql の wrapper の各メソッドの用意(select / update / delete ...
- go と sql の変数名のルールの差分を埋める
- メソッドチェーンからクエリを生成するクエリビルダー
- ドメインモデルとテーブルデータ間 の型変換
がメインで、+αとして eager loading や before / after hook という感じでしょうか?
はい、その分解で概ね合っていると思います。シンプルな ORM の主要な関心事をよく捉えていますね。もう少し具体的に、今回の実装 (Day 31) と照らし合わせながら補足します。
-
SQL の Wrapper の各メソッドの用意(select / update / delete ...:
- その通りです。
database/sqlの基本的な機能 (QueryContext,ExecContext,BeginTxなど) をラップし、構造体のマッピングなどを加えたSelectOne,Select,Insert,Update,Deleteといったメソッドを提供しました。これが ORM の基本的なデータ操作インターフェースになります。
- その通りです。
-
Go と SQL の変数名のルールの差分を埋める:
- これも重要な点です。Go のフィールド名 (通常 CamelCase) と DB のカラム名 (通常 snake_case) の間の命名規則の違いを吸収する仕組みが必要です。
- 今回の実装では、主に
db:"column_name"タグによる明示的な指定で対応しました。Query Builder でテーブル名を構造体名から推測する際にはstrcaseを使って snake_case への変換を行いましたが、カラム名自体はタグ優先でした。一般的な ORM では、タグがない場合に自動で snake_case に変換する機能を持つことが多いです。
-
メソッドチェーンからクエリを生成するクエリビルダー:
- はい、これも実装しましたね。
Model(),Table(),Where(),Order(),Limit(),Offset(),Preload()といったメソッドをチェーンで繋ぎ、最終的にSelect()やCount()などを呼び出すことで、動的に SQL クエリを組み立てて実行できるようにしました。これにより、SQL 文字列を直接書く手間を減らし、よりプログラム的にクエリを扱えるようになります。
- はい、これも実装しましたね。
-
ドメインモデルとテーブルデータ間の型変換:
- これも ORM の中核です。
reflectを使って Go の構造体 (ドメインモデル) のフィールド情報やタグを読み取り、database/sqlから返されるデータを適切な Go の型 (ポインタ型やsql.Null*型を含む) にマッピング(Scan)しました。逆にInsertやUpdateの際には、構造体の値を SQL のプレースホルダに対応する値に変換する必要がありました。
- これも ORM の中核です。
そして、+α として挙げられた点も的確です。
-
Eager Loading (Preload): 今回実装しました。関連するデータを一度のクエリ(または少ないクエリ回数)で効率的に読み込むための機能です。
Preload()メソッドとorm:"hasmany/belongsTo"タグで実現しました。 - Before / After Hook: これは今回実装しませんでしたが、本番レベルの ORM ではよく見られる機能です。データの保存前 (BeforeSave) や取得後 (AfterFind) など、特定のイベントの前後に独自の処理(バリデーション、ロギング、データ加工など)を差し込むための仕組みです。
したがって、ご提示いただいた分解は、シンプルな ORM が持つべき主要な要素と、さらに発展させる際の一般的な追加機能として非常に的確だと思います。

