Consultas e instrucciones con go-mssqldb

El go-mssqldb controlador utiliza la interfaz estándar database/sql para ejecutar consultas y sentencias. Este artículo aborda patrones comunes para el acceso a datos con el controlador.

Ejecución de una consulta SELECT

Úsase QueryContext para ejecutar una consulta que devuelva filas:

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)
}

Importante

Llama siempre a rows.Close() (normalmente con defer) y comprueba rows.Err() después del bucle. No cerrar los rows puede provocar fugas de conexiones del pool. rows.Close() también puede devolver un error del lado del servidor mientras el controlador vacía los tokens restantes, así que no lo ignores cuando el conjunto de resultados no se haya consumido por completo.

Si dejas de leer antes de terminar, cierra las filas explícitamente y gestiona el error al cerrarlas:

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)
}

Los ejemplos de este artículo se aplican a la base de datos de ejemplo AdventureWorks2025 . Ejemplos orientados a lectura consultan objetos integrados como Sales.vSalesPerson, Production.Product, y Sales.SalesOrderHeader. Los ejemplos orientados a la escritura están dirigidos a HumanResources.Department y Production.ProductInventory.

Consulta una sola fila

Usa QueryRowContext cuando esperes exactamente una fila:

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)
}

Ejecutar una sentencia

Usa ExecContext para INSERT, UPDATE, DELETE, y las sentencias DDL:

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)

Importante

El go-mssqldb controlador no soporta LastInsertId(). Llamarlo devuelve un error. Utiliza una OUTPUT cláusula o una consulta separada SELECT SCOPE_IDENTITY() para recuperar un valor de identidad insertado.

Si usas SELECT SCOPE_IDENTITY(), ejecútalo en el mismo lote o en la misma transacción que INSERT, para que el ámbito de identidad permanezca en la misma conexión.

Si un procedimiento almacenado o disparador usa SET NOCOUNT ON, RowsAffected() devuelve 0 porque SQL Server suprime el mensaje de recuento de filas. Si necesitas el recuento real, o bien quita SET NOCOUNT ON del procedimiento, o bien devuelve el recuento explícitamente mediante un parámetro de salida o una instrucción SELECT.

Consultas parametrizadas

Utiliza siempre consultas parametrizadas para evitar la inyección SQL. El controlador admite tanto parámetros posicionales como parámetros con nombre.

Importante

El go-mssqldb controlador utiliza @p1, @p2, y así sucesivamente para parámetros posicionales y sql.Named() para parámetros nombrados. La sintaxis de marcador ? que utilizan algunos otros controladores (como go-sql-driver la de MySQL) no funciona con el nombre de controlador sqlserver. Si estás migrando de otra base de datos, sustituye todos los marcadores de posición de tipo ? o $1 por marcadores de posición de tipo @p1 o parámetros con nombre.

Parámetros posicionales

Usa los marcadores @p1, @p2 y pasa los valores en orden:

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

Parámetros con nombre

Usar sql.Named() para vincular valores a marcadores de posición nombrados:

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"))

Varios conjuntos de resultados

Use rows.NextResultSet() para recorrer varios conjuntos de resultados que devuelve un único lote o procedimiento almacenado.

Importante

Debes agotar rows.Next() completamente para cada conjunto de resultados antes de llamar rows.NextResultSet(). Si se llama a NextResultSet() antes de Next(), se devuelve false y se omiten las filas restantes de forma silenciosa.

Utiliza este patrón de bucle para procesar todos los conjuntos de resultados de forma fiable:

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

Úsalo BeginTx para iniciar una transacción con un nivel de aislamiento específico. Para una guía completa sobre transacciones, incluyendo niveles de aislamiento, puntos de guardado, gestión de bloqueos y patrones de reintentos, consulte Transacciones.

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)
}

Inserta valores de identidad

El go-mssqldb controlador no soporta LastInsertId(). Utiliza la cláusula OUTPUT para recuperar el valor de identidad en la misma sentencia:

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)

Para varias filas:

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)
}

Pagination

Uso OFFSET y FETCH NEXT para paginación en servidor. Se requiere una ORDER BY cláusula:

Paginación basada en desplazamiento

Pasa el desplazamiento y el tamaño de página como parámetros:

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()
}

Paginación por conjunto de claves para tablas grandes

La paginación con desplazamiento resulta lenta en tablas grandes porque el servidor debe omitir filas. La paginación por conjunto de teclas utiliza la última clave vista para obtener la siguiente página de forma eficiente:

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

La paginación por conjunto de teclas es significativamente más rápida que OFFSET/FETCH para páginas profundas (página 1000+) porque utiliza una búsqueda de índice en lugar de escanear y saltar filas.

Agrupar varias sentencias en lote

Envía varias sentencias SQL en una sola llamada para reducir los viajes de ida y vuelta de red.

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)

Procesar conjuntos de resultados grandes de manera eficiente

Para consultas que devuelven millones de filas, procese los resultados en modo streaming. No acumules todas las filas en memoria.

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()
}

Precaución

Un open *sql.Rows fija una conexión desde la piscina hasta que rows.Close() se llama. Para el procesamiento muy prolongado de conjuntos de resultados, considera dividir el trabajo en intervalos mediante paginación por claves para no mantener una conexión abierta durante minutos.

Upsert con MERGE

SQL Server utiliza la MERGE instrucción para operaciones de inserción o actualización (upsert).

_, 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))

Instrucciones preparadas

Úsalo PrepareContext para crear una declaración preparada reutilizable. Las sentencias preparadas pueden mejorar el rendimiento cuando la misma consulta se ejecuta muchas veces con parámetros diferentes.

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)
}

Cancelación del contexto

Todos los database/sql métodos aceptan un context.Context. Úsalo para tiempos muertos y cancelaciones.

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

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

Si expira el plazo de contexto, el controlador cancela la consulta en el servidor y devuelve un error al llamante.