Estrategias de persistencia: de SQL manual a código generado

En el ecosistema de Go, la interacción con bases de datos no tiene una solución única, sino un espectro de abstracción que va desde el control absoluto hasta la magia casi total. En un extremo tenemos database/sql y sqlx, donde tú escribes cada línea de SQL y cada mapeo de columnas; es el estándar de oro para el control total, pero es extremadamente verboso. En el otro extremo están los ORM completos como gorm o ent, que abstraen la base de datos tras una capa de estructuras y métodos, permitiéndote trabajar con objetos en lugar de filas. Entre ambos, encontramos los query builders como squirrel para construir SQL de forma programática y la joya de la corona para sistemas de alto rendimiento: la generación de código como sqlc.

Esta fragmentación existe porque Go prioriza la explicitness (explicitud) sobre la magia. Un ORM puede ser extremadamente productivo al prototipar, pero en producción, cuando una consulta compleja de 200 líneas se convierte en un db.Preload("Orders").Find(&users), pierdes la visibilidad de lo que realmente está ocurriendo en el motor de la base de datos. La filosofía de sqlc es distinta: tú escribes SQL puro en archivos .sql, y el compilador genera el código Go necesario para mapear esos resultados a tipos seguros. Esto elimina la necesidad de escribir código repetitivo (el famoso boilerplate) sin sacrificar la capacidad de optimizar tus queries.

Debes usar sqlc cuando construyes microservicios de alta carga donde cada milisegundo y cada JOIN importa, ya que el SQL es explícito y los errores de tipos se detectan en tiempo de compilación. Usa un ORM como gorm si estás haciendo un MVP o una herramienta administrativa rápida donde la velocidad de desarrollo es más crítica que la optimización extrema. Si intentas usar un ORM para consultas complejas de analítica o reportes, lo que rompes es la predictibilidad del rendimiento: te enfrentarás al problema de las consultas N+1 o a SQL generado ineficientemente que es casi imposible de debuguear.

package main

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

	// Importamos el driver de sqlite para que el ejemplo sea ejecutable.
	// En producción usarías drivers como pgx para PostgreSQL.
	_ "github.com/mattn/go-sqlite3"
)

// User representa nuestra entidad de negocio.
type User struct {
	ID        int64
	Email     string
	CreatedAt time.Time
	IsActive  bool
}

// UserRepo define la interfaz de nuestro repositorio.
// En un flujo con sqlc, esta interfaz y sus implementaciones
// serían generadas automáticamente por la herramienta.
type UserRepo interface {
	GetByID(ctx context.Context, id int64) (*User, error)
	ListActive(ctx context.Context) ([]User, error)
}

// sqlRepo es la implementación manual (estilo database/sql / sqlx).
// Aquí es donde el desarrollador gasta la mayor parte de su tiempo
// escribiendo código repetitivo y propenso a errores de mapeo.
type sqlRepo struct {
	db *sql.DB
}

func (r *sqlRepo) GetByID(ctx context.Context, id int64) (*User, error) {
	// Si añadimos una columna a la tabla 'users', esta línea y el Scan fallarán.
	const query = `SELECT id, email, created_at, is_active FROM users WHERE id = ?`
	
	row := r.db.QueryRowContext(ctx, query, id)

	var u User
	// El error frecuente: el orden y el número de argumentos en Scan
	// debe coincidir exactamente con la consulta SQL.
	err := row.Scan(&u.ID, &u.Email, &u.CreatedAt, &u.IsActive)
	if err != nil {
		return nil, fmt.Errorf("error en scan: %w", err)
	}
	return &u, nil
}

func (r *sqlRepo) ListActive(ctx context.Context) ([]User, error) {
	const query = `SELECT id, email, created_at, is_active FROM users WHERE is_active = 1`
	
	rows, err := r.db.QueryContext(ctx, query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var users []User
	for rows.Next() {
		var u User
		// El mapeo manual es tedioso y propenso a errores de tipos
		if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt, &u.IsActive); err != nil {
			return nil, err
		}
		users = append(users, u)
	}

	return users, nil
}

func main() {
	// Configuración inicial para el ejemplo ejecutable.
	db, err := sql.Open("sqlite3", ":memory:")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Setup de la base de datos de prueba.
	setupSchema(db)

	// Sembrar datos.
	seedData(db)

	ctx := context.Background()
	repo := &sqlRepo{db: db}

	// Ejecución de consultas.
	user, err := repo.GetByID(ctx, 1)
	if err != nil {
		log.Fatalf("Error buscando usuario: %v", err)
	}
	fmt.Printf("Usuario encontrado: %+v\n", user)

	activeUsers, _ := repo.ListActive(ctx)
	fmt.Printf("Usuarios activos: %d\n", len(activeUsers))
}

func setupSchema(db *sql.DB) {
	schema := `
	CREATE TABLE users (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		email TEXT NOT NULL,
		created_at DATETIME NOT NULL,
		is_active BOOLEAN NOT NULL
	);`
	_, err := db.Exec(schema)
	if err != nil {
		log.Fatal(err)
	}
}

func seedData(db *sql.DB) {
	_, err := db.Exec(`
		INSERT INTO users (email, created_at, is_active) VALUES 
		('dev@example.com', '2023-10-27 10:00:00', 1),
		('test@example.com', '2023-10-27 11:00:00', 0);
	`)
	if err != nil {
		log.Fatal(err)
	}
}

Análisis del ejemplo

En el código anterior, observa la implementación de GetByID en sqlRepo. Aunque el uso de database/sql es el estándar, nos obliga a realizar el mapeo manual mediante row.Scan. Si la tabla users evolucionara y añadimos una columna phone_number, el código fallaría en tiempo de ejecución porque el número de argumentos en Scan ya no coincidiría con el SELECT.

La estructura del repositorio UserRepo representa la abstracción que un desarrollador senior espera. Con una herramienta como sqlc, tú solo escribirías el archivo SQL con la sentencia SELECT id, email, created_at, is_active FROM users WHERE id = $1;. La herramienta leería el esquema de la base de datos y generaría automáticamente la estructura User y el método GetByID, garantizando que los tipos de Go (como time.Time y int64) coincidan perfectamente con los tipos de la base de datos. Esto elimina el error humano en el mapeo y el “ruido” de código repetitivo que vemos en el for rows.Next() de ListActive.

El error frecuente

El error más peligroso en equipos que utilizan ORMs no es un error de sintaxis, sino el problema de las consultas N+1.

// Ejemplo de lo que sucede erróneamente con un ORM en un loop:
for _, user := range users {
    // Este código parece limpio, pero dentro de un loop dispara 
    // una consulta extra a la DB por cada iteración.
    db.Model(&user).Association("Orders").Find(&user.Orders)
}

Si tienes 1,000 usuarios, habrás ejecutado 1,001 consultas a la base de datos. Con sqlc o incluso con sqlx mediante un JOIN explícito, te verías obligado (o motivado) a escribir una única consulta que traiga todo de una vez, lo cual es órdenes de magnitud más eficiente para la latencia de la red y la carga del motor de base de datos.

199

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio