Initial commit
This commit is contained in:
+224
@@ -0,0 +1,224 @@
|
||||
// This package provides a small framework to work with SQL databases using
|
||||
// generics. The most important part for now is the "Ref[T]" type that lets
|
||||
// us pass around typed IDs to table rows in a type safe manner.
|
||||
//
|
||||
// For now this library uses both generics for keeping the interface type safe
|
||||
// and reflection to extract automatically some metedata from structs that
|
||||
// represent tables.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Option è un valore che nel database può potenzialmente essere "NULL"
|
||||
//
|
||||
// NOTE/TODO: Magari si può fare una cosa fatta bene e nascondere
|
||||
// l'implementazione al Go, ad esempio Option[string] può essere NULL o testo
|
||||
// nel db mentre Option[time.Time] può essere codificato in json come
|
||||
//
|
||||
// "{ present: false }"
|
||||
//
|
||||
// oppure
|
||||
//
|
||||
// "{ present: true, value: '2023/04/...' }"
|
||||
//
|
||||
// in modo da semplificare leggermente la processazione in Go. Altrimenti si
|
||||
// può separare in Option[T] e Nullable[T].
|
||||
type Option[T any] struct {
|
||||
present bool
|
||||
value T
|
||||
}
|
||||
|
||||
func NewOption[T any](value T) Option[T] {
|
||||
return Option[T]{true, value}
|
||||
}
|
||||
|
||||
func NewEmptyOption[T any]() Option[T] {
|
||||
return Option[T]{present: false}
|
||||
}
|
||||
|
||||
func (Option[T]) SqlType() string {
|
||||
return "BLOB"
|
||||
}
|
||||
|
||||
// func (v *Option[T]) Scan(a any) error {
|
||||
// data, ok := a.([]byte)
|
||||
// if !ok {
|
||||
// return fmt.Errorf(`scan expected []byte`)
|
||||
// }
|
||||
|
||||
// m := map[string]any{}
|
||||
// if err := json.Unmarshal(data, m); err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// if present, _ := m["present"].(bool); present {
|
||||
|
||||
// m["value"]
|
||||
// }
|
||||
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// Ref è un id tipato da un'entità presente nel database. È il tipo fondamentale
|
||||
// esportato da questo package. Non dovrebbe essere troppo importante ma
|
||||
// internamente è una stringa, inoltre è consigliato che una struct
|
||||
// con _primary key_ abbia un campo di tipo Ref a se stessa.
|
||||
//
|
||||
// type User struct {
|
||||
// Id db.Ref[User] // meglio se unico e immutabile
|
||||
// ...
|
||||
// }
|
||||
type Ref[T any] string
|
||||
|
||||
func (Ref[T]) SqlType() string {
|
||||
return "TEXT"
|
||||
}
|
||||
|
||||
func (v *Ref[T]) Scan(a any) error {
|
||||
s, ok := a.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf(`scan expected string`)
|
||||
}
|
||||
|
||||
*v = Ref[T](s)
|
||||
return nil
|
||||
}
|
||||
|
||||
type sqlValue interface {
|
||||
SqlType() string
|
||||
sql.Scanner
|
||||
}
|
||||
|
||||
type Table[T any] struct {
|
||||
Name string
|
||||
PrimaryKey func(value *T, pk ...string) Ref[T]
|
||||
Columns [][2]string
|
||||
}
|
||||
|
||||
var commonTypes = map[string]string{
|
||||
"string": "TEXT",
|
||||
"int": "INTEGER",
|
||||
"int32": "INTEGER",
|
||||
"int64": "INTEGER",
|
||||
"float32": "REAL",
|
||||
"float64": "REAL",
|
||||
}
|
||||
|
||||
func toSqlType(typ reflect.Type) string {
|
||||
st, ok := reflect.New(typ).Interface().(sqlValue)
|
||||
if ok {
|
||||
return st.SqlType()
|
||||
}
|
||||
|
||||
return commonTypes[typ.Name()]
|
||||
}
|
||||
|
||||
func AutoTable[T any](name string) Table[T] {
|
||||
columns := [][2]string{}
|
||||
pkIndex := -1
|
||||
|
||||
var zero T
|
||||
typ := reflect.TypeOf(zero)
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
fieldTyp := typ.Field(i)
|
||||
isPK, _, typ, col := fieldInfo(fieldTyp)
|
||||
if isPK {
|
||||
pkIndex = i
|
||||
}
|
||||
columns = append(columns, [2]string{col, toSqlType(typ)})
|
||||
}
|
||||
|
||||
if pkIndex == -1 {
|
||||
panic(fmt.Sprintf("struct %T has no primary key field", zero))
|
||||
}
|
||||
|
||||
// debug logging
|
||||
// log.Printf("[auto table] struct %T has primary key %q", zero, typ.Field(pkIndex).Name)
|
||||
|
||||
return Table[T]{
|
||||
Name: name,
|
||||
PrimaryKey: func(value *T, pk ...string) Ref[T] {
|
||||
// SAFETY: this is required to cast *Ref[T] to *string, internally
|
||||
// they are the same type so this should be safe
|
||||
ptr := reflect.ValueOf(value).Elem().Field(pkIndex).Addr().UnsafePointer()
|
||||
pkPtr := (*string)(ptr)
|
||||
|
||||
if len(pk) > 0 {
|
||||
*pkPtr = pk[0]
|
||||
}
|
||||
|
||||
return Ref[T](*pkPtr)
|
||||
},
|
||||
Columns: columns,
|
||||
}
|
||||
}
|
||||
|
||||
func (table Table[T]) CreateIfNotExists(dbConn *sql.DB) error {
|
||||
columns := make([]string, len(table.Columns))
|
||||
|
||||
for i, col := range table.Columns {
|
||||
columns[i] = fmt.Sprintf("%s %s", col[0], col[1])
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s(%s)`,
|
||||
table.Name,
|
||||
strings.Join(columns, ", "),
|
||||
)
|
||||
|
||||
_, err := dbConn.Exec(stmt)
|
||||
return err
|
||||
}
|
||||
|
||||
func Create[T any](dbConn *sql.DB, table Table[T], value T) (Ref[T], error) {
|
||||
id := randomHex(15)
|
||||
|
||||
table.PrimaryKey(&value, id)
|
||||
|
||||
columns, values := structDatabaseColumnValues(&value)
|
||||
|
||||
stmt := fmt.Sprintf(`INSERT INTO %s(%s) VALUES (%s)`,
|
||||
table.Name,
|
||||
strings.Join(columns, ", "),
|
||||
strings.Join(repeatSlice("?", len(columns)), ", "),
|
||||
)
|
||||
|
||||
if _, err := dbConn.Exec(stmt, values...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return Ref[T](id), nil
|
||||
}
|
||||
|
||||
func Insert[T any](dbConn *sql.DB, table Table[T], value T) error {
|
||||
columns, values := structDatabaseColumnValues(&value)
|
||||
|
||||
stmt := fmt.Sprintf(`INSERT INTO %s(%s) VALUES (%s)`,
|
||||
table.Name,
|
||||
strings.Join(columns, ", "),
|
||||
strings.Join(repeatSlice("?", len(columns)), ", "),
|
||||
)
|
||||
|
||||
_, err := dbConn.Exec(stmt, values...)
|
||||
return err
|
||||
}
|
||||
|
||||
func Read[T any](dbConn *sql.DB, table Table[T], id Ref[T]) (T, error) {
|
||||
panic("todo")
|
||||
}
|
||||
|
||||
func ReadAll[T any](dbConn *sql.DB, table Table[T]) ([]T, error) {
|
||||
panic("todo")
|
||||
}
|
||||
|
||||
func Update[T any](dbConn *sql.DB, table Table[T], id Ref[T], value T) error {
|
||||
panic("todo")
|
||||
}
|
||||
|
||||
func Delete[T any](dbConn *sql.DB, table Table[T], id Ref[T]) error {
|
||||
panic("todo")
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"testing"
|
||||
|
||||
"git.phc.dm.unipi.it/phc/website/libs/db"
|
||||
"gotest.tools/assert"
|
||||
)
|
||||
|
||||
type mockDB struct {
|
||||
*testing.T
|
||||
|
||||
LastQuery string
|
||||
}
|
||||
|
||||
func (db *mockDB) Prepare(query string) (driver.Stmt, error) { return nil, nil }
|
||||
func (db *mockDB) Close() error { return nil }
|
||||
func (db *mockDB) Begin() (driver.Tx, error) { return nil, nil }
|
||||
|
||||
func (db *mockDB) Query(query string, args []driver.Value) (driver.Rows, error) {
|
||||
db.LastQuery = query
|
||||
db.Logf("[sql mock driver] [exec] query: '%s', args: %#v", query, args)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (db *mockDB) Exec(query string, args []driver.Value) (driver.Result, error) {
|
||||
db.LastQuery = query
|
||||
db.Logf("[sql mock driver] [exec] query: '%s', args: %#v", query, args)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (db *mockDB) Open(name string) (driver.Conn, error) {
|
||||
return &mockDB{T: db.T}, nil
|
||||
}
|
||||
|
||||
func (db *mockDB) Connect(context.Context) (driver.Conn, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *mockDB) Driver() driver.Driver {
|
||||
return db
|
||||
}
|
||||
|
||||
//
|
||||
// Tests
|
||||
//
|
||||
|
||||
func TestDb1(t *testing.T) {
|
||||
type Product struct {
|
||||
Id db.Ref[Product] `db:"id*"`
|
||||
Title string `db:"title"`
|
||||
Quantity int `db:"quantity"`
|
||||
}
|
||||
|
||||
products := db.AutoTable[Product]("products")
|
||||
|
||||
mock := &mockDB{T: t}
|
||||
conn := sql.OpenDB(mock)
|
||||
|
||||
err := products.CreateIfNotExists(conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, mock.LastQuery,
|
||||
`CREATE TABLE IF NOT EXISTS products(id TEXT, title TEXT, quantity INTEGER)`,
|
||||
)
|
||||
|
||||
ref1, err := db.Create(conn, products, Product{Title: "Foo", Quantity: 27})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Assert(t, string(ref1) != "")
|
||||
assert.Equal(t, mock.LastQuery,
|
||||
`INSERT INTO products(id, title, quantity) VALUES (?, ?, ?)`,
|
||||
)
|
||||
|
||||
if err := db.Insert(conn, products, Product{Id: "123", Title: "Bar", Quantity: 42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, mock.LastQuery,
|
||||
`INSERT INTO products(id, title, quantity) VALUES (?, ?, ?)`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package db
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func randomHex(len int) string {
|
||||
buff := make([]byte, len/2+1)
|
||||
rand.Read(buff)
|
||||
str := hex.EncodeToString(buff)
|
||||
return str[:len]
|
||||
}
|
||||
|
||||
func fieldInfo(fieldTyp reflect.StructField) (bool, string, reflect.Type, string) {
|
||||
fieldName := fieldTyp.Name
|
||||
columnName, ok := fieldTyp.Tag.Lookup("db")
|
||||
if !ok {
|
||||
columnName = strings.ToLower(fieldName)
|
||||
}
|
||||
if ok && columnName[len(columnName)-1] == '*' {
|
||||
return true, fieldName, fieldTyp.Type, columnName[:len(columnName)-1]
|
||||
}
|
||||
|
||||
return false, fieldName, fieldTyp.Type, columnName
|
||||
}
|
||||
|
||||
func fieldPrimaryKey(fieldTyp reflect.StructField) bool {
|
||||
isPK, _, _, _ := fieldInfo(fieldTyp)
|
||||
return isPK
|
||||
}
|
||||
|
||||
// structDatabaseColumnValues takes a pointer to a struct and returns a pair
|
||||
// of slices, one with the column names and the other with pointers to all
|
||||
// its exported fields.
|
||||
//
|
||||
// The column name can be changed using the "db" tag attribute on that field
|
||||
func structDatabaseColumnValues(s any) ([]string, []any) {
|
||||
v := reflect.ValueOf(s).Elem()
|
||||
|
||||
numFields := v.NumField()
|
||||
|
||||
names := []string{}
|
||||
values := []any{}
|
||||
|
||||
for i := 0; i < numFields; i++ {
|
||||
fieldTyp := v.Type().Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
|
||||
if fieldTyp.IsExported() {
|
||||
key := strings.ToLower(fieldTyp.Name)
|
||||
|
||||
if name, ok := fieldTyp.Tag.Lookup("db"); ok {
|
||||
key = name
|
||||
|
||||
// primary key has a "*" at the end, exclude from column name
|
||||
if key[len(key)-1] == '*' {
|
||||
key = key[:len(key)-1]
|
||||
}
|
||||
}
|
||||
|
||||
names = append(names, key)
|
||||
values = append(values, fieldVal.Addr().Interface())
|
||||
}
|
||||
}
|
||||
|
||||
return names, values
|
||||
}
|
||||
|
||||
func structPrimaryKeyPtr(s any) (*string, bool) {
|
||||
v := reflect.ValueOf(s).Elem()
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
fieldTyp := v.Type().Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
|
||||
if fieldTyp.IsExported() {
|
||||
if fieldPrimaryKey(fieldTyp) {
|
||||
// SAFETY: this is required to cast *Ref[T] to *string, internally
|
||||
// they are the same type so this should be safe
|
||||
ptr := fieldVal.Addr().UnsafePointer()
|
||||
return (*string)(ptr), true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// structDatabasePrimaryKeyColumn takes a pointer to a struct and returns the
|
||||
// name of the field with the primary key
|
||||
func structDatabasePrimaryKeyColumn(s any) (field, column string, ok bool) {
|
||||
v := reflect.ValueOf(s).Elem()
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
fieldTyp := v.Type().Field(i)
|
||||
if fieldTyp.IsExported() {
|
||||
key := fieldTyp.Name
|
||||
if value, ok := fieldTyp.Tag.Lookup("db"); ok {
|
||||
if value[len(value)-1] == '*' {
|
||||
return key, value[:len(value)-1], true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func repeatSlice[T any](value T, count int) []T {
|
||||
s := make([]T, count)
|
||||
for i := 0; i < count; i++ {
|
||||
s[i] = value
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user