From e3ca6328835fa338fa39369342fb9c2e560815b4 Mon Sep 17 00:00:00 2001 From: Fabien Masson Date: Sat, 8 Aug 2026 20:36:24 +0200 Subject: [PATCH] first commit --- builder.go | 84 +++++++ dialect_sqlite.go | 61 +++++ go.mod | 23 ++ go.sum | 68 ++++++ orm.go | 551 ++++++++++++++++++++++++++++++++++++++++++++++ orm_test.go | 460 ++++++++++++++++++++++++++++++++++++++ testdb | Bin 0 -> 12288 bytes utility.go | 20 ++ 8 files changed, 1267 insertions(+) create mode 100644 builder.go create mode 100644 dialect_sqlite.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 orm.go create mode 100644 orm_test.go create mode 100644 testdb create mode 100644 utility.go diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..d8a4095 --- /dev/null +++ b/builder.go @@ -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, + ) +} diff --git a/dialect_sqlite.go b/dialect_sqlite.go new file mode 100644 index 0000000..a241076 --- /dev/null +++ b/dialect_sqlite.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fdb9528 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1fb248d --- /dev/null +++ b/go.sum @@ -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= diff --git a/orm.go b/orm.go new file mode 100644 index 0000000..c73c994 --- /dev/null +++ b/orm.go @@ -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) +} diff --git a/orm_test.go b/orm_test.go new file mode 100644 index 0000000..d86d8f9 --- /dev/null +++ b/orm_test.go @@ -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) + } +} diff --git a/testdb b/testdb new file mode 100644 index 0000000000000000000000000000000000000000..cd5b420b2dc86e8873053d8bfabfc35ef1d5fb6f GIT binary patch literal 12288 zcmeI2OK;oQ6~}3NCYhwQoleJ-W-)-!O{N%2xbI7PP#}6ymi44eJr@-yijpXa)Qgg3 zWn~oYuFI~vm`y)GH$~A!KR{8Sv*;)2YS2Yj-ORF=PSQjv6T=>$-H;+FQioUfr#}zx zJ@;IlYkAwVBE8AnF}w(t9!a~q(z8fJlB6TC?TPKxvLg;Ycy%J){qviaBPsUlzw94= zEA1Y9Djj@!`1MT%LVKt{s6ePds6ePds6ePds6ePds6ePdsKEbHfuFOT`;U)~c79p% zjLz6vF08q~?ItVfxSo#a@kBly@jr=tZg(S|we}+JL~P6PI5M5vjxoQCjI7J!o;_c9 zuHjg3UDLIC#&YaMo<2Pu8#i4sH5QAD$^7kl-uMp7G3@cXtC-rRx17IVIJR8>7j*@z zW6f>zHTSFU1IOOLdOxw-e|Kf(#PuxKyT0JK-8DR`ZO$$6_MR%u<$Ny6v_0Fg7M|fu zUlv?W{qp}Qc-&rD^MyTe-&y-lp6=dz{N>S(WV>B!y_k)~*uHHny~*`?+rMLN=?fbh&l5|)-_|pgLn_vFr;SwqkDiA90T@<+b_>%+vz6K&qlR)3Yl?Fk{RKmibAgesgUfCfS_2q`%ovAFJ4kfk;g2~?o@jMAtGsGn^?ZIor= zv}`#UJ)i4r3aF5$(7inrI>9ui$V?`R!dV~`1;T3AOFgzw`NJ#k&5|$FCGH9+ zvIS+znRGof$)Z9jG4|pFW@Y{CE>H->Fo%pF$^(6h${2Clo-tOdd(AvrwKQ_tv+2-Q zML+x17SzZotU+yNw+nQPH#xaP&oaH+L%|b>V@y_Ls30N-DMe+@F=-lcwds-mbd$Mk z>4Qq@v_4}3>fvjsONsX@T0>KlDyb*=XkCepReO@VJrpMv`=Y^_oev2*v2xd9+y^4j@=qrX|Pc7l_@O39=K)Vw5VB$l~?| z3PlNq3In*hN@zHZ3Y%GXR4krXz(kVn{e26{txk2wx@83?Q97BlqAVJ3-vx@uF+`Z6 zAfS*SC~COb?HX4810S0HzRLW<~*8KlCmf}cJ@8OA->VdpB&w+c~5r#M}3b6rC~ znOjhf-KbPIb?bDIn$HJueVQ%MJ2*P^g9J`60Se&3jt(#fq_bLiv-AYnn&YIjmg=TU zt-K`d{&fqg+f|qHysR&B_1-d-P362=y>l0(fE-gqfWQ!9flY#V_88|dQLel5OARYk zYpL;ZHvUR7#OeqoA$i7CuuMIlny=IiWm;8D{{=JlLlcgemGThvV91 zdA6E(PK%xnFV%w96H~;$-Gb^Z@(ku(0!tlzy)pVdqI&Q5sjKYnn_=Q}&RXicIF8ed znoOXpN5cB}@f$(pX-{t;$C($p1P?D-`3)GY<73;j&K=tu82(Xg;U7M;)?%DJw%!UM z5fI(#36x_pmZ2m zu>37>_Tgh;`D5S|zz>AwtH3%_O9<~Pzq%CM|=E1U=rfr>7;~a literal 0 HcmV?d00001 diff --git a/utility.go b/utility.go new file mode 100644 index 0000000..a6edf26 --- /dev/null +++ b/utility.go @@ -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() +}