データベース操作

レッスン27:データベース操作

現実世界のアナロジー

図書館を経営していると想像してください —

このレッスンの後、Goを使用してデータベースを自信を持って操作できるようになります。


コアコンセプト

コンセプト 説明
database/sql Go標準ライブラリ、統一されたデータベース操作インターフェースを定義
ドライバ database/sql/driverインターフェースを実装するサードパーティパッケージ
sql.Open() データベース接続プールを作成(すぐに接続を確立するわけではない)
sql.DB データベース接続プールオブジェクト、スレッドセーフ
Query / QueryRow クエリステートメントを実行し、結果セットを返す
Exec 非クエリステートメントを実行(INSERT/UPDATE/DELETE)
Prepare プリペアドステートメント、繰り返し実行のパフォーマンスを向上
Tx トランザクションオブジェクト、CommitとRollbackをサポート
接続プール 接続を再利用し、頻繁な接続のオープン/クローズのオーバーヘッドを回避

基本構文と使い方

1. ドライバのインポート

GO
package main

import (
    "database/sql"
    _ "github.com/mattn/go-sqlite3" // SQLiteドライバ(匿名インポート、ドライバのみ登録)
)
💡 ヒント: ドライバは_で匿名インポートする必要があります。database/sqlにドライバを登録するためにinit()関数を実行するだけで、ドライバ関数を直接呼び出すことはないためです。

2. データベースを開く

GO
db, err := sql.Open("sqlite3", "./mydb.db")
if err != nil {
    log.Fatal(err)
}
defer db.Close()

// 接続が利用可能か確認
if err = db.Ping(); err != nil {
    log.Fatal("データベースに接続できません:", err)
}
💡 ヒント: sql.Open()はすぐに接続を確立するわけではなく、接続プールの設定を初期化するだけです。実際に接続を試みるにはPing()を使用してください。

3. テーブルの作成

GO
createTableSQL := `
CREATE TABLE IF NOT EXISTS users (
    id    INTEGER PRIMARY KEY AUTOINCREMENT,
    name  TEXT    NOT NULL,
    email TEXT    NOT NULL UNIQUE,
    age   INTEGER DEFAULT 0
);`
_, err = db.Exec(createTableSQL)
if err != nil {
    log.Fatal("テーブルの作成に失敗しました:", err)
}

4. CRUD操作

GO
// ---- INSERT ----
result, err := db.Exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
    "Alice", "alice@example.com", 25)
if err != nil {
    log.Fatal(err)
}
id, _ := result.LastInsertId() // 自動インクリメントIDを取得
fmt.Println("正常に挿入、ID:", id)

// ---- SELECT ----
var name, email string
var age int
err = db.QueryRow("SELECT name, email, age FROM users WHERE id = ?", id).
    Scan(&name, &email, &age)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("クエリ結果: %s、%s、%d歳\n", name, email, age)

// ---- UPDATE ----
_, err = db.Exec("UPDATE users SET age = ? WHERE id = ?", 26, id)
if err != nil {
    log.Fatal(err)
}

// ---- DELETE ----
_, err = db.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
    log.Fatal(err)
}

5. プリペアドステートメント

GO
stmt, err := db.Prepare("INSERT INTO users (name, email, age) VALUES (?, ?, ?)")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

// 同じstmtを複数回再利用でき、パフォーマンスが向上する
stmt.Exec("Bob", "bob@example.com", 30)
stmt.Exec("Charlie", "charlie@example.com", 28)
💡 ヒント: プリペアドステートメントはパフォーマンスを向上させる(データベースがSQLを一度だけ解析する)だけでなく、SQLインジェクションも防止します。

6. トランザクション処理

GO
tx, err := db.Begin()
if err != nil {
    log.Fatal(err)
}

// トランザクション内で複数のステートメントを実行
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", 100, 1)
if err != nil {
    tx.Rollback() // エラー時にロールバック
    log.Fatal(err)
}

_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", 100, 2)
if err != nil {
    tx.Rollback()
    log.Fatal(err)
}

if err = tx.Commit(); err != nil { // 成功時にコミット
    log.Fatal(err)
}
fmt.Println("トランザクションが正常にコミットされました")

7. 接続プール設定

GO
db.SetMaxOpenConns(25)                 // 最大オープン接続数
db.SetMaxIdleConns(10)                 // 最大アイドル接続数
db.SetConnMaxLifetime(5 * time.Minute) // 接続の最大存続期間
db.SetConnMaxIdleTime(3 * time.Minute) // アイドル接続の最大存続期間
💡 ヒント: 適切な接続プール設定は高並行シナリオでパフォーマンスを大幅に向上させます。MaxOpenConnsを低く設定するとリクエストがキューイングされ、高く設定しすぎるとデータベースリソースが枯渇します。


実践

例:SQLiteユーザー管理(難易度⭐)

基本的なCRUD操作を実演する完全なユーザー管理の例です。

GO
package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/mattn/go-sqlite3" // SQLiteドライバ
)

// User構造体
type User struct {
    ID    int
    Name  string
    Email string
    Age   int
}

func main() {
    // SQLiteデータベースを開く(または作成する)
    db, err := sql.Open("sqlite3", "./users.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // テーブルを作成
    _, err = db.Exec(`
        CREATE TABLE IF NOT EXISTS users (
            id    INTEGER PRIMARY KEY AUTOINCREMENT,
            name  TEXT NOT NULL,
            email TEXT NOT NULL UNIQUE,
            age   INTEGER DEFAULT 0
        )
    `)
    if err != nil {
        log.Fatal("テーブルの作成に失敗しました:", err)
    }

    // ユーザーを挿入
    result, err := db.Exec(
        "INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
        "Alice", "alice@example.com", 25,
    )
    if err != nil {
        log.Fatal("挿入に失敗しました:", err)
    }
    id, _ := result.LastInsertId()
    fmt.Printf("ユーザーが正常に挿入されました、ID: %d\n", id)

    // 単一ユーザーをクエリ
    var u User
    err = db.QueryRow("SELECT id, name, email, age FROM users WHERE id = ?", id).
        Scan(&u.ID, &u.Name, &u.Email, &u.Age)
    if err != nil {
        log.Fatal("クエリに失敗しました:", err)
    }
    fmt.Printf("クエリ結果: %+v\n", u)

    // ユーザーの年齢を更新
    _, err = db.Exec("UPDATE users SET age = ? WHERE id = ?", 26, id)
    if err != nil {
        log.Fatal("更新に失敗しました:", err)
    }
    fmt.Println("正常に更新されました")

    // すべてのユーザーをクエリ
    rows, err := db.Query("SELECT id, name, email, age FROM users")
    if err != nil {
        log.Fatal("全件クエリに失敗しました:", err)
    }
    defer rows.Close()

    fmt.Println("\nすべてのユーザー:")
    for rows.Next() {
        var user User
        if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.Age); err != nil {
            log.Fatal(err)
        }
        fmt.Printf("  ID:%d  名前:%s  メール:%s  年齢:%d\n",
            user.ID, user.Name, user.Email, user.Age)
    }

    // ユーザーを削除
    _, err = db.Exec("DELETE FROM users WHERE id = ?", id)
    if err != nil {
        log.Fatal("削除に失敗しました:", err)
    }
    fmt.Println("ユーザーが正常に削除されました")
}
▶ 試してみよう
TEXT
ユーザーが正常に挿入されました、ID: 1
クエリ結果: {ID:1 Name:Alice Email:alice@example.com Age:25}
正常に更新されました

すべてのユーザー:
  ID:1  名前:Alice  メール:alice@example.com  年齢:26
ユーザーが正常に削除されました

例:CRUDカプセル化 + プリペアドステートメント(難易度⭐⭐)

データベース操作を構造体メソッドとしてカプセル化し、プリペアドステートメントを使用してパフォーマンスを向上させます。

GO
package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/mattn/go-sqlite3"
)

// Userモデル
type User struct {
    ID    int
    Name  string
    Email string
    Age   int
}

// UserDAOはユーザーデータアクセスオブジェクト
type UserDAO struct {
    db         *sql.DB
    insertStmt *sql.Stmt
    getByID    *sql.Stmt
    updateStmt *sql.Stmt
    deleteStmt *sql.Stmt
}

// NewUserDAOはDAOを作成し、プリペアドステートメントを初期化する
func NewUserDAO(db *sql.DB) (*UserDAO, error) {
    dao := &UserDAO{db: db}

    // テーブルを初期化
    _, err := db.Exec(`
        CREATE TABLE IF NOT EXISTS users (
            id    INTEGER PRIMARY KEY AUTOINCREMENT,
            name  TEXT NOT NULL,
            email TEXT NOT NULL UNIQUE,
            age   INTEGER DEFAULT 0
        )
    `)
    if err != nil {
        return nil, err
    }

    // すべての一般的に使用されるステートメントを準備
    dao.insertStmt, err = db.Prepare(
        "INSERT INTO users (name, email, age) VALUES (?, ?, ?)")
    if err != nil {
        return nil, err
    }

    dao.getByID, err = db.Prepare(
        "SELECT id, name, email, age FROM users WHERE id = ?")
    if err != nil {
        return nil, err
    }

    dao.updateStmt, err = db.Prepare(
        "UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?")
    if err != nil {
        return nil, err
    }

    dao.deleteStmt, err = db.Prepare(
        "DELETE FROM users WHERE id = ?")
    if err != nil {
        return nil, err
    }

    return dao, nil
}

// Closeはすべてのプリペアドステートメントを閉じる
func (d *UserDAO) Close() {
    d.insertStmt.Close()
    d.getByID.Close()
    d.updateStmt.Close()
    d.deleteStmt.Close()
}

// Createは新しいユーザーを挿入する
func (d *UserDAO) Create(u *User) error {
    result, err := d.insertStmt.Exec(u.Name, u.Email, u.Age)
    if err != nil {
        return err
    }
    id, err := result.LastInsertId()
    if err != nil {
        return err
    }
    u.ID = int(id)
    return nil
}

// GetByIDはIDでユーザーをクエリする
func (d *UserDAO) GetByID(id int) (*User, error) {
    u := &User{}
    err := d.getByID.QueryRow(id).Scan(&u.ID, &u.Name, &u.Email, &u.Age)
    if err != nil {
        return nil, err
    }
    return u, nil
}

// Updateはユーザー情報を更新する
func (d *UserDAO) Update(u *User) error {
    _, err := d.updateStmt.Exec(u.Name, u.Email, u.Age, u.ID)
    return err
}

// Deleteはユーザーを削除する
func (d *UserDAO) Delete(id int) error {
    _, err := d.deleteStmt.Exec(id)
    return err
}

// ListAllはすべてのユーザーをクエリする
func (d *UserDAO) ListAll() ([]User, error) {
    rows, err := d.db.Query("SELECT id, name, email, age FROM users")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age); err != nil {
            return nil, err
        }
        users = append(users, u)
    }
    return users, rows.Err()
}

func main() {
    db, err := sql.Open("sqlite3", ":memory:") // デモンストレーション用インメモリデータベース
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    dao, err := NewUserDAO(db)
    if err != nil {
        log.Fatal(err)
    }
    defer dao.Close()

    // バッチ挿入
    users := []User{
        {Name: "Alice", Email: "alice@example.com", Age: 25},
        {Name: "Bob", Email: "bob@example.com", Age: 30},
        {Name: "Charlie", Email: "charlie@example.com", Age: 28},
    }

    for i := range users {
        if err := dao.Create(&users[i]); err != nil {
            log.Printf("挿入に失敗しました: %v", err)
            continue
        }
        fmt.Printf("挿入しました: %s (ID=%d)\n", users[i].Name, users[i].ID)
    }

    // クエリと変更
    u, err := dao.GetByID(1)
    if err != nil {
        log.Fatal("クエリに失敗しました:", err)
    }
    fmt.Printf("\nID=1をクエリ: %+v\n", u)

    u.Age = 26
    if err := dao.Update(u); err != nil {
        log.Fatal("更新に失敗しました:", err)
    }
    fmt.Printf("更新後: %+v\n", u)

    // すべて一覧表示
    all, _ := dao.ListAll()
    fmt.Println("\nすべてのユーザー:")
    for _, user := range all {
        fmt.Printf("  [%d] %s | %s | %d歳\n",
            user.ID, user.Name, user.Email, user.Age)
    }

    // 削除
    dao.Delete(2)
    fmt.Println("\nID=2を削除後:")
    all, _ = dao.ListAll()
    for _, user := range all {
        fmt.Printf("  [%d] %s | %s | %d歳\n",
            user.ID, user.Name, user.Email, user.Age)
    }
}
▶ 試してみよう
TEXT
挿入しました: Alice (ID=1)
挿入しました: Bob (ID=2)
挿入しました: Charlie (ID=3)

ID=1をクエリ: {ID:1 Name:Alice Email:alice@example.com Age:25}
更新後: {ID:1 Name:Alice Email:alice@example.com Age:26}

すべてのユーザー:
  [1] Alice | alice@example.com | 26歳
  [2] Bob | bob@example.com | 30歳
  [3] Charlie | charlie@example.com | 28歳

ID=2を削除後:
  [1] Alice | alice@example.com | 26歳
  [3] Charlie | charlie@example.com | 28歳

例:トランザクション送金 + 接続プール設定(難易度⭐⭐⭐)

銀行送金シナリオをシミュレートし、トランザクションのアトミック性保証と接続プール設定を実演します。

GO
package main

import (
    "database/sql"
    "fmt"
    "log"
    "time"

    _ "github.com/mattn/go-sqlite3"
)

// Accountは銀行口座
type Account struct {
    ID      int
    Name    string
    Balance float64
}

func initDB(db *sql.DB) error {
    // テーブルを作成
    _, err := db.Exec(`
        CREATE TABLE IF NOT EXISTS accounts (
            id      INTEGER PRIMARY KEY AUTOINCREMENT,
            name    TEXT    NOT NULL,
            balance REAL    NOT NULL DEFAULT 0
        )
    `)
    return err
}

// Transferは送金を実行する(トランザクション操作)
// fromIDからtoIDへ金額を送金する
func Transfer(db *sql.DB, fromID, toID int, amount float64) error {
    // トランザクションを開始
    tx, err := db.Begin()
    if err != nil {
        return fmt.Errorf("トランザクションの開始に失敗しました: %w", err)
    }

    // トランザクションがコミットまたはロールバックされることを保証
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    // 送金元口座の残高を確認
    var fromBalance float64
    err = tx.QueryRow("SELECT balance FROM accounts WHERE id = ?", fromID).Scan(&fromBalance)
    if err != nil {
        return fmt.Errorf("送金元口座のクエリに失敗しました: %w", err)
    }

    if fromBalance < amount {
        return fmt.Errorf("残高不足: 現在 %.2f、必要 %.2f", fromBalance, amount)
    }

    // 送金元から引き落とし
    _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
    if err != nil {
        return fmt.Errorf("引き落としに失敗しました: %w", err)
    }

    // 送金先に入金
    _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
    if err != nil {
        return fmt.Errorf("入金に失敗しました: %w", err)
    }

    // ビジネスロジックをシミュレート:操作ログを記録(同じトランザクション内で)
    _, err = tx.Exec(
        "INSERT INTO transfer_log (from_id, to_id, amount, created_at) VALUES (?, ?, ?, ?)",
        fromID, toID, amount, time.Now().Format(time.RFC3339),
    )
    if err != nil {
        // ログテーブルが存在しない場合、エラーを無視(デモンストレーションのみ)
        fmt.Printf("  [警告] 送金ログの記録に失敗しました: %v\n", err)
    }

    // トランザクションをコミット
    if err = tx.Commit(); err != nil {
        return fmt.Errorf("トランザクションのコミットに失敗しました: %w", err)
    }

    return nil
}

// PrintBalancesはすべての口座残高を表示する
func PrintBalances(db *sql.DB) {
    rows, err := db.Query("SELECT id, name, balance FROM accounts ORDER BY id")
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    fmt.Println("  口座残高:")
    for rows.Next() {
        var a Account
        if err := rows.Scan(&a.ID, &a.Name, &a.Balance); err != nil {
            log.Fatal(err)
        }
        fmt.Printf("    [%d] %-6s 残高: %.2f\n", a.ID, a.Name, a.Balance)
    }
}

func main() {
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 接続プールを設定
    db.SetMaxOpenConns(10)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(5 * time.Minute)
    db.SetConnMaxIdleTime(3 * time.Minute)

    fmt.Println("✓ 接続プールが設定されました:")
    fmt.Println("  最大接続数: 10")
    fmt.Println("  最大アイドル数: 5")
    fmt.Println("  接続存続期間: 5分")
    fmt.Println()

    // テーブルを初期化
    if err := initDB(db); err != nil {
        log.Fatal(err)
    }

    // 送金ログテーブルを作成(オプション)
    db.Exec(`CREATE TABLE IF NOT EXISTS transfer_log (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        from_id INTEGER, to_id INTEGER,
        amount REAL, created_at TEXT
    )`)

    // 初期口座を挿入
    _, err = db.Exec("INSERT INTO accounts (name, balance) VALUES (?, ?), (?, ?)",
        "Alice", 1000.00, "Bob", 500.00)
    if err != nil {
        log.Fatal("口座の初期化に失敗しました:", err)
    }

    fmt.Println("=== 初期状態 ===")
    PrintBalances(db)
    fmt.Println()

    // 通常の送金:Alice → Bob 300
    fmt.Println("=== Alice → Bob 送金 300 ===")
    if err := Transfer(db, 1, 2, 300); err != nil {
        fmt.Printf("送金に失敗しました: %v\n", err)
    } else {
        fmt.Println("送金に成功しました!")
    }
    PrintBalances(db)
    fmt.Println()

    // 残高不足の送金:Bob → Alice 900
    fmt.Println("=== Bob → Alice 送金 900 ===")
    if err := Transfer(db, 2, 1, 900); err != nil {
        fmt.Printf("送金に失敗しました: %v\n", err)
    } else {
        fmt.Println("送金に成功しました!")
    }
    PrintBalances(db)
    fmt.Println()

    // 別の通常送金
    fmt.Println("=== Bob → Alice 送金 100 ===")
    if err := Transfer(db, 2, 1, 100); err != nil {
        fmt.Printf("送金に失敗しました: %v\n", err)
    } else {
        fmt.Println("送金に成功しました!")
    }
    PrintBalances(db)
}
▶ 試してみよう
TEXT
✓ 接続プールが設定されました:
  最大接続数: 10
  最大アイドル数: 5
  接続存続期間: 5分

=== 初期状態 ===
  口座残高:
    [1] Alice  残高: 1000.00
    [2] Bob    残高: 500.00

=== Alice → Bob 送金 300 ===
送金に成功しました!
  口座残高:
    [1] Alice  残高: 700.00
    [2] Bob    残高: 800.00

=== Bob → Alice 送金 900 ===
送金に失敗しました: 残高不足: 現在 800.00、必要 900.00
  口座残高:
    [1] Alice  残高: 700.00
    [2] Bob    残高: 800.00

=== Bob → Alice 送金 100 ===
送金に成功しました!
  口座残高:
    [1] Alice  残高: 800.00
    [2] Bob    残高: 700.00

実世界のシナリオ

シナリオ1:ユーザー登録とログイン(MySQL)

MySQLを使用してユーザー情報を保存し、登録とパスワード検証を実装します。

GO
package main

import (
    "crypto/sha256"
    "database/sql"
    "encoding/hex"
    "fmt"
    "log"
    "time"

    _ "github.com/go-sql-driver/mysql" // MySQLドライバ
)

// hashPasswordはシンプルなパスワードハッシュ(本番ではbcryptを使用すべき)
func hashPassword(password string) string {
    h := sha256.Sum256([]byte(password))
    return hex.EncodeToString(h[:])
}

// UserRepositoryはユーザーリポジトリ
type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(dsn string) (*UserRepository, error) {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, err
    }

    // 接続プールを設定
    db.SetMaxOpenConns(20)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(10 * time.Minute)

    if err := db.Ping(); err != nil {
        return nil, fmt.Errorf("データベース接続に失敗しました: %w", err)
    }

    return &UserRepository{db: db}, nil
}

func (r *UserRepository) Close() error {
    return r.db.Close()
}

// Registerは新しいユーザーを登録する
func (r *UserRepository) Register(username, password, email string) (int64, error) {
    hashedPwd := hashPassword(password)

    result, err := r.db.Exec(
        "INSERT INTO users (username, password_hash, email, created_at) VALUES (?, ?, ?, ?)",
        username, hashedPwd, email, time.Now(),
    )
    if err != nil {
        return 0, fmt.Errorf("登録に失敗しました: %w", err)
    }

    return result.LastInsertId()
}

// Loginはユーザーログインを検証する
func (r *UserRepository) Login(username, password string) (int, error) {
    hashedPwd := hashPassword(password)

    var id int
    var storedHash string

    err := r.db.QueryRow(
        "SELECT id, password_hash FROM users WHERE username = ?",
        username,
    ).Scan(&id, &storedHash)

    if err == sql.ErrNoRows {
        return 0, fmt.Errorf("ユーザーが存在しません")
    }
    if err != nil {
        return 0, fmt.Errorf("クエリに失敗しました: %w", err)
    }

    if storedHash != hashedPwd {
        return 0, fmt.Errorf("パスワードが正しくありません")
    }

    // 最終ログイン時刻を更新
    _, _ = r.db.Exec(
        "UPDATE users SET last_login = ? WHERE id = ?",
        time.Now(), id,
    )

    return id, nil
}

func main() {
    // DSN形式: username:password@tcp(host:port)/dbname?parseTime=true
    dsn := "root:password@tcp(127.0.0.1:3306)/myapp?parseTime=true"

    repo, err := NewUserRepository(dsn)
    if err != nil {
        log.Fatal("初期化に失敗しました:", err)
    }
    defer repo.Close()

    // 登録
    id, err := repo.Register("alice", "mypassword123", "alice@example.com")
    if err != nil {
        log.Fatal("登録に失敗しました:", err)
    }
    fmt.Printf("登録に成功しました、ユーザーID: %d\n", id)

    // ログイン
    userID, err := repo.Login("alice", "mypassword123")
    if err != nil {
        fmt.Printf("ログインに失敗しました: %v\n", err)
    } else {
        fmt.Printf("ログインに成功しました、ユーザーID: %d\n", userID)
    }

    // 間違ったパスワード
    _, err = repo.Login("alice", "wrongpassword")
    if err != nil {
        fmt.Printf("ログインに失敗しました: %v\n", err)
    }
}

シナリオ2:ページネーションクエリとバッチ挿入

大量のデータに対してページネーションクエリとバッチ挿入を効率的に処理します。

GO
package main

import (
    "database/sql"
    "fmt"
    "log"
    "strings"

    _ "github.com/mattn/go-sqlite3"
)

// Productは商品
type Product struct {
    ID    int
    Name  string
    Price float64
    Stock int
}

// PageResultはページネーション結果
type PageResult struct {
    Items      []Product
    Total      int
    Page       int
    PageSize   int
    TotalPages int
}

// ProductStoreは商品ストレージ
type ProductStore struct {
    db *sql.DB
}

func NewProductStore(db *sql.DB) (*ProductStore, error) {
    _, err := db.Exec(`
        CREATE TABLE IF NOT EXISTS products (
            id    INTEGER PRIMARY KEY AUTOINCREMENT,
            name  TEXT NOT NULL,
            price REAL NOT NULL,
            stock INTEGER DEFAULT 0
        )
    `)
    if err != nil {
        return nil, err
    }
    return &ProductStore{db: db}, nil
}

// BatchInsertはバッチ挿入する(トランザクションを使用してパフォーマンスを向上)
func (s *ProductStore) BatchInsert(products []Product) error {
    tx, err := s.db.Begin()
    if err != nil {
        return err
    }

    stmt, err := tx.Prepare("INSERT INTO products (name, price, stock) VALUES (?, ?, ?)")
    if err != nil {
        tx.Rollback()
        return err
    }
    defer stmt.Close()

    for _, p := range products {
        _, err := stmt.Exec(p.Name, p.Price, p.Stock)
        if err != nil {
            tx.Rollback()
            return fmt.Errorf("%s の挿入に失敗しました: %w", p.Name, err)
        }
    }

    return tx.Commit()
}

// Paginateはページネーションクエリ
func (s *ProductStore) Paginate(page, pageSize int) (*PageResult, error) {
    if page < 1 {
        page = 1
    }
    if pageSize < 1 {
        pageSize = 10
    }

    // 合計件数をクエリ
    var total int
    err := s.db.QueryRow("SELECT COUNT(*) FROM products").Scan(&total)
    if err != nil {
        return nil, err
    }

    // 総ページ数を計算
    totalPages := total / pageSize
    if total%pageSize > 0 {
        totalPages++
    }

    // 現在のページデータをクエリ
    offset := (page - 1) * pageSize
    rows, err := s.db.Query(
        "SELECT id, name, price, stock FROM products ORDER BY id LIMIT ? OFFSET ?",
        pageSize, offset,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var items []Product
    for rows.Next() {
        var p Product
        if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock); err != nil {
            return nil, err
        }
        items = append(items, p)
    }

    return &PageResult{
        Items:      items,
        Total:      total,
        Page:       page,
        PageSize:   pageSize,
        TotalPages: totalPages,
    }, nil
}

// Searchは名前であいまい検索する
func (s *ProductStore) Search(keyword string, page, pageSize int) (*PageResult, error) {
    likePattern := "%" + keyword + "%"

    var total int
    err := s.db.QueryRow(
        "SELECT COUNT(*) FROM products WHERE name LIKE ?", likePattern,
    ).Scan(&total)
    if err != nil {
        return nil, err
    }

    totalPages := total / pageSize
    if total%pageSize > 0 {
        totalPages++
    }

    offset := (page - 1) * pageSize
    rows, err := s.db.Query(
        "SELECT id, name, price, stock FROM products WHERE name LIKE ? ORDER BY id LIMIT ? OFFSET ?",
        likePattern, pageSize, offset,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var items []Product
    for rows.Next() {
        var p Product
        if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock); err != nil {
            return nil, err
        }
        items = append(items, p)
    }

    return &PageResult{
        Items:      items,
        Total:      total,
        Page:       page,
        PageSize:   pageSize,
        TotalPages: totalPages,
    }, nil
}

func printPage(result *PageResult) {
    fmt.Printf("  %d/%dページ(合計 %d 件)\n",
        result.Page, result.TotalPages, result.Total)
    for _, p := range result.Items {
        fmt.Printf("    [%d] %-10s 価格: %6.2f  在庫: %d\n",
            p.ID, p.Name, p.Price, p.Stock)
    }
}

func main() {
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    store, err := NewProductStore(db)
    if err != nil {
        log.Fatal(err)
    }

    // 50件の商品をバッチ挿入
    var products []Product
    names := []string{"キーボード", "マウス", "モニター", "ヘッドセット", "スピーカー",
        "ウェブカメラ", "USBドライブ", "ハードドライブ", "RAM", "マザーボード"}
    for i := 0; i < 50; i++ {
        products = append(products, Product{
            Name:  fmt.Sprintf("%s-%02d", names[i%len(names)], i+1),
            Price: float64(50 + i*10),
            Stock: 100 - i,
        })
    }

    if err := store.BatchInsert(products); err != nil {
        log.Fatal("バッチ挿入に失敗しました:", err)
    }
    fmt.Printf("✓ %d 件の商品をバッチ挿入しました\n\n", len(products))

    // ページネーションクエリ
    fmt.Println("=== ページネーションクエリ(10件/ページ)===")
    for page := 1; page <= 3; page++ {
        result, err := store.Paginate(page, 10)
        if err != nil {
            log.Fatal(err)
        }
        printPage(result)
        fmt.Println()
    }

    // 検索
    fmt.Println("=== 'キーボード' を検索 ===")
    result, err := store.Search("キーボード", 1, 10)
    if err != nil {
        log.Fatal(err)
    }
    printPage(result)
}
TEXT
✓ 50 件の商品をバッチ挿入しました

=== ページネーションクエリ(10件/ページ)===
  1/5ページ(合計 50 件)
    [1] キーボード-01 価格:  50.00  在庫: 100
    [2] マウス-02     価格:  60.00  在庫: 99
    ...

=== 'キーボード' を検索 ===
  1/1ページ(合計 5 件)
    [1] キーボード-01  価格:  50.00  在庫: 100
    [11] キーボード-11 価格: 150.00  在庫: 90
    [21] キーボード-21 価格: 250.00  在庫: 80
    [31] キーボード-31 価格: 350.00  在庫: 70
    [41] キーボード-41 価格: 450.00  在庫: 60

❓ よくある質問

質問1:なぜsql.Open()unknown driverと表示されるのですか?

ドライバが正しくインポートされていません。匿名インポートを使用していることを確認してください:

GO
import (
    "database/sql"
    _ "github.com/mattn/go-sqlite3" // この行が必要です
)

_はパッケージのinit()関数(ドライバの登録)のみを実行し、パッケージ名を直接参照しないことを意味します。ドライバのインポートを忘れるのは初心者によくある間違いです。

質問2:データがない場合にQueryRow().Scan()がエラーを報告するのはなぜですか?

クエリに一致する行がない場合、Scan()sql.ErrNoRowsを返します。個別に処理する必要があります:

GO
var name string
err := db.QueryRow("SELECT name FROM users WHERE id = ?", 999).Scan(&name)
if err == sql.ErrNoRows {
    fmt.Println("ユーザーが存在しません")
} else if err != nil {
    log.Fatal("クエリエラー:", err)
}

質問3:db.Close()はいつ呼び出すべきですか?

sql.DBは接続プールであり、プログラムのライフサイクル全体を通じて開いたままにすべきで、各操作後に閉じるべきではありません。通常、main()関数の冒頭でdefer db.Close()を使用します:

GO
func main() {
    db, err := sql.Open("sqlite3", "./mydb.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close() // プログラム終業時のみ閉じる

    // ... dbを使用してさまざまな操作を行う
}

質問4:SQLインジェクションを防ぐには?

文字列連結でSQLステートメントを構築しないでください。常にパラメータ化クエリを使用してください:

GO
// ❌ 危険!SQLインジェクションのリスク
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", userInput)

// ✓ 安全:パラメータプレースホルダーを使用
db.Query("SELECT * FROM users WHERE name = ?", userInput)

?プレースホルダーはドライバによって自動的にエスケープされ、データベースはSQLコードとしてではなく純粋なデータとして扱います。


📖 まとめ

このレッスンでは以下を学びました:

トピック ポイント
database/sql Go標準ライブラリの統一インターフェース、任意のデータベースドライバをサポート
ドライバのインポート _で匿名インポートする必要があり、ドライバのみ登録される
sql.Open 接続プールを作成、すぐに接続しない。Ping()で検証する
CRUD Execで書き込み操作、Query/QueryRowでデータを読み取る
プリペアドステートメント Prepareでパフォーマンスを向上、SQLインジェクションを防止
トランザクション BeginExecCommit/Rollback、アトミック性を保証
接続プール SetMaxOpenConns/SetMaxIdleConnsなどのチューニング方法
Scan データベースカラムをGo変数にマッピング
sql.ErrNoRows クエリが結果を返さない場合の特別なエラー、個別の処理が必要

これらのスキルにより、Goプロジェクトでリレーショナルデータベースを操作する完全な能力を身につけました。


📝 演習

演習1:基本CRUD(ウォームアップ)

SQLiteデータベースを作成し、booksテーブル(フィールド:id、title、author、price、published_year)を作成して、以下の操作を完了してください:

  1. 少なくとも5冊の本を挿入
  2. 価格が50より大きい本をクエリ
  3. 本の価格を更新
  4. 2000年以前に出版された本を削除

演習2:トランザクションとエラー処理

演習1に基づいて、TransferBook関数を実装してください:著者から別の著者へすべての本を転送します(トランザクションでアトミック性を保証)。要件:

演習3:総合チャレンジ — ミニ図書管理システム

以下の要件を持つコマンドライン図書管理システムを構築してください:

  1. CRUD操作とページネーションリストをサポート
  2. DAOパターンを使用してデータベース操作をカプセル化
  3. タイトルでのあいまい検索と価格範囲によるフィルタリングをサポート
  4. プリペアドステートメントを使用して繰り返しクエリを最適化
  5. 適切な接続プールパラメータを設定
  6. log.Fatalの代わりに構造化されたエラー処理を使用

次のレッスン:レッスン28:プロジェクトデプロイ — Goプロジェクトのコンパイル、パッケージング、サーバーへのデプロイ方法を学びます。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%