Aceasta este traducerea în română a unui articol publicat inițial în engleză pe Stackademic (Medium): Enhancing Database Interactions in Go: Implementing Custom Types and NULL Management via Database/SQL.
Bazele de date cer adesea o tratare specială a datelor, iar Go are unelte solide pentru ca aplicațiile să comunice fără probleme cu bazele de date relaționale. În articolul ăsta vedem cum ajută pachetul database/sql, cu accent pe interfețele driver.Valuer și sql.Scanner.
În Go, fiecare tip are o valoare „zero” implicită. Pentru int e 0, pentru string e "". Tipurile de referință, ca pointerii, slice-urile, map-urile, canalele și funcțiile, au implicit valoarea nil. Doar că acest nil nu e același lucru cu NULL-ul pe care îl știu cei care lucrează cu baze de date. Când lucrezi cu o bază de date, trebuie să legi sistemul de tipuri din Go de tipurile de date, foarte diverse, ale bazei de date.
Pachetul database/sql are deja tipuri ca sql.NullString pentru valorile care pot fi NULL:
var ns sql.NullString
// ... după citirea din baza de date ...
if ns.Valid {
fmt.Println("Value:", ns.String)
} else {
fmt.Println("Value is NULL")
}
Când tipurile Null* din database/sql nu ne ajung, ne definim propriile tipuri. Ca ele să poată fi folosite cu baza de date, trebuie să implementeze interfețele driver.Valuer (din database/sql/driver) și sql.Scanner (din database/sql).
driver.Valuer
Metoda Value transformă tipul Go într-o formă pe care o poate stoca baza de date.
package driver
// Valuer is the interface providing the Value method.
type Valuer interface {
// Value returns a driver Value.
Value() (Value, error)
}
sql.Scanner
Metoda Scan face operația inversă: preia o valoare din baza de date și o pune în tipul Go, cu conversiile necesare.
package sql
// Scanner is the interface that wraps the Scan method.
type Scanner interface {
// Scan reads a value from a database driver-specific source interface
// and assigns it to the receiver, converting it if necessary.
Scan(src interface{}) error
}
Tipurile proprii, fie că reprezintă valori NULL, fie structuri mai complexe, ar trebui să implementeze aceste metode. Așa sunt compatibile cu database/sql și pot fi scrise și citite direct din baza de date.
Notă de traducere: în codul de mai jos, Value are value receiver și returnează driver.Value, iar Scan are pointer receiver. În versiunea originală, Value returna interface{} și avea pointer receiver. Cu interface{} în loc de driver.Value, semnătura nu se potrivește, iar tipul nu implementează de fapt driver.Valuer. Convenția din biblioteca standard (vezi sql.NullString) e cea folosită aici.
Să zicem că avem un tip Money pentru sume de bani:
type Money struct {
Amount float64
Currency string
}
Ca să reprezentăm o valoare Money care poate fi NULL, introducem tipul NullMoney:
type NullMoney struct {
Money Money
Valid bool
}
Ca să lucreze cu baza de date, tipul trebuie să implementeze driver.Valuer și sql.Scanner cu logica potrivită. Uite cum poate arăta:
package main
import (
"database/sql/driver"
"encoding/json"
"fmt"
)
// Money reprezintă o sumă de bani.
type Money struct {
Amount float64
Currency string
}
// NullMoney reprezintă un Money care poate fi NULL.
type NullMoney struct {
Money Money
Valid bool
}
// Value implementează driver.Valuer:
// transformă tipul nostru într-o valoare pe care o poate stoca baza de date.
func (nm NullMoney) Value() (driver.Value, error) {
if !nm.Valid {
return nil, nil
}
encoded, err := json.Marshal(nm.Money)
if err != nil {
return nil, err
}
return string(encoded), nil
}
// Scan implementează sql.Scanner:
// transformă valoarea din baza de date înapoi în tipul nostru.
func (nm *NullMoney) Scan(value interface{}) error {
if value == nil {
nm.Money, nm.Valid = Money{}, false
return nil
}
nm.Valid = true
strValue, ok := value.(string)
if !ok {
return fmt.Errorf("expected a string, got %T", value)
}
return json.Unmarshal([]byte(strValue), &nm.Money)
}
func main() {
// Simulăm salvarea în baza de date
amount := Money{Amount: 100.50, Currency: "USD"}
nm := NullMoney{Money: amount, Valid: true}
// Asta e valoarea pe care am stoca-o în baza de date
storedValue, _ := nm.Value()
fmt.Printf("Stored Money: %v\n", storedValue)
// Simulăm citirea din baza de date
var fetchedMoney NullMoney
_ = fetchedMoney.Scan(storedValue)
if fetchedMoney.Valid {
fmt.Printf("The amount is %f %s\n", fetchedMoney.Money.Amount, fetchedMoney.Money.Currency)
} else {
fmt.Println("The amount is not set.")
}
// OUTPUT:
// Stored Money: {"Amount":100.5,"Currency":"USD"}
// The amount is 100.500000 USD
}
Exemplul folosește float64 ca să rămână simplu. Pentru bani reali, vezi secțiunea „Actualizare 2026” de la final.
Construim o structură NullableTime:
package main
import (
"database/sql/driver"
"fmt"
"time"
)
// NullableTime reprezintă un time.Time care poate fi NULL.
type NullableTime struct {
Time time.Time // valoarea propriu-zisă
Valid bool // true dacă Time nu e NULL
}
// Value implementează driver.Valuer.
func (nt NullableTime) Value() (driver.Value, error) {
if !nt.Valid {
// Dacă data nu e validă, returnăm nil, adică NULL în baza de date.
return nil, nil
}
return nt.Time.Format(time.RFC3339), nil
}
// Scan implementează sql.Scanner.
func (nt *NullableTime) Scan(value interface{}) error {
if value == nil {
nt.Time, nt.Valid = time.Time{}, false
return nil
}
nt.Valid = true
// Transformăm valoarea din baza de date (un string, în simularea noastră) în time.Time.
var err error
nt.Time, err = time.Parse(time.RFC3339, value.(string))
return err
}
func main() {
// Simulăm salvarea în baza de date
currentTime := time.Now()
nt := NullableTime{Time: currentTime, Valid: true}
// Asta e valoarea pe care am stoca-o în baza de date
storedValue, _ := nt.Value()
fmt.Printf("Stored Time: %v\n", storedValue)
// Simulăm citirea din baza de date
var fetchedTime NullableTime
_ = fetchedTime.Scan(storedValue)
if fetchedTime.Valid {
fmt.Printf("The event starts at %v\n", fetchedTime.Time)
} else {
fmt.Println("The event start time is not set.")
}
// Output (rulat în Go Playground, unde ceasul e fixat la 2009-11-10):
// Stored Time: 2009-11-10T23:00:00Z
// The event starts at 2009-11-10 23:00:00 +0000 UTC
}
Acum NullMoney și NullableTime merg cu database/sql și acceptă valori Money și time.Time care pot fi NULL.
Cum se vede din cele două exemple, poți defini un comportament propriu pentru orice tip Go care ajunge în baza de date prin database/sql. Nu doar pentru NULL: poți avea tipuri care salvează datele ca JSON, tipuri care criptează, tipuri pentru monede sau orice altă logică ai nevoie.
Să zicem că ai un tip User cu un câmp sensibil. Când îl salvezi în baza de date, vrei să fie criptat, iar când îl citești, decriptat. Exemplul de mai jos folosește AES, cu o cheie fixă ca să fie simplu; într-un proiect real cheile trebuie gestionate în siguranță.
Atenție: exemplul original folosea o parolă ca date sensibile. Parolele nu se criptează, se hash-uiesc cu un algoritm făcut pentru asta, ca bcrypt sau Argon2, astfel încât să nu poată fi recuperate nici de tine. Criptarea reversibilă de mai jos are sens pentru date pe care trebuie să le poți citi din nou, de exemplu un IBAN sau un număr de telefon.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"database/sql/driver"
"encoding/base64"
"fmt"
"io"
)
type EncryptedString struct {
Data string
}
func (es EncryptedString) Value() (driver.Value, error) {
return encrypt(es.Data)
}
func (es *EncryptedString) Scan(src interface{}) error {
decrypted, err := decrypt(src.(string))
if err != nil {
return err
}
es.Data = decrypted
return nil
}
func encrypt(data string) (string, error) {
key := []byte("examplekey123456") // cheia secretă, de 16, 24 sau 32 de octeți
plaintext := []byte(data)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
ciphertext := make([]byte, aes.BlockSize+len(plaintext))
iv := ciphertext[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext)
return base64.URLEncoding.EncodeToString(ciphertext), nil
}
func decrypt(data string) (string, error) {
key := []byte("examplekey123456")
ciphertext, err := base64.URLEncoding.DecodeString(data)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
if len(ciphertext) < aes.BlockSize {
return "", fmt.Errorf("ciphertext too short")
}
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext)
return string(ciphertext), nil
}
func main() {
// Simulăm salvarea în baza de date
iban := "RO49AAAA1B31007593840000"
es := EncryptedString{Data: iban}
// Asta e valoarea pe care am stoca-o în baza de date
encryptedValue, _ := es.Value()
fmt.Printf("Encrypted: %s\n", encryptedValue)
// Simulăm citirea din baza de date
var fetchedValue EncryptedString
_ = fetchedValue.Scan(encryptedValue)
if fetchedValue.Data == iban {
fmt.Println("Decrypted successfully!")
fmt.Printf("Decrypted: %s\n", fetchedValue.Data)
} else {
fmt.Println("Decryption failed!")
}
}
Modul CFB folosit aici nu verifică integritatea datelor și e marcat ca depreciat în biblioteca standard. Varianta recomandată, cu AES-GCM, e în secțiunea de actualizare.
Chiar dacă database/sql gestionează singur pool-ul de conexiuni, trebuie să închizi rândurile (rows.Close()) și statement-urile rămase deschise, ca resursele să se întoarcă în pool.
La început, Go pare departe de felul în care bazele de date tratează NULL, dar database/sql face legătura. Cu driver.Valuer și sql.Scanner în mână, aplicațiile Go lucrează cu bazele de date fără surprize.
Note adăugate la traducere; nu există în articolul original din 2023. Actualizate pentru Go 1.27, versiunea curentă la data publicării.
Pentru NULL nu mai ai nevoie de tipuri proprii. sql.NullTime există din Go 1.13, deci NullableTime din exemplul 2 nu mai e necesar. Din Go 1.22 există și varianta generică sql.Null[T], care merge pentru orice tip:
var price sql.Null[int64]
err := db.QueryRow("SELECT price FROM products WHERE id = ?", id).Scan(&price)
if err != nil {
return err
}
if price.Valid {
fmt.Println(price.V)
}
Tipurile proprii rămân utile pentru ce face exemplul 1: salvarea unei structuri ca JSON sau orice altă conversie specială.
Banii nu se țin în float64. 0.1 + 0.2 nu dă exact 0.3 în virgulă mobilă, iar la sume adunate de mii de ori erorile se văd. Variantele sigure: un int64 cu suma în subunități (bani, cenți) sau un tip zecimal dedicat. Pentru exemplul 1, schimbarea minimă e Amount int64 // în bani.
Scan poate primi []byte, nu doar string. Depinde de driver și de tipul coloanei. Multe drivere returnează []byte pentru text și JSON, iar value.(string) din exemple ar da eroare sau, fără verificarea ok, panic. Tratează ambele cazuri:
var b []byte
switch v := value.(type) {
case string:
b = []byte(v)
case []byte:
b = v
default:
return fmt.Errorf("expected string or []byte, got %T", value)
}
Criptare autentificată cu AES-GCM. CFB doar ascunde datele: dacă cineva le modifică în baza de date, decriptarea întoarce date corupte fără nicio eroare. AES-GCM detectează modificarea și returnează eroare. Documentația e în crypto/cipher:
func encryptGCM(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func decryptGCM(key, data []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(data) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
return gcm.Open(nil, nonce, ciphertext, nil)
}
Cheia nu stă în cod: vine dintr-o variabilă de mediu sau dintr-un manager de secrete.
Ghidul oficial pentru lucrul cu baze de date în Go e pe go.dev/doc/database.
La DO IT MAGIC SOFTWARE scriem în Go sisteme care rulează în producție, de la platforme web la soluții AI locale. Dacă ai un proiect Go care are nevoie de o a doua opinie, scrie-ne.