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
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
// The [sl] package has two main concepts, the [ServiceLocator] itself is the
|
||||
// main object that one should pass around through the application. A
|
||||
// [ServiceLocator] has a list of slots that can be filled with
|
||||
// [InjectLazy] and [InjectValue] and retrieved with [Use]. Slots should
|
||||
// be unique by type and they can only be created with the [NewSlot] function.
|
||||
//
|
||||
// The usual way to use this module is to make slots for Go interfaces and
|
||||
// then pass implementations using the [InjectValue] and
|
||||
// [InjectLazy] functions.
|
||||
//
|
||||
// Services can be of various types:
|
||||
// - a service with no dependencies can be directly injected inside a
|
||||
// ServiceLocator using [InjectValue].
|
||||
// - a service with dependencies on other service should use
|
||||
// [InjectLazy]. This lets the service to initialize itself when needed
|
||||
// and makes the developer not think about correctly ordering the
|
||||
// initialization of its dependencies
|
||||
// - a service can also be private, in this case the slot for a service
|
||||
// should be a private field in the service package. This kind of
|
||||
// services should also provide a way to inject them into a
|
||||
// ServiceLocator.
|
||||
// - a package can also just provide a slot with some value. This is useful
|
||||
// for using the ServiceLocator to easily pass around values, effectively
|
||||
// threating slots just as dynamically scoped variables.
|
||||
package sl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Logger is the debug logger
|
||||
//
|
||||
// TODO: in the future this will be disabled and discard by default.
|
||||
//
|
||||
// As this is the service locator module it was meaning less to pass this
|
||||
// through the ServiceLocator itself (without making the whole module more
|
||||
// complex)
|
||||
var Logger *log.Logger = log.New(os.Stderr, "[service locator] ", log.Lmsgprefix)
|
||||
|
||||
// slot is just a "typed" unique "symbol".
|
||||
type slot[T any] *struct{}
|
||||
|
||||
// NewSlot is the only way to create instances of the slot type. Each instance
|
||||
// is unique.
|
||||
//
|
||||
// This then lets you attach a service instance of type "T" to a
|
||||
// [ServiceLocator] object.
|
||||
func NewSlot[T any]() slot[T] {
|
||||
return slot[T](new(struct{}))
|
||||
}
|
||||
|
||||
// slotEntry represents a service that can lazily initialized
|
||||
// (using "createFunc"). Once initialized the instance is kept in the "value"
|
||||
// field and "created" will always be "true". The field "typeName" just for
|
||||
// debugging purposes.
|
||||
type slotEntry struct {
|
||||
typeName string
|
||||
|
||||
createFunc func(*ServiceLocator) (any, error)
|
||||
created bool
|
||||
|
||||
value any
|
||||
}
|
||||
|
||||
func (s *slotEntry) checkInitialized(l *ServiceLocator) error {
|
||||
if !s.created {
|
||||
v, err := s.createFunc(l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Logger.Printf(`[slot: %s] initialized value of type %T`, s.typeName, v)
|
||||
|
||||
s.created = true
|
||||
s.value = v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServiceLocator is the main context passed around to retrive service
|
||||
// instances, the interface uses generics so to inject and retrive service
|
||||
// instances you should use the functions [InjectValue], [InjectLazy] and
|
||||
// [Use].
|
||||
type ServiceLocator struct {
|
||||
providers map[any]*slotEntry
|
||||
}
|
||||
|
||||
// New creates a new [ServiceLocator] context to pass around in the application.
|
||||
func New() *ServiceLocator {
|
||||
return &ServiceLocator{
|
||||
providers: map[any]*slotEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// InjectValue will inject a concrete instance inside the ServiceLocator "l"
|
||||
// for the given "slotKey". This should be used for injecting "static"
|
||||
// services, for instances whose construction depend on other services you
|
||||
// should use the [InjectLazy] function.
|
||||
//
|
||||
// This is generic over "T" to check that instances for the given slot type
|
||||
// check as "T" can also be an interface.
|
||||
func InjectValue[T any](l *ServiceLocator, slotKey slot[T], value T) T {
|
||||
Logger.Printf(`[slot: %s] injected value of type %T`, getTypeName[T](), value)
|
||||
|
||||
l.providers[slotKey] = &slotEntry{
|
||||
getTypeName[T](),
|
||||
nil,
|
||||
true,
|
||||
value,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// InjectLazy will inject an instance inside the given ServiceLocator
|
||||
// and "slotKey" that is created only when requested with a call to the
|
||||
// [Use] function.
|
||||
//
|
||||
// This is generic over "T" to check that instances for the given slot type
|
||||
// check as "T" can also be an interface.
|
||||
func InjectLazy[T any](l *ServiceLocator, slotKey slot[T], createFunc func(*ServiceLocator) (T, error)) {
|
||||
Logger.Printf(`[slot: %s] injected lazy`, getTypeName[T]())
|
||||
|
||||
l.providers[slotKey] = &slotEntry{
|
||||
createFunc: func(l *ServiceLocator) (any, error) {
|
||||
return createFunc(l)
|
||||
},
|
||||
created: false,
|
||||
value: nil,
|
||||
typeName: getTypeName[T](),
|
||||
}
|
||||
}
|
||||
|
||||
// Use retrieves the value of type T associated with the given slot key from
|
||||
// the provided ServiceLocator instance.
|
||||
//
|
||||
// If the ServiceLocator does not have a value for the slot key, or if the
|
||||
// value wasn't correctly initialized (in the case of a lazy slot), an error
|
||||
// is returned.
|
||||
func Use[T any](l *ServiceLocator, slotKey slot[T]) (T, error) {
|
||||
var zero T
|
||||
|
||||
slot, ok := l.providers[slotKey]
|
||||
if !ok {
|
||||
return zero, fmt.Errorf(`no injected value for type %s`, getTypeName[T]())
|
||||
}
|
||||
|
||||
err := slot.checkInitialized(l)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
v := slot.value.(T)
|
||||
|
||||
Logger.Printf(`[slot: %s] using slot with value of type %T`, getTypeName[T](), v)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// getTypeName is a trick to get the name of a type (even if it is an
|
||||
// interface type)
|
||||
func getTypeName[T any]() string {
|
||||
var zero T
|
||||
return fmt.Sprintf(`%T`, &zero)[1:]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// CompactIndentedLines removes all indentation from a string and also removes all newlines, usefull in tests
|
||||
func CompactIndentedLines(s string) string {
|
||||
return regexp.MustCompile(`(?m)\n\s+`).ReplaceAllString(s, "")
|
||||
}
|
||||
Reference in New Issue
Block a user