Initial commit

This commit is contained in:
2023-05-14 19:44:16 +02:00
commit 15bc406494
29 changed files with 4346 additions and 0 deletions
+224
View File
@@ -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")
}
+88
View File
@@ -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 (?, ?, ?)`,
)
}
+1
View File
@@ -0,0 +1 @@
package db
+118
View File
@@ -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
View File
@@ -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:]
}
+10
View File
@@ -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, "")
}