go-mssqldb を用いたクエリと文

go-mssqldbドライバは、クエリの実行や文の実行に標準的なdatabase/sqlインターフェースを使用しています。 この記事では、ドライバーによるデータアクセスの一般的なパターンについて解説します。

SELECT クエリを実行する

QueryContextを使って、以下の行を返すクエリを実行します:

rows, err := db.QueryContext(ctx,
    "SELECT BusinessEntityID, FirstName + ' ' + LastName AS Name, CountryRegionName FROM Sales.vSalesPerson WHERE CountryRegionName = @p1",
    sql.Named("p1", "Australia"))
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
    var id int
    var name, location string
    if err := rows.Scan(&id, &name, &location); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%d: %s (%s)\n", id, name, location)
}
if err = rows.Err(); err != nil {
    log.Fatal(err)
}

Important

rows.Close() を常に呼び出し(通常は defer とともに)、ループの後で rows.Err() を確認してください。 列を閉じないとプールからの接続が漏れる可能性があります。 rows.Close() また、ドライバーが残りのトークンを消耗している間にサーバー側エラーを返すこともあるので、結果セットが完全に消費されていない場合は無視しないでください。

途中で読み取りをやめる場合は、rows を明示的に閉じて、閉じる際のエラーを処理してください:

rows, err := db.QueryContext(ctx,
    "SELECT TOP (100) ProductID, Name FROM Production.Product ORDER BY ProductID")
if err != nil {
    log.Fatal(err)
}

for rows.Next() {
    var id int
    var name string
    if err := rows.Scan(&id, &name); err != nil {
        _ = rows.Close()
        log.Fatal(err)
    }

    fmt.Printf("%d %s\n", id, name)
    break // Stop early for demonstration.
}

if err := rows.Close(); err != nil {
    log.Fatal(err)
}
if err := rows.Err(); err != nil {
    log.Fatal(err)
}

この記事の例は AdventureWorks2025 のサンプルデータベースと比較しています。 読み取り指向の例は、 Sales.vSalesPersonProduction.ProductSales.SalesOrderHeaderなどの組み込みオブジェクトをクエリします。 書き込み指向の例は HumanResources.DepartmentProduction.ProductInventoryを対象としています。

1 行を取得する

正確に1行になると予想するときに QueryRowContext を使います:

var id int
var name string
err := db.QueryRowContext(ctx,
    "SELECT BusinessEntityID, FirstName + ' ' + LastName AS Name FROM Sales.vSalesPerson WHERE BusinessEntityID = @p1",
    sql.Named("p1", 280)).Scan(&id, &name)
if err == sql.ErrNoRows {
    fmt.Println("No employee found.")
} else if err != nil {
    log.Fatal(err)
} else {
    fmt.Printf("Employee %d: %s\n", id, name)
}

ステートメントを実行する

ExecContextINSERTUPDATE、DDL文にはDELETEを使います:

result, err := db.ExecContext(ctx,
    "INSERT INTO HumanResources.Department (Name, GroupName) VALUES (@p1, @p2)",
    sql.Named("p1", "Data Science"),
    sql.Named("p2", "Research and Development"))
if err != nil {
    log.Fatal(err)
}

rowsAffected, _ := result.RowsAffected()
fmt.Printf("Rows affected: %d\n", rowsAffected)

Important

go-mssqldbドライバーはLastInsertId()をサポートしていません。 呼び出すとエラーが返されます。 挿入された識別値を取得するには、別 OUTPUT 節または別の SELECT SCOPE_IDENTITY() クエリを使用します。

SELECT SCOPE_IDENTITY()を使う場合は、INSERTと同じバッチやトランザクション内で実行し、アイデンティティスコープが同じ接続に残るようにしてください。

ストアドプロシージャまたはトリガーで SET NOCOUNT ON を使用している場合、SQL Server が行数メッセージを抑制するため、RowsAffected() は 0 を返します。 実際のカウントが必要な場合は、 SET NOCOUNT ON をプロシージャから削除するか、出力パラメータや SELECT 文で明示的にカウントを返すのが良いでしょう。

パラメーター化されたクエリ

SQLインジェクションを避けるために、常にパラメータ付きクエリを使いましょう。 ドライバーは位置パラメータと名前付きパラメータの両方をサポートしています。

Important

go-mssqldbドライバーは位置パラメータに@p1@p2などを使い、名前付きパラメータにはsql.Named()を使用します。 MySQLの?など他のドライバーで使われているgo-sql-driverプレースホルダー構文は、sqlserverドライバー名には対応しません。 他のデータベースから移行する場合は、すべての ?$1スタイルのプレースホルダーを @p1スタイルまたは名前付きパラメータに置き換えてください。

位置指定パラメーター

@p1@p2プレースホルダー、パス値を順番に使います:

rows, err := db.QueryContext(ctx,
    "SELECT BusinessEntityID, FirstName, CountryRegionName FROM Sales.vSalesPerson WHERE FirstName = @p1 AND CountryRegionName = @p2",
    "Jared", "Australia")

名前付きパラメーター

sql.Named()を使って、値を名前付きのプレースホルダーに割り当てます:

rows, err := db.QueryContext(ctx,
    "SELECT BusinessEntityID, FirstName, CountryRegionName FROM Sales.vSalesPerson WHERE FirstName = @name AND CountryRegionName = @location",
    sql.Named("name", "Jared"),
    sql.Named("location", "Australia"))

複数の結果セット

rows.NextResultSet()を使って、単一のバッチやストアドプロシージャが返す複数の結果セットを反復処理します。

Important

rows.Next() を呼び出す前に、各結果セットについて rows.NextResultSet() を最後まで完全に処理しなければなりません。 NextResultSet() の前に Next() を呼び出すと、false を返し、残りの行を何も通知せずにスキップします。

このループパターンを使ってすべての結果セットを確実に処理します:

rows, err := db.QueryContext(ctx,
    `SELECT TOP (3) ProductID, Name
     FROM Production.Product
     ORDER BY ProductID;

    SELECT TOP (3) SalesOrderID, CONVERT(NVARCHAR(10), OrderDate, 23) AS OrderDate
     FROM Sales.SalesOrderHeader
     ORDER BY SalesOrderID DESC;`)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

setIndex := 0
for {
    switch setIndex {
    case 0:
        for rows.Next() {
            var productID int
            var productName string
            if err := rows.Scan(&productID, &productName); err != nil {
                log.Fatal(err)
            }
            fmt.Printf("Product %d: %s\n", productID, productName)
        }
    case 1:
        for rows.Next() {
            var salesOrderID int
            var orderDate string
            if err := rows.Scan(&salesOrderID, &orderDate); err != nil {
                log.Fatal(err)
            }
            fmt.Printf("Order %d: %s\n", salesOrderID, orderDate)
        }
    }

    if err := rows.Err(); err != nil {
        log.Fatal(err)
    }
    if !rows.NextResultSet() {
        break
    }
    setIndex++
}

Transactions

BeginTxを使って特定の隔離レベルで取引を開始します。 分離レベル、セーブポイント、デッドロック処理、再試行パターンを含む包括的なトランザクションガイダンスについては 、トランザクションを参照してください。

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
})
if err != nil {
    log.Fatal(err)
}
defer tx.Rollback()

// Subtract from source location.
_, err = tx.ExecContext(ctx,
    "UPDATE Production.ProductInventory SET Quantity = Quantity - @p1 WHERE ProductID = @p2 AND LocationID = 1",
    sql.Named("p1", 5),
    sql.Named("p2", 1))
if err != nil {
    log.Fatal(err)
}

// Add to destination location.
_, err = tx.ExecContext(ctx,
    "UPDATE Production.ProductInventory SET Quantity = Quantity + @p1 WHERE ProductID = @p2 AND LocationID = 6",
    sql.Named("p1", 5),
    sql.Named("p2", 1))
if err != nil {
    log.Fatal(err)
}

if err = tx.Commit(); err != nil {
    log.Fatal(err)
}

挿入された識別値を取得する

go-mssqldbドライバーはLastInsertId()をサポートしていません。 同じ文の単位元値を取得するために OUTPUT 節を用いてください:

var newID int64
err := db.QueryRowContext(ctx,
    "INSERT INTO HumanResources.Department (Name, GroupName) OUTPUT INSERTED.DepartmentID VALUES (@name, @grp)",
    sql.Named("name", "Data Science"),
    sql.Named("grp", "Research and Development")).Scan(&newID)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Inserted department with ID: %d\n", newID)

複数列の場合:

rows, err := db.QueryContext(ctx, `
    INSERT INTO HumanResources.Department (Name, GroupName)
    OUTPUT INSERTED.DepartmentID, INSERTED.Name
    VALUES (@n1, @g1), (@n2, @g2)`,
    sql.Named("n1", "Data Science"), sql.Named("g1", "Research and Development"),
    sql.Named("n2", "Cloud Ops"), sql.Named("g2", "Information Technology"))
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
    var id int64
    var name string
    if err := rows.Scan(&id, &name); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Inserted: %d - %s\n", id, name)
}

ページネーション

サーバー側のページ割り当てには OFFSETFETCH NEXT を使いましょう。 ORDER BY条項が必要です:

オフセットベースのページネーション

オフセットとページサイズをパラメータとして渡します:

func getEmployeesPage(ctx context.Context, db *sql.DB, page, pageSize int) ([]Employee, error) {
    offset := (page - 1) * pageSize
    rows, err := db.QueryContext(ctx, `
        SELECT BusinessEntityID, FirstName + ' ' + LastName AS Name, CountryRegionName AS Location
        FROM Sales.vSalesPerson
        ORDER BY BusinessEntityID
        OFFSET @offset ROWS
        FETCH NEXT @pageSize ROWS ONLY`,
        sql.Named("offset", offset),
        sql.Named("pageSize", pageSize))
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var employees []Employee
    for rows.Next() {
        var e Employee
        if err := rows.Scan(&e.Id, &e.Name, &e.Location); err != nil {
            return nil, err
        }
        employees = append(employees, e)
    }
    return employees, rows.Err()
}

大規模テーブル向けのキーセットページング

大きなテーブルでは、サーバーが行をスキップしなければならないため、オフセットページ化が遅くなります。 キーセットページメーションは、最後に見たキーを使って効率的に次のページを取得します:

func getNextPage(ctx context.Context, db *sql.DB, lastID int, pageSize int) ([]Employee, error) {
    rows, err := db.QueryContext(ctx, `
        SELECT TOP(@pageSize) BusinessEntityID, FirstName + ' ' + LastName AS Name, CountryRegionName AS Location
        FROM Sales.vSalesPerson
        WHERE BusinessEntityID > @lastID
        ORDER BY BusinessEntityID`,
        sql.Named("pageSize", pageSize),
        sql.Named("lastID", lastID))
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var employees []Employee
    for rows.Next() {
        var e Employee
        if err := rows.Scan(&e.Id, &e.Name, &e.Location); err != nil {
            return nil, err
        }
        employees = append(employees, e)
    }
    return employees, rows.Err()
}

Tip

キーセットページネーションは、行をスキャンしてスキップするのではなくインデックスシークを使用するため、ページ番号が大きいページ(1000ページ以降)では OFFSET/FETCH よりも大幅に高速です。

複数のステートメントを一括処理する

ネットワークの往復を減らすために、1回の通話で複数のSQL文を送信しましょう。

rows, err := db.QueryContext(ctx, `
    SELECT COUNT(*) FROM HumanResources.Employee;
    SELECT COUNT(*) FROM Sales.SalesOrderHeader;
    SELECT COUNT(*) FROM Production.Product;`)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

var empCount, orderCount, productCount int

if rows.Next() {
    if err := rows.Scan(&empCount); err != nil {
        log.Fatal(err)
    }
}

if rows.NextResultSet() && rows.Next() {
    if err := rows.Scan(&orderCount); err != nil {
        log.Fatal(err)
    }
}

if rows.NextResultSet() && rows.Next() {
    if err := rows.Scan(&productCount); err != nil {
        log.Fatal(err)
    }
}

if err := rows.Err(); err != nil {
    log.Fatal(err)
}
fmt.Printf("Employees: %d, Orders: %d, Products: %d\n",
    empCount, orderCount, productCount)

大規模な結果セットを効率的に処理する

数百万行を返すクエリの場合、処理結果はストリーミング方式で行われます。 すべての行をメモリに蓄積しないでください。

func processLargeTable(ctx context.Context, db *sql.DB) error {
    rows, err := db.QueryContext(ctx, "SELECT TransactionID, CONVERT(NVARCHAR(30), TransactionDate, 126) FROM Production.TransactionHistory")
    if err != nil {
        return err
    }
    defer rows.Close()

    var processed int
    for rows.Next() {
        var id int
        var data string
        if err := rows.Scan(&id, &data); err != nil {
            return err
        }

        // Process each row without accumulating.
        if err := handleRow(id, data); err != nil {
            return err
        }

        processed++
        if processed%10000 == 0 {
            log.Printf("Processed %d rows", processed)
        }
    }
    return rows.Err()
}

注意事項

オープン *sql.Rows はプールからの接続をピン留めし、 rows.Close() が呼ばれるまで保持します。 非常に長時間実行された結果セット処理の場合、接続を数分も保持しないようにキーセットページを用いて作業を範囲に分割することを検討してください。

MERGE を使用したアップサート

SQL Serverは挿入または更新(upsert)操作にMERGE文を使用します。

_, err := db.ExecContext(ctx, `
    MERGE HumanResources.Department AS target
    USING (SELECT @id AS DepartmentID, @name AS Name, @grp AS GroupName) AS source
    ON target.DepartmentID = source.DepartmentID
    WHEN MATCHED THEN
        UPDATE SET Name = source.Name, GroupName = source.GroupName
    WHEN NOT MATCHED THEN
        INSERT (Name, GroupName)
        VALUES (source.Name, source.GroupName);`,
    sql.Named("id", dept.Id),
    sql.Named("name", dept.Name),
    sql.Named("grp", dept.GroupName))

準備済みのステートメント

PrepareContextを使って再利用可能な準備済みステートメントを作成しましょう。 同じクエリが異なるパラメータで複数回実行された場合、準備済み文はパフォーマンスを向上させることができます。

stmt, err := db.PrepareContext(ctx,
    "SELECT TOP (1) FirstName + ' ' + LastName AS Name FROM Sales.vSalesPerson WHERE CountryRegionName = @p1")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

for _, location := range []string{"Australia", "India", "Germany"} {
    var name string
    err := stmt.QueryRowContext(ctx, location).Scan(&name)
    if err != nil {
        log.Println(location, err)
        continue
    }
    fmt.Printf("%s: %s\n", location, name)
}

コンテキストキャンセリング

すべての database/sql 手法は、 context.Contextを受け入れています。 タイムアウトやキャンセルに使うのが良いでしょう。

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT * FROM Production.TransactionHistory")

コンテキストの期限が切れると、ドライバーはサーバー上のクエリをキャンセルし、呼び出し元にエラーを返します。