first commit

This commit is contained in:
2026-08-08 20:36:24 +02:00
commit e3ca632883
8 changed files with 1267 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
package orm
import (
"fmt"
"strings"
)
func buildInsertSql(tablename string, fields []dbfield, dialect Dialect) string {
cols := make([]string, len(fields))
placeholders := make([]string, len(fields))
for i, f := range fields {
cols[i] = f.dbname
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
return fmt.Sprintf(
"insert into %s (%s) values (%s)",
tablename,
strings.Join(cols, ","),
strings.Join(placeholders, ","),
)
}
func buildUpdateSql(tablename string, fields []dbfield, dialect Dialect) string {
predicates := make([]string, len(fields)-1)
var where string
i := 0
for _, f := range fields {
if f.dbname == "id" {
where = fmt.Sprintf("id=$%d and _version=$%d", len(fields), len(fields)+1)
} else {
predicates[i] = fmt.Sprintf("%s=$%d", f.dbname, i+1)
i++
}
}
return fmt.Sprintf(
"update %s set %s where %s",
tablename,
strings.Join(predicates, ","),
where,
)
}
func buildSelectbyidSql(tablename string, fields []dbfield, dialect Dialect) string {
columns := make([]string, len(fields))
var where string
for i, f := range fields {
columns[i] = fmt.Sprintf("%s", f.dbname)
}
where = "id=$1"
return fmt.Sprintf(
"select %s from %s where %s",
strings.Join(columns, ","),
tablename,
where,
)
}
func buildDeletebyidSql(tablename string, fields []dbfield, dialect Dialect) string {
where := "id=$1"
return fmt.Sprintf(
"delete from %s where %s",
tablename,
where,
)
}
func buildSelectWhere(tablename string, fields []dbfield, dialect Dialect) string {
columns := make([]string, len(fields))
for i, f := range fields {
columns[i] = fmt.Sprintf("%s", f.dbname)
}
return fmt.Sprintf(
"select %s from %s",
strings.Join(columns, ","),
tablename,
)
}
func buildDeleteWhere(tablename string, fields []dbfield, dialect Dialect) string {
return fmt.Sprintf(
"delete from %s",
tablename,
)
}
+61
View File
@@ -0,0 +1,61 @@
package orm
import (
"database/sql"
"fmt"
"strings"
)
type SqliteDialect struct{}
func NewSqliteDialect() *SqliteDialect {
return &SqliteDialect{}
}
func convertType(f dbfield) string {
switch f.stype {
case "string", "*string":
return "text"
case "bool", "int", "*int", "time.Time", "*time.Time":
return "numeric"
case "[]byte":
return "blob"
}
return "text"
}
func defaultValue(f dbfield) string {
if f.dbname == "_date_created" {
return "current_timestamp"
}
switch f.stype {
case "string":
return "''"
case "bool", "int", "time.Time":
return "0"
}
return "''"
}
func (d *SqliteDialect) TableExists(db *sql.DB, tableName string) (bool, error) {
sql := "select name from sqlite_schema where type='table' and name=$1"
rows, err := db.Query(sql, tableName)
if err != nil {
return false, err
}
if rows.Next() {
rows.Close()
return true, nil
}
return false, nil
}
func (d *SqliteDialect) ColumnSpec(f dbfield) (string, error) {
if f.dbname == "id" {
return "id text not null primary key", nil
}
if strings.HasPrefix(f.stype, "*") || strings.HasPrefix(f.stype, "[]") {
return fmt.Sprintf("%s %s", f.dbname, convertType(f)), nil
}
return fmt.Sprintf("%s %s not null default %s", f.dbname, convertType(f), defaultValue(f)), nil
}
+23
View File
@@ -0,0 +1,23 @@
module trankilou.fr/lasebuche
go 1.26.5
require (
github.com/sixafter/nanoid v1.64.5
modernc.org/sqlite v1.56.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sixafter/aes-ctr-drbg v1.19.2 // indirect
github.com/sixafter/prng-chacha v1.16.5 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+68
View File
@@ -0,0 +1,68 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sixafter/aes-ctr-drbg v1.19.2 h1:+Bd1tS9/y/GapqgyyRjmrce6a5wUAQPusJXlAOh44yM=
github.com/sixafter/aes-ctr-drbg v1.19.2/go.mod h1:iOBiPPkiy5Z5cEWm2yCoqEtnkfafxNTokJLL0zOPZeQ=
github.com/sixafter/nanoid v1.64.5 h1:Aei4SU9i9I35EyJQawo9XYpzYoARGobQRU8EnBf/Pjo=
github.com/sixafter/nanoid v1.64.5/go.mod h1:+9tqjRHutec4D9mCKGvTaGSAxj1Q+eEHOvfp2WQgWa4=
github.com/sixafter/prng-chacha v1.16.5 h1:RNPMMVDq6rzBS+wDc9BAkpcKoVRhFGfBIwGRUCo5+Cc=
github.com/sixafter/prng-chacha v1.16.5/go.mod h1:fY7WtbBwx94oa5qN417ZzCLCozjQ7JDag7dr1f/v2Ss=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+551
View File
@@ -0,0 +1,551 @@
package orm
import (
"database/sql"
"fmt"
"reflect"
"strings"
"time"
)
type dbfield struct {
name string
stype string
dbname string
}
type Dialect interface {
TableExists(db *sql.DB, tableName string) (bool, error)
ColumnSpec(f dbfield) (string, error)
}
type Table[T any] struct {
db *sql.DB
dialect Dialect
tablename string
fields []dbfield
insertsql string
updatesql string
selectbyid string
deletebyid string
selectwhere string
deletewhere string
}
func NewTable[T any](db *sql.DB, dialect Dialect, sample T, tablename string) (Table[T], error) {
t := reflect.TypeOf(sample)
fields := make([]dbfield, 0)
for field := range t.Fields() {
f := dbfield{
name: field.Name,
stype: field.Type.String(),
dbname: field.Tag.Get("db"),
}
fmt.Println(f.name, f.stype, f.dbname)
fields = append(fields, f)
}
return Table[T]{
db: db,
dialect: dialect,
tablename: tablename,
fields: fields,
insertsql: buildInsertSql(tablename, fields, dialect),
updatesql: buildUpdateSql(tablename, fields, dialect),
selectbyid: buildSelectbyidSql(tablename, fields, dialect),
deletebyid: buildDeletebyidSql(tablename, fields, dialect),
selectwhere: buildSelectWhere(tablename, fields, dialect),
deletewhere: buildDeleteWhere(tablename, fields, dialect),
}, nil
}
func (t Table[T]) Sync() error {
if exists, err := t.dialect.TableExists(t.db, t.tablename); exists {
return nil
} else if err != nil {
return err
}
columns := make([]string, len(t.fields))
for i, f := range t.fields {
c, err := t.dialect.ColumnSpec(f)
if err != nil {
return err
}
columns[i] = c
}
sql := fmt.Sprintf("create table %s (%s)", t.tablename, strings.Join(columns, ","))
_, err := t.db.Exec(sql)
if err != nil {
return err
}
return nil
}
func (t Table[T]) Get(id string) (*T, error) {
row := t.db.QueryRow(t.selectbyid, id)
if row == nil {
return nil, sql.ErrNoRows
}
var obj T
// Use a slice of pointers to struct fields for scanning
// We need to pass pointers to each field in the order of the SQL columns
ptrVal := reflect.ValueOf(&obj).Elem()
fields := make([]any, len(t.fields))
for i, f := range t.fields {
fieldValue := ptrVal.FieldByName(f.name)
if !fieldValue.IsValid() {
return nil, fmt.Errorf("field %s not found in struct", f.name)
}
// For all fields, pass the address so Scan can set the value
// For pointer fields (e.g., *string), this gives **T
// For value fields (e.g., string), this gives *T
fields[i] = fieldValue.Addr().Interface()
}
if err := row.Scan(fields...); err != nil {
// Handle time scanning manually for SQLite which returns dates as strings
if strings.Contains(err.Error(), "unsupported Scan, storing driver.Value type string into type *time.Time") {
return t.getWithTimeHandling(id)
}
return nil, err
}
return &obj, nil
}
func (t Table[T]) getWithTimeHandling(id string) (*T, error) {
row := t.db.QueryRow(t.selectbyid, id)
if row == nil {
return nil, sql.ErrNoRows
}
var obj T
ptrVal := reflect.ValueOf(&obj).Elem()
// Create a slice to hold all values, using string for time fields
tempFields := make([]any, len(t.fields))
fieldInfos := make([]struct {
index int
fieldValue reflect.Value
isTime bool
isTimePtr bool
}, len(t.fields))
for i, f := range t.fields {
fieldValue := ptrVal.FieldByName(f.name)
if !fieldValue.IsValid() {
return nil, fmt.Errorf("field %s not found in struct", f.name)
}
// Check if this is a time.Time or *time.Time field
isTime := fieldValue.Type() == reflect.TypeOf(time.Time{})
isTimePtr := fieldValue.Kind() == reflect.Ptr && fieldValue.Type().Elem() == reflect.TypeOf(time.Time{})
fieldInfos[i] = struct {
index int
fieldValue reflect.Value
isTime bool
isTimePtr bool
}{
index: i,
fieldValue: fieldValue,
isTime: isTime,
isTimePtr: isTimePtr,
}
// For time fields, use a string to capture the raw value
if isTime || isTimePtr {
var s sql.NullString
tempFields[i] = &s
} else {
tempFields[i] = fieldValue.Addr().Interface()
}
}
if err := row.Scan(tempFields...); err != nil {
return nil, err
}
// Copy values back, parsing time fields
for _, info := range fieldInfos {
if info.isTime || info.isTimePtr {
s := tempFields[info.index].(*sql.NullString)
if s.Valid {
t, err := time.Parse(time.RFC3339, s.String)
if err != nil {
// Try other common formats
t, err = time.Parse("2006-01-02 15:04:05", s.String)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05Z", s.String)
if err != nil {
continue // Skip if we can't parse
}
}
}
if info.isTime {
info.fieldValue.Set(reflect.ValueOf(t))
} else if info.isTimePtr {
ptr := reflect.New(info.fieldValue.Type().Elem())
ptr.Elem().Set(reflect.ValueOf(t))
info.fieldValue.Set(ptr)
}
}
}
}
return &obj, nil
}
func (t Table[T]) SelectOne(where string, args ...any) (*T, error) {
query := t.selectwhere + " where " + where + " limit 1"
row := t.db.QueryRow(query, args...)
if row == nil {
return nil, sql.ErrNoRows
}
var obj T
ptrVal := reflect.ValueOf(&obj).Elem()
fields := make([]any, len(t.fields))
for i, f := range t.fields {
fieldValue := ptrVal.FieldByName(f.name)
if !fieldValue.IsValid() {
return nil, fmt.Errorf("field %s not found in struct", f.name)
}
fields[i] = fieldValue.Addr().Interface()
}
if err := row.Scan(fields...); err != nil {
if strings.Contains(err.Error(), "unsupported Scan, storing driver.Value type string into type *time.Time") {
return t.selectOneWithTimeHandling(where, args...)
}
return nil, err
}
return &obj, nil
}
func (t Table[T]) selectOneWithTimeHandling(where string, args ...any) (*T, error) {
query := t.selectwhere + " where " + where + " limit 1"
row := t.db.QueryRow(query, args...)
if row == nil {
return nil, sql.ErrNoRows
}
return t.scanWithTimeHandling(row)
}
func (t Table[T]) SelectWhere(where string, args ...any) ([]*T, error) {
query := t.selectwhere
if where != "" {
query += " where " + where
}
rows, err := t.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*T
for rows.Next() {
var obj T
ptrVal := reflect.ValueOf(&obj).Elem()
fields := make([]any, len(t.fields))
for i, f := range t.fields {
fieldValue := ptrVal.FieldByName(f.name)
if !fieldValue.IsValid() {
return nil, fmt.Errorf("field %s not found in struct", f.name)
}
fields[i] = fieldValue.Addr().Interface()
}
if err := rows.Scan(fields...); err != nil {
if strings.Contains(err.Error(), "unsupported Scan, storing driver.Value type string into type *time.Time") {
// Need to re-execute with time handling
rows.Close()
return t.selectWhereWithTimeHandling(where, args...)
}
return nil, err
}
results = append(results, &obj)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
func (t Table[T]) selectWhereWithTimeHandling(where string, args ...any) ([]*T, error) {
query := t.selectwhere
if where != "" {
query += " where " + where
}
rows, err := t.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*T
for rows.Next() {
obj, err := t.scanWithTimeHandling(rows)
if err != nil {
return nil, err
}
results = append(results, obj)
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
func (t Table[T]) scanWithTimeHandling(scanner any) (*T, error) {
var obj T
ptrVal := reflect.ValueOf(&obj).Elem()
// Create a slice to hold all values, using string for time fields
tempFields := make([]any, len(t.fields))
fieldInfos := make([]struct {
index int
fieldValue reflect.Value
isTime bool
isTimePtr bool
}, len(t.fields))
for i, f := range t.fields {
fieldValue := ptrVal.FieldByName(f.name)
if !fieldValue.IsValid() {
return nil, fmt.Errorf("field %s not found in struct", f.name)
}
// Check if this is a time.Time or *time.Time field
isTime := fieldValue.Type() == reflect.TypeOf(time.Time{})
var isTimePtr bool
if fieldValue.Kind() == reflect.Ptr {
isTimePtr = fieldValue.Type().Elem() == reflect.TypeOf(time.Time{})
}
fieldInfos[i] = struct {
index int
fieldValue reflect.Value
isTime bool
isTimePtr bool
}{
index: i,
fieldValue: fieldValue,
isTime: isTime,
isTimePtr: isTimePtr,
}
// For time fields, use a string to capture the raw value
if isTime || isTimePtr {
var s sql.NullString
tempFields[i] = &s
} else {
tempFields[i] = fieldValue.Addr().Interface()
}
}
var err error
switch s := scanner.(type) {
case *sql.Row:
err = s.Scan(tempFields...)
case *sql.Rows:
err = s.Scan(tempFields...)
default:
return nil, fmt.Errorf("unsupported scanner type")
}
if err != nil {
return nil, err
}
// Copy values back, parsing time fields
for _, info := range fieldInfos {
if info.isTime || info.isTimePtr {
s := tempFields[info.index].(*sql.NullString)
if s.Valid {
t, err := time.Parse(time.RFC3339, s.String)
if err != nil {
// Try other common formats
t, err = time.Parse("2006-01-02 15:04:05", s.String)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05Z", s.String)
if err != nil {
// Try SQLite format: YYYY-MM-DD HH:MM:SS
t, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", s.String)
if err != nil {
// Try simpler format
t, err = time.Parse("2006-01-02 15:04:05", s.String)
if err != nil {
continue
}
}
}
}
}
if info.isTime {
info.fieldValue.Set(reflect.ValueOf(t))
} else if info.isTimePtr {
ptr := reflect.New(info.fieldValue.Type().Elem())
ptr.Elem().Set(reflect.ValueOf(t))
info.fieldValue.Set(ptr)
}
}
}
}
return &obj, nil
}
func (t Table[T]) Delete(id string) error {
_, err := t.db.Exec(t.deletebyid, id)
return err
}
func (t Table[T]) DeleteWhere(where string, args ...any) error {
query := t.deletewhere
if where != "" {
query += " where " + where
}
_, err := t.db.Exec(query, args...)
return err
}
func (t Table[T]) Insert(value *T) (*T, error) {
// Get a settable reflect value of the struct
// value is *T, &value is **T, .Elem() gives *T (settables), .Elem() gives T (settables)
val := reflect.ValueOf(&value).Elem().Elem()
// Auto-fill ID field if it exists and is empty
if fieldID := val.FieldByName("ID"); fieldID.IsValid() && fieldID.CanSet() && fieldID.Kind() == reflect.String {
if fieldID.String() == "" {
fieldID.SetString(GenID())
}
}
// Auto-fill VersionId field if it exists and is empty
if fieldVersion := val.FieldByName("VersionId"); fieldVersion.IsValid() && fieldVersion.CanSet() && fieldVersion.Kind() == reflect.String {
if fieldVersion.String() == "" {
fieldVersion.SetString(GenID())
}
}
// Auto-fill DateCreated or DateCreate field if it exists
now := time.Now()
if fieldCreate := val.FieldByName("DateCreated"); fieldCreate.IsValid() && fieldCreate.CanSet() && fieldCreate.Type() == reflect.TypeOf(now) {
fieldCreate.Set(reflect.ValueOf(now))
} else if fieldCreate := val.FieldByName("DateCreate"); fieldCreate.IsValid() && fieldCreate.CanSet() && fieldCreate.Type() == reflect.TypeOf(now) {
fieldCreate.Set(reflect.ValueOf(now))
}
vals := make([]any, len(t.fields))
for i, f := range t.fields {
fieldValue := val.FieldByName(f.name)
if !fieldValue.IsValid() {
return nil, fmt.Errorf("field %s not found in struct", f.name)
}
vals[i] = fieldValue.Interface()
}
_, err := t.db.Exec(t.insertsql, vals...)
if err != nil {
return nil, err
}
return value, nil
}
func (t Table[T]) Update(value *T) (*T, error) {
// Get a settable reflect value of the struct
val := reflect.ValueOf(&value).Elem().Elem()
// Save old VersionId for WHERE clause
versionField := val.FieldByName("VersionId")
if !versionField.IsValid() {
return nil, fmt.Errorf("VersionId field not found")
}
oldVersionId := versionField.String()
// Generate new VersionId for optimistic locking
newVersionId := GenID()
versionField.SetString(newVersionId)
// Update DateUpdated field if it exists
if fieldUpdated := val.FieldByName("DateUpdated"); fieldUpdated.IsValid() && fieldUpdated.CanSet() {
if fieldUpdated.Kind() == reflect.Ptr {
now := time.Now()
if fieldUpdated.IsNil() {
fieldUpdated.Set(reflect.New(fieldUpdated.Type().Elem()))
}
fieldUpdated.Elem().Set(reflect.ValueOf(now))
} else if fieldUpdated.Type() == reflect.TypeOf(time.Time{}) {
fieldUpdated.Set(reflect.ValueOf(time.Now()))
}
}
// Build the values slice for the prepared statement
// UPDATE SQL expects: SET fields (excluding ID), then WHERE: ID and VersionId (old version)
vals := make([]any, 0, len(t.fields))
// Add all fields except ID for SET clause (includes new VersionId)
for _, f := range t.fields {
if f.dbname == "id" {
continue // Skip ID field for SET clause
}
fieldValue := val.FieldByName(f.name)
if !fieldValue.IsValid() {
return nil, fmt.Errorf("field %s not found in struct", f.name)
}
vals = append(vals, fieldValue.Interface())
}
// Add ID and OLD VersionId at the end for WHERE clause
idField := val.FieldByName("ID")
if !idField.IsValid() {
return nil, fmt.Errorf("ID field not found")
}
vals = append(vals, idField.Interface(), oldVersionId)
result, err := t.db.Exec(t.updatesql, vals...)
if err != nil {
return nil, err
}
// Check if any row was affected
rowsAffected, err := result.RowsAffected()
if err != nil {
return nil, err
}
if rowsAffected == 0 {
return nil, fmt.Errorf("no rows affected: record not found or version mismatch")
}
return value, nil
}
func (t Table[T]) Debug() {
fmt.Println("insert: ", t.insertsql)
fmt.Println("update: ", t.updatesql)
fmt.Println("selectbyid: ", t.selectbyid)
fmt.Println("deletebyid: ", t.deletebyid)
fmt.Println("selectwhere: ", t.selectwhere)
fmt.Println("deletewhere: ", t.deletewhere)
}
+460
View File
@@ -0,0 +1,460 @@
package orm_test
import (
"database/sql"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
orm "trankilou.fr/lasebuche"
)
type User struct {
ID string `db:"id" json:"id"`
Firstname string `db:"firstname" json:"firstname,omitempty"`
Lastname string `db:"lastname" json:"lastname,omitempty"`
Password string `db:"password" json:"-"`
Email string `db:"email" json:"email,omitempty"`
PictureID *string `db:"picture" json:"picture,omitempty"`
Enabled bool `db:"enabled" json:"enabled"`
Theme string `db:"theme" json:"theme"`
Lang string `db:"lang" json:"lang"`
Content []byte `db:"content" json:"content"`
DateCreated time.Time `db:"_date_created" json:"_date_created"`
DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"`
VersionId string `db:"_version" json:"_version"`
}
func TestHelloName(t *testing.T) {
db, err := sql.Open("sqlite", "testdb")
if err != nil {
t.Error(err.Error())
}
defer db.Close()
dialect := orm.NewSqliteDialect()
tableUser, err := orm.NewTable[User](db, dialect, User{}, "user")
if err != nil {
t.Error(err.Error())
}
tableUser.Debug()
err = tableUser.Sync()
if err != nil {
t.Error(err.Error())
}
user := &User{
Firstname: "fabien",
Lastname: "masson",
Email: "masson.fabien@gmail.com",
}
user, err = tableUser.Insert(user)
if err != nil {
t.Error(err.Error())
}
id := user.ID
user2, err := tableUser.Get(id)
if err != nil {
t.Error(err.Error())
}
if user2.Firstname != "fabien" {
t.Errorf("GetByID : expected %s, got %s", "fabien", user2.Firstname)
}
// Test SelectOne
user3, err := tableUser.SelectOne("firstname = $1", "fabien")
if err != nil {
t.Errorf("SelectOne failed: %v", err)
}
if user3 == nil {
t.Error("SelectOne returned nil")
}
if user3.Firstname != "fabien" {
t.Errorf("SelectOne: expected firstname %s, got %s", "fabien", user3.Firstname)
}
// Test SelectWhere
users, err := tableUser.SelectWhere("firstname = $1", "fabien")
if err != nil {
t.Errorf("SelectWhere failed: %v", err)
}
if len(users) == 0 {
t.Error("SelectWhere returned no results")
}
// Test Update
user.Firstname = "Fabien"
updatedUser, err := tableUser.Update(user)
if err != nil {
t.Errorf("Update failed: %v", err)
}
if updatedUser.Firstname != "Fabien" {
t.Errorf("Update: expected firstname %s, got %s", "Fabien", updatedUser.Firstname)
}
// Verify update with Get
user4, err := tableUser.Get(id)
if err != nil {
t.Errorf("Get after update failed: %v", err)
}
if user4.Firstname != "Fabien" {
t.Errorf("After update, Get: expected firstname %s, got %s", "Fabien", user4.Firstname)
}
// Test Delete
err = tableUser.Delete(id)
if err != nil {
t.Errorf("Delete failed: %v", err)
}
// Verify delete
_, err = tableUser.Get(id)
if err != sql.ErrNoRows {
t.Errorf("After delete, expected ErrNoRows, got: %v", err)
}
// Test DeleteWhere - insert another user first
user5 := &User{
Firstname: "Test",
Lastname: "User",
Email: "test@example.com",
}
user5, err = tableUser.Insert(user5)
if err != nil {
t.Errorf("Insert for DeleteWhere test failed: %v", err)
}
// Delete the test user
err = tableUser.DeleteWhere("firstname = $1", "Test")
if err != nil {
t.Errorf("DeleteWhere failed: %v", err)
}
// Verify delete
_, err = tableUser.Get(user5.ID)
if err != sql.ErrNoRows {
t.Errorf("After DeleteWhere, expected ErrNoRows, got: %v", err)
}
}
func TestCRUDOperations(t *testing.T) {
db, err := sql.Open("sqlite", "testdb")
if err != nil {
t.Fatal(err.Error())
}
defer db.Close()
dialect := orm.NewSqliteDialect()
tableUser, err := orm.NewTable[User](db, dialect, User{}, "user")
if err != nil {
t.Fatal(err.Error())
}
// Clean up before test
_, err = db.Exec("DELETE FROM user")
if err != nil {
t.Fatal(err.Error())
}
// Test Insert
user1 := &User{
Firstname: "John",
Lastname: "Doe",
Email: "john.doe@example.com",
Enabled: true,
Theme: "dark",
Lang: "en",
}
insertedUser, err := tableUser.Insert(user1)
if err != nil {
t.Fatalf("Insert failed: %v", err)
}
if insertedUser.ID == "" {
t.Error("Inserted user should have an ID")
}
if insertedUser.VersionId == "" {
t.Error("Inserted user should have a VersionId")
}
if insertedUser.DateCreated.IsZero() {
t.Error("Inserted user should have DateCreated set")
}
t.Logf("Inserted user with ID: %s, VersionId: %s", insertedUser.ID, insertedUser.VersionId)
// Test Get
fetchedUser, err := tableUser.Get(insertedUser.ID)
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if fetchedUser.ID != insertedUser.ID {
t.Errorf("Get: expected ID %s, got %s", insertedUser.ID, fetchedUser.ID)
}
if fetchedUser.Firstname != "John" {
t.Errorf("Get: expected Firstname John, got %s", fetchedUser.Firstname)
}
// Test Update
fetchedUser.Firstname = "Jane"
updatedUser, err := tableUser.Update(fetchedUser)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if updatedUser.Firstname != "Jane" {
t.Errorf("Update: expected Firstname Jane, got %s", updatedUser.Firstname)
}
if updatedUser.DateUpdated == nil {
t.Error("Update should have set DateUpdated")
}
// Verify update
verifiedUser, err := tableUser.Get(insertedUser.ID)
if err != nil {
t.Fatalf("Get after update failed: %v", err)
}
if verifiedUser.Firstname != "Jane" {
t.Errorf("After update, Get: expected Firstname Jane, got %s", verifiedUser.Firstname)
}
// Test SelectOne
selectedUser, err := tableUser.SelectOne("email = $1", "john.doe@example.com")
if err != nil {
t.Fatalf("SelectOne failed: %v", err)
}
if selectedUser == nil {
t.Error("SelectOne returned nil")
}
if selectedUser.Email != "john.doe@example.com" {
t.Errorf("SelectOne: expected email john.doe@example.com, got %s", selectedUser.Email)
}
// Insert another user for SelectWhere test
user2 := &User{
Firstname: "Alice",
Lastname: "Smith",
Email: "alice.smith@example.com",
}
_, err = tableUser.Insert(user2)
if err != nil {
t.Fatalf("Insert user2 failed: %v", err)
}
// Test SelectWhere
users, err := tableUser.SelectWhere("")
if err != nil {
t.Fatalf("SelectWhere failed: %v", err)
}
if len(users) < 2 {
t.Errorf("SelectWhere: expected at least 2 users, got %d", len(users))
}
// Test SelectWhere with condition
filteredUsers, err := tableUser.SelectWhere("firstname = $1", "Jane")
if err != nil {
t.Fatalf("SelectWhere with condition failed: %v", err)
}
if len(filteredUsers) != 1 {
t.Errorf("SelectWhere with condition: expected 1 user, got %d", len(filteredUsers))
}
// Test Delete
err = tableUser.Delete(insertedUser.ID)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
// Verify deletion
_, err = tableUser.Get(insertedUser.ID)
if err != sql.ErrNoRows {
t.Errorf("After Delete, expected ErrNoRows, got: %v", err)
}
// Test DeleteWhere
err = tableUser.DeleteWhere("firstname = $1", "Alice")
if err != nil {
t.Fatalf("DeleteWhere failed: %v", err)
}
// Verify deletion
_, err = tableUser.Get(user2.ID)
if err != sql.ErrNoRows {
t.Errorf("After DeleteWhere, expected ErrNoRows, got: %v", err)
}
// Clean up
_, err = db.Exec("DELETE FROM user")
if err != nil {
t.Logf("Cleanup failed: %v", err)
}
}
func TestEdgeCases(t *testing.T) {
db, err := sql.Open("sqlite", "testdb")
if err != nil {
t.Fatal(err.Error())
}
defer db.Close()
dialect := orm.NewSqliteDialect()
tableUser, err := orm.NewTable[User](db, dialect, User{}, "user")
if err != nil {
t.Fatal(err.Error())
}
// Clean up before test
_, err = db.Exec("DELETE FROM user")
if err != nil {
t.Fatal(err.Error())
}
// Test SelectOne with no results
user, err := tableUser.SelectOne("firstname = $1", "NonExistent")
if err != nil && err != sql.ErrNoRows {
t.Errorf("SelectOne with no results: expected error or nil, got: %v", err)
}
if user != nil {
t.Error("SelectOne with no results should return nil user")
}
// Test SelectWhere with no results
users, err := tableUser.SelectWhere("firstname = $1", "NonExistent")
if err != nil {
t.Errorf("SelectWhere with no results: unexpected error: %v", err)
}
if len(users) != 0 {
t.Errorf("SelectWhere with no results: expected 0 users, got %d", len(users))
}
// Test Get with non-existent ID
_, err = tableUser.Get("non-existent-id")
if err != sql.ErrNoRows {
t.Errorf("Get with non-existent ID: expected ErrNoRows, got: %v", err)
}
// Test Delete with non-existent ID (should not error, just affect 0 rows)
err = tableUser.Delete("non-existent-id")
if err != nil {
t.Errorf("Delete with non-existent ID: unexpected error: %v", err)
}
// Test DeleteWhere with no matching rows
err = tableUser.DeleteWhere("firstname = $1", "NonExistent")
if err != nil {
t.Errorf("DeleteWhere with no matching rows: unexpected error: %v", err)
}
// Test Update with non-existent ID (will fail due to WHERE clause)
nonExistentUser := &User{
ID: "non-existent-id",
Firstname: "Test",
VersionId: "test-version",
}
_, err = tableUser.Update(nonExistentUser)
// This should fail because the row doesn't exist
if err == nil {
t.Error("Update with non-existent ID should have failed")
}
// Clean up
_, err = db.Exec("DELETE FROM user")
if err != nil {
t.Logf("Cleanup failed: %v", err)
}
}
func TestVersionLocking(t *testing.T) {
db, err := sql.Open("sqlite", "testdb")
if err != nil {
t.Fatal(err.Error())
}
defer db.Close()
dialect := orm.NewSqliteDialect()
tableUser, err := orm.NewTable[User](db, dialect, User{}, "user")
if err != nil {
t.Fatal(err.Error())
}
// Clean up before test
_, err = db.Exec("DELETE FROM user")
if err != nil {
t.Fatal(err.Error())
}
// Insert a user
user := &User{
Firstname: "Version",
Lastname: "Test",
Email: "version@test.com",
}
insertedUser, err := tableUser.Insert(user)
if err != nil {
t.Fatalf("Insert failed: %v", err)
}
originalVersionId := insertedUser.VersionId
t.Logf("Original VersionId: %s", originalVersionId)
// Fetch the user to get fresh data
fetchedUser, err := tableUser.Get(insertedUser.ID)
if err != nil {
t.Fatalf("Get failed: %v", err)
}
// First update should succeed with correct version
fetchedUser.Firstname = "Version2"
updatedUser, err := tableUser.Update(fetchedUser)
if err != nil {
t.Fatalf("First update failed: %v", err)
}
// VersionId should have changed
if updatedUser.VersionId == originalVersionId {
t.Error("VersionId should have changed after update")
}
newVersionId := updatedUser.VersionId
t.Logf("New VersionId after first update: %s", newVersionId)
// Second update with correct version should succeed
updatedUser.Firstname = "Version3"
updatedUser2, err := tableUser.Update(updatedUser)
if err != nil {
t.Fatalf("Second update failed: %v", err)
}
// VersionId should have changed again
if updatedUser2.VersionId == newVersionId {
t.Error("VersionId should have changed after second update")
}
// Now try to update with old version - should fail
// Create a new user with the old version
oldVersionUser := &User{
ID: insertedUser.ID,
Firstname: "ShouldFail",
Lastname: "Test",
Email: "version@test.com",
VersionId: originalVersionId, // Old version
}
_, err = tableUser.Update(oldVersionUser)
if err == nil {
t.Error("Update with old VersionId should have failed")
}
if !strings.Contains(err.Error(), "version mismatch") {
t.Errorf("Expected version mismatch error, got: %v", err)
}
// Clean up
_, err = db.Exec("DELETE FROM user")
if err != nil {
t.Logf("Cleanup failed: %v", err)
}
}
BIN
View File
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
package orm
import (
"crypto/rand"
"encoding/hex"
"github.com/sixafter/nanoid"
)
func GenID() string {
id, err := nanoid.New()
if err != nil {
// Fallback to random hex
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "fallback-id"
}
return hex.EncodeToString(b)
}
return id.String()
}