commit efa486fea8fafa17c6cf87bc9578d2792da9912c Author: Aneurin Barker Snook Date: Tue Sep 1 00:13:01 2026 +0100 initial commit diff --git a/error.go b/error.go new file mode 100644 index 0000000..a4fb62b --- /dev/null +++ b/error.go @@ -0,0 +1,50 @@ +package sqaffold + +import "net/http" + +var ( + ErrNoRows = NewError(http.StatusNotFound, "no rows", nil) +) + +type Error struct { + StatusCode int + Text string + + Previous error +} + +func (e *Error) Error() string { + return e.Text +} + +func (e *Error) Is(target error) bool { + if other, ok := target.(*Error); ok { + if other.StatusCode > 0 { + if e.StatusCode != other.StatusCode { + return false + } + } + + if other.Text != "" { + if e.Text != other.Text { + return false + } + } + } + + return true +} + +func Err(err error) *Error { + return NewError(http.StatusInternalServerError, err.Error(), err) +} + +func NewError(statusCode int, text string, previous error) *Error { + err := &Error{ + StatusCode: statusCode, + Text: text, + Previous: previous, + } + + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..134650d --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module code.aneur.in/go/sqaffold + +go 1.25.7 + +require ( + code.aneur.in/go/record v0.0.0-20260704022026-db3f74ccdeb4 + code.aneur.in/go/version v0.0.0-20260706210436-9f229dde97c9 + modernc.org/sqlite v1.57.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 + 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..c782dae --- /dev/null +++ b/go.sum @@ -0,0 +1,62 @@ +code.aneur.in/go/record v0.0.0-20260704022026-db3f74ccdeb4 h1:cE8tCjhHePkEMBEQbkuTy/NQ5DlAoDwRPQERqcZK5DM= +code.aneur.in/go/record v0.0.0-20260704022026-db3f74ccdeb4/go.mod h1:nIqf1d1YWOPSVrbBFJY2ZBEMMFSfQCgXL7nFVXOzVqo= +code.aneur.in/go/version v0.0.0-20260706210436-9f229dde97c9 h1:OquK0VYWHUts7u/23A6IiZ0PkznhIihj9U0DBSXfeM0= +code.aneur.in/go/version v0.0.0-20260706210436-9f229dde97c9/go.mod h1:G26bUe3Jroua1UDRzJO12ssn6OPcTsizffKJ+5/FK7U= +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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.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/migrate.go b/migrate.go new file mode 100644 index 0000000..bf4468f --- /dev/null +++ b/migrate.go @@ -0,0 +1,47 @@ +package sqaffold + +import ( + "code.aneur.in/go/version" +) + +func (s *Scaffold) Migrate() error { + if err := s.migrateApp("sqaffold", s.migration()); err != nil { + return Err(err) + } + + for appName, migration := range s.apps { + if err := s.migrateApp(appName, migration); err != nil { + return Err(err) + } + } + + return nil +} + +func (s *Scaffold) migrateApp(appName string, migration version.Migration) error { + currentVersion, err := s.GetVersion(appName) + if err != nil { + return err + } + + required, err := migration.Match(&version.Constraint{Gt: currentVersion}) + if err != nil { + return err + } + + versions, _ := required.Versions() + if len(versions) == 0 { + return nil + } + + var updateVersion *version.Version + err = migration.Up(func(applied, next *version.Version) { + updateVersion = applied + }) + + if updateVersion != nil { + s.setVersion(appName, updateVersion) + } + + return err +} diff --git a/migrate_test.go b/migrate_test.go new file mode 100644 index 0000000..7ffb312 --- /dev/null +++ b/migrate_test.go @@ -0,0 +1,39 @@ +package sqaffold + +import ( + "database/sql" + "os" + "testing" + + "code.aneur.in/go/version" + _ "modernc.org/sqlite" +) + +func TestMigrate(t *testing.T) { + conn, err := sql.Open("sqlite", "migrate_test.sqlite3") + if err != nil { + t.Error(err) + return + } + + defer func() { + conn.Close() + os.Remove("migrate_test.sqlite3") + }() + + s := NewScaffold(t.Context(), conn).WithApp("myapp", version.Migration{ + "1.0.0": version.Patch{ + Up: func() error { + return nil + }, + Down: func() error { + return nil + }, + }, + }) + + if err := s.Migrate(); err != nil { + t.Error(err) + return + } +} diff --git a/migration.go b/migration.go new file mode 100644 index 0000000..ee036cd --- /dev/null +++ b/migration.go @@ -0,0 +1,25 @@ +package sqaffold + +import "code.aneur.in/go/version" + +func (s *Scaffold) migration() version.Migration { + return version.Migration{ + "0.1.0": version.Patch{ + Up: func() error { + _, err := s.Exec(` + create table sqaffold_apps ( + name text not null, + version text, + constraint config__unq__name unique (name) on conflict replace + )`) + + return err + }, + + Down: func() error { + _, err := s.Exec("drop table sqaffold_apps") + return err + }, + }, + } +} diff --git a/record.go b/record.go new file mode 100644 index 0000000..6711624 --- /dev/null +++ b/record.go @@ -0,0 +1,166 @@ +package sqaffold + +import ( + "database/sql" + "fmt" + + "code.aneur.in/go/record" +) + +func ScanOneRecord(rows *sql.Rows) (record.Record, error) { + records, err := ScanRecords(rows) + if err != nil { + return nil, err + } + + if len(records) == 0 { + return nil, ErrNoRows + } + + return records[0], nil +} + +// ScanRecords transforms SQL row data into record.Record objects, making them easier to handle with type safety. +// +// https://go.dev/ref/spec#Types +func ScanRecords(rows *sql.Rows) ([]record.Record, error) { + columns, err := rows.ColumnTypes() + if err != nil { + return nil, err + } + + data := []record.Record{} + + for rows.Next() { + values := []any{} + for _, column := range columns { + switch column.ScanType().Name() { + case "bool": + var value bool + values = append(values, &value) + case "byte": + var value byte + values = append(values, &value) + case "complex64": + var value complex64 + values = append(values, &value) + case "complex128": + var value complex128 + values = append(values, &value) + case "float32": + var value float32 + values = append(values, &value) + case "float64": + var value float64 + values = append(values, &value) + case "int": + var value int + values = append(values, &value) + case "int8": + var value int8 + values = append(values, &value) + case "int16": + var value int16 + values = append(values, &value) + case "int32": + var value int32 + values = append(values, &value) + case "int64": + var value int64 + values = append(values, &value) + case "rune": + var value rune + values = append(values, &value) + case "string": + var value string + values = append(values, &value) + case "uint": + var value uint + values = append(values, &value) + case "uint8": + var value uint8 + values = append(values, &value) + case "uint16": + var value uint16 + values = append(values, &value) + case "uint32": + var value uint32 + values = append(values, &value) + case "uint64": + var value uint64 + values = append(values, &value) + default: + return nil, fmt.Errorf("unknown scan type %q", column.ScanType().Name()) + } + } + + err := rows.Scan(values...) + if err != nil { + return nil, err + } + + row := record.Record{} + for i, column := range columns { + switch column.ScanType().Name() { + case "bool": + value := values[i].(*bool) + row[column.Name()] = *value + case "byte": + value := values[i].(*byte) + row[column.Name()] = *value + case "complex64": + value := values[i].(*complex64) + row[column.Name()] = *value + case "complex128": + value := values[i].(*complex128) + row[column.Name()] = *value + case "float32": + value := values[i].(*float32) + row[column.Name()] = *value + case "float64": + value := values[i].(*float64) + row[column.Name()] = *value + case "int": + value := values[i].(*int) + row[column.Name()] = *value + case "int8": + value := values[i].(*int8) + row[column.Name()] = *value + case "int16": + value := values[i].(*int16) + row[column.Name()] = *value + case "int32": + value := values[i].(*int32) + row[column.Name()] = *value + case "int64": + value := values[i].(*int64) + row[column.Name()] = *value + case "rune": + value := values[i].(*rune) + row[column.Name()] = *value + case "string": + value := values[i].(*string) + row[column.Name()] = *value + case "uint": + value := values[i].(*uint) + row[column.Name()] = *value + case "uint8": + value := values[i].(*uint8) + row[column.Name()] = *value + case "uint16": + value := values[i].(*uint16) + row[column.Name()] = *value + case "uint32": + value := values[i].(*uint32) + row[column.Name()] = *value + case "uint64": + value := values[i].(*uint64) + row[column.Name()] = *value + } + } + + data = append(data, row) + } + + return data, nil +} diff --git a/scaffold.go b/scaffold.go new file mode 100644 index 0000000..3ac60c3 --- /dev/null +++ b/scaffold.go @@ -0,0 +1,54 @@ +package sqaffold + +import ( + "context" + "database/sql" + + "code.aneur.in/go/record" + "code.aneur.in/go/version" +) + +type Scaffold struct { + ctx context.Context + conn *sql.DB + + apps map[string]version.Migration +} + +func (s *Scaffold) Exec(query string, args ...any) (sql.Result, error) { + return s.conn.ExecContext(s.ctx, query, args...) +} + +func (s *Scaffold) Query(query string, args ...any) ([]record.Record, error) { + rows, err := s.conn.QueryContext(s.ctx, query, args...) + if err != nil { + return nil, Err(err) + } + + return ScanRecords(rows) +} + +func (s *Scaffold) QueryOne(query string, args ...any) (record.Record, error) { + rows, err := s.conn.QueryContext(s.ctx, query, args...) + if err != nil { + return nil, Err(err) + } + + return ScanOneRecord(rows) +} + +func (s *Scaffold) WithApp(name string, migration version.Migration) *Scaffold { + s.apps[name] = migration + return s +} + +func NewScaffold(ctx context.Context, conn *sql.DB) *Scaffold { + s := &Scaffold{ + ctx: ctx, + conn: conn, + + apps: map[string]version.Migration{}, + } + + return s +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..0ad8b15 --- /dev/null +++ b/version.go @@ -0,0 +1,27 @@ +package sqaffold + +import ( + "errors" + + "code.aneur.in/go/version" +) + +func (s *Scaffold) GetVersion(appName string) (*version.Version, error) { + row, err := s.QueryOne("select version from sqaffold_apps where name = ?", appName) + switch { + case err == nil: + case err.Error() == "SQL logic error: no such table: sqaffold_apps (1)": + fallthrough + case errors.Is(err, ErrNoRows): + return version.Parse("0") + default: + return nil, Err(err) + } + + return version.Parse(row.String("version")) +} + +func (s *Scaffold) setVersion(appName string, version *version.Version) error { + _, err := s.Exec("insert into sqaffold_apps (name, version) values (?, ?)", appName, version.Text) + return err +}