diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4eb7821c..d9ca426f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,22 +9,21 @@ jobs: name: Release runs-on: ubuntu-latest steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Set up Go 1.17 uses: actions/setup-go@v3 with: go-version: 1.17 - id: go - - - name: Checkout Code - uses: actions/checkout@v3 - name: Fetch tags run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} - with: - version: latest - args: release --rm-dist + - name: setup release environment + run: |- + echo 'GITHUB_TOKEN=${{secrets.GORELEASER_TOKEN }}' > .release-env + + - name: release publish + run: make release + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4e98acfe..d3f9c48e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,7 @@ jobs: go-version: - "1.16.x" - "1.17.x" + - "1.18.x" services: mysql: @@ -57,6 +58,7 @@ jobs: go-version: - "1.16.x" - "1.17.x" + - "1.18.x" services: postgres: @@ -102,6 +104,7 @@ jobs: go-version: - "1.16.x" - "1.17.x" + - "1.18.x" steps: - uses: actions/checkout@v3 @@ -145,6 +148,7 @@ jobs: go-version: - "1.16.x" - "1.17.x" + - "1.18.x" steps: - uses: actions/checkout@v3 @@ -185,6 +189,7 @@ jobs: go-version: - "1.16.x" - "1.17.x" + - "1.18.x" os: - "macos-latest" - "windows-latest" diff --git a/.gitignore b/.gitignore index d587321e..74d9ca99 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ testdata/migrations/schema.sql .grifter/ vendor/ .env +.release-env # test data cockroach-data/ diff --git a/.goreleaser.yml b/.goreleaser.yml index f22cfd3f..5588bc8a 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,6 @@ # GoReleaser config +--- before: hooks: - go mod tidy @@ -14,17 +15,32 @@ builds: - amd64 env: - CGO_ENABLED=1 - - CC=o64-clang - - CXX=o64-clang++ + - CC=/osxcross/target/bin/o64-clang + - CXX=/osxcross/target/bin/o64-clang++ flags: - -tags - sqlite -- id: pop_linux +- id: pop_darwin_arm64 binary: soda main: soda/main.go + goos: + - darwin + goarch: + - arm64 env: - CGO_ENABLED=1 + - CC=/osxcross/target/bin/oa64-clang + - CXX=/osxcross/target/bin/oa64-clang++ + flags: + - -tags + - sqlite + +- id: pop_linux + binary: soda + main: soda/main.go + env: + - CGO_ENABLED=0 flags: - -tags - sqlite diff --git a/Makefile b/Makefile index 893e46e8..24d4eab5 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +PACKAGE_NAME := github.com/gobuffalo/pop +GOLANG_CROSS_VERSION ?= v1.17.6 + TAGS ?= "sqlite" GO_BIN ?= go @@ -37,5 +40,42 @@ endif release-test: ./test.sh +.PHONY: sysroot-pack +sysroot-pack: + @tar cf - $(SYSROOT_DIR) -P | pv -s $[$(du -sk $(SYSROOT_DIR) | awk '{print $1}') * 1024] | pbzip2 > $(SYSROOT_ARCHIVE) + +.PHONY: sysroot-unpack +sysroot-unpack: + @pv $(SYSROOT_ARCHIVE) | pbzip2 -cd | tar -xf - + +.PHONY: release-dry-run +release-dry-run: + @docker run \ + --rm \ + --privileged \ + -e CGO_ENABLED=1 \ + --env-file .release-env \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v `pwd`:/go/src/$(PACKAGE_NAME) \ + -v `pwd`/sysroot:/sysroot \ + -w /go/src/$(PACKAGE_NAME) \ + goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ + --rm-dist --skip-validate --skip-publish --snapshot + +.PHONY: release release: - release -y -f soda/cmd/version.go + @if [ ! -f ".release-env" ]; then \ + echo "\033[91m.release-env is required for release\033[0m";\ + exit 1;\ + fi + docker run \ + --rm \ + --privileged \ + -e CGO_ENABLED=1 \ + --env-file .release-env \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v `pwd`:/go/src/$(PACKAGE_NAME) \ + -v `pwd`/sysroot:/sysroot \ + -w /go/src/$(PACKAGE_NAME) \ + goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ + release --rm-dist diff --git a/associations/associations_for_struct.go b/associations/associations_for_struct.go index bdd7e180..529b210b 100644 --- a/associations/associations_for_struct.go +++ b/associations/associations_for_struct.go @@ -27,6 +27,11 @@ var associationBuilders = map[string]associationBuilder{} // it throws an error when it finds a field that does // not exist for a model. func ForStruct(s interface{}, fields ...string) (Associations, error) { + return forStruct(s, s, fields) +} + +// forStruct is a recursive helper that passes the root model down for embedded fields +func forStruct(parent, s interface{}, fields []string) (Associations, error) { t, v := getModelDefinition(s) if t.Kind() != reflect.Struct { return nil, fmt.Errorf("could not get struct associations: not a struct but %T", s) @@ -74,7 +79,20 @@ func ForStruct(s interface{}, fields ...string) (Associations, error) { // inline embedded field if f.Anonymous { - innerAssociations, err := ForStruct(v.Field(i).Interface(), fields...) + field := v.Field(i) + // we need field to be a pointer, so that we can later set the value + // if the embedded field is of type struct {...}, we have to take its address + if field.Kind() != reflect.Ptr { + field = field.Addr() + } + if fieldIsNil(field) { + // initialize zero value + field = reflect.New(field.Type().Elem()) + // we can only get in this case if v.Field(i) is a pointer type because it could not be nil otherwise + // => it is safe to set it here as is + v.Field(i).Set(field) + } + innerAssociations, err := forStruct(parent, field.Interface(), fields) if err != nil { return nil, err } @@ -92,11 +110,12 @@ func ForStruct(s interface{}, fields ...string) (Associations, error) { for name, builder := range associationBuilders { tag := tags.Find(name) if !tag.Empty() { + pt, pv := getModelDefinition(parent) params := associationParams{ field: f, - model: s, - modelType: t, - modelValue: v, + model: parent, + modelType: pt, + modelValue: pv, popTags: tags, innerAssociations: fieldsWithInnerAssociation[f.Name], } diff --git a/dialect.go b/dialect.go index 1b87847d..0e49b168 100644 --- a/dialect.go +++ b/dialect.go @@ -12,6 +12,7 @@ type crudable interface { SelectMany(store, *Model, Query) error Create(store, *Model, columns.Columns) error Update(store, *Model, columns.Columns) error + UpdateQuery(store, *Model, columns.Columns, Query) (int64, error) Destroy(store, *Model) error Delete(store, *Model, Query) error } diff --git a/dialect_cockroach.go b/dialect_cockroach.go index 16d4d96b..5fb7e324 100644 --- a/dialect_cockroach.go +++ b/dialect_cockroach.go @@ -106,6 +106,10 @@ func (p *cockroach) Update(s store, model *Model, cols columns.Columns) error { return genericUpdate(s, model, cols, p) } +func (p *cockroach) UpdateQuery(s store, model *Model, cols columns.Columns, query Query) (int64, error) { + return genericUpdateQuery(s, model, cols, p, query, sqlx.DOLLAR) +} + func (p *cockroach) Destroy(s store, model *Model) error { stmt := p.TranslateSQL(fmt.Sprintf("DELETE FROM %s AS %s WHERE %s", p.Quote(model.TableName()), model.Alias(), model.WhereID())) _, err := genericExec(s, stmt, model.ID()) diff --git a/dialect_common.go b/dialect_common.go index d0876295..5b1d76f4 100644 --- a/dialect_common.go +++ b/dialect_common.go @@ -14,6 +14,7 @@ import ( "github.com/gobuffalo/pop/v6/columns" "github.com/gobuffalo/pop/v6/logging" "github.com/gofrs/uuid" + "github.com/jmoiron/sqlx" ) func init() { @@ -110,6 +111,32 @@ func genericUpdate(s store, model *Model, cols columns.Columns, quoter quotable) return nil } +func genericUpdateQuery(s store, model *Model, cols columns.Columns, quoter quotable, query Query, bindType int) (int64, error) { + q := fmt.Sprintf("UPDATE %s AS %s SET %s", quoter.Quote(model.TableName()), model.Alias(), cols.Writeable().QuotedUpdateString(quoter)) + + q, updateArgs, err := sqlx.Named(q, model.Value) + if err != nil { + return 0, err + } + + sb := query.toSQLBuilder(model) + q = sb.buildWhereClauses(q) + + q = sqlx.Rebind(bindType, q) + + result, err := genericExec(s, q, append(updateArgs, sb.args...)...) + if err != nil { + return 0, err + } + + n, err := result.RowsAffected() + if err != nil { + return 0, err + } + + return n, err +} + func genericDestroy(s store, model *Model, quoter quotable) error { stmt := fmt.Sprintf("DELETE FROM %s AS %s WHERE %s", quoter.Quote(model.TableName()), model.Alias(), model.WhereID()) _, err := genericExec(s, stmt, model.ID()) diff --git a/dialect_mysql.go b/dialect_mysql.go index 1cfda3cf..7b5022b4 100644 --- a/dialect_mysql.go +++ b/dialect_mysql.go @@ -13,6 +13,7 @@ import ( "github.com/gobuffalo/pop/v6/columns" "github.com/gobuffalo/pop/v6/internal/defaults" "github.com/gobuffalo/pop/v6/logging" + "github.com/jmoiron/sqlx" ) const nameMySQL = "mysql" @@ -94,6 +95,14 @@ func (m *mysql) Update(s store, model *Model, cols columns.Columns) error { return nil } +func (m *mysql) UpdateQuery(s store, model *Model, cols columns.Columns, query Query) (int64, error) { + if n, err := genericUpdateQuery(s, model, cols, m, query, sqlx.QUESTION); err != nil { + return n, fmt.Errorf("mysql update query: %w", err) + } else { + return n, nil + } +} + func (m *mysql) Destroy(s store, model *Model) error { stmt := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", m.Quote(model.TableName()), model.IDField()) _, err := genericExec(s, stmt, model.ID()) diff --git a/dialect_postgresql.go b/dialect_postgresql.go index fe4d0236..6ce61569 100644 --- a/dialect_postgresql.go +++ b/dialect_postgresql.go @@ -90,6 +90,10 @@ func (p *postgresql) Update(s store, model *Model, cols columns.Columns) error { return genericUpdate(s, model, cols, p) } +func (p *postgresql) UpdateQuery(s store, model *Model, cols columns.Columns, query Query) (int64, error) { + return genericUpdateQuery(s, model, cols, p, query, sqlx.DOLLAR) +} + func (p *postgresql) Destroy(s store, model *Model) error { stmt := p.TranslateSQL(fmt.Sprintf("DELETE FROM %s AS %s WHERE %s", p.Quote(model.TableName()), model.Alias(), model.WhereID())) _, err := genericExec(s, stmt, model.ID()) diff --git a/dialect_sqlite.go b/dialect_sqlite.go index 1ac8691f..d8400a50 100644 --- a/dialect_sqlite.go +++ b/dialect_sqlite.go @@ -1,9 +1,9 @@ -// +build sqlite - package pop import ( + "database/sql" "database/sql/driver" + "errors" "fmt" "io" "net/url" @@ -19,8 +19,7 @@ import ( "github.com/gobuffalo/pop/v6/columns" "github.com/gobuffalo/pop/v6/internal/defaults" "github.com/gobuffalo/pop/v6/logging" - "github.com/mattn/go-sqlite3" - _ "github.com/mattn/go-sqlite3" // Load SQLite3 CGo driver + "github.com/jmoiron/sqlx" ) const nameSQLite3 = "sqlite3" @@ -41,6 +40,15 @@ type sqlite struct { smGil *sync.Mutex } +func requireSQLite3() error { + for _, driverName := range sql.Drivers() { + if driverName == nameSQLite3 { + return nil + } + } + return errors.New("sqlite3 support was not compiled into the binary") +} + func (m *sqlite) Name() string { return nameSQLite3 } @@ -109,6 +117,20 @@ func (m *sqlite) Update(s store, model *Model, cols columns.Columns) error { }) } +func (m *sqlite) UpdateQuery(s store, model *Model, cols columns.Columns, query Query) (int64, error) { + rowsAffected := int64(0) + err := m.locker(m.smGil, func() error { + if n, err := genericUpdateQuery(s, model, cols, m, query, sqlx.QUESTION); err != nil { + rowsAffected = n + return fmt.Errorf("sqlite update query: %w", err) + } else { + rowsAffected = n + return nil + } + }) + return rowsAffected, err +} + func (m *sqlite) Destroy(s store, model *Model) error { return m.locker(m.smGil, func() error { if err := genericDestroy(s, model, m); err != nil { @@ -247,6 +269,10 @@ func (m *sqlite) TruncateAll(tx *Connection) error { } func newSQLite(deets *ConnectionDetails) (dialect, error) { + err := requireSQLite3() + if err != nil { + return nil, err + } deets.URL = fmt.Sprintf("sqlite3://%s", deets.Database) cd := &sqlite{ gil: &sync.Mutex{}, @@ -312,5 +338,13 @@ func finalizerSQLite(cd *ConnectionDetails) { } func newSQLiteDriver() (driver.Driver, error) { - return new(sqlite3.SQLiteDriver), nil + err := requireSQLite3() + if err != nil { + return nil, err + } + db, err := sql.Open(nameSQLite3, ":memory:?cache=newSQLiteDriver_temporary") + if err != nil { + return nil, err + } + return db.Driver(), db.Close() } diff --git a/dialect_sqlite_shim.go b/dialect_sqlite_shim.go deleted file mode 100644 index 0c51baa1..00000000 --- a/dialect_sqlite_shim.go +++ /dev/null @@ -1,23 +0,0 @@ -// +build !sqlite - -package pop - -import ( - "database/sql/driver" - "errors" -) - -const nameSQLite3 = "sqlite3" - -func init() { - dialectSynonyms["sqlite"] = nameSQLite3 - newConnection[nameSQLite3] = newSQLite -} - -func newSQLite(deets *ConnectionDetails) (dialect, error) { - return nil, errors.New("sqlite3 support was not compiled into the binary") -} - -func newSQLiteDriver() (driver.Driver, error) { - return nil, errors.New("sqlite3 support was not compiled into the binary") -} diff --git a/dialect_sqlite_tag.go b/dialect_sqlite_tag.go new file mode 100644 index 00000000..286dd783 --- /dev/null +++ b/dialect_sqlite_tag.go @@ -0,0 +1,8 @@ +//go:build sqlite +// +build sqlite + +package pop + +import ( + _ "github.com/mattn/go-sqlite3" // Load SQLite3 CGo driver +) diff --git a/docker-compose.yml b/docker-compose.yml index 54b90986..2201bec8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '2' +version: '2.1' services: mysql: @@ -25,7 +25,7 @@ services: - ./sqldumps:/docker-entrypoint-initdb.d cockroach: image: cockroachdb/cockroach:v20.2.4 - user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} + user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$$(id -u):$$(id -g) docker-compose up'"} ports: - "26257:26257" volumes: diff --git a/executors.go b/executors.go index 04478f66..37b03b35 100644 --- a/executors.go +++ b/executors.go @@ -380,6 +380,35 @@ func (c *Connection) Update(model interface{}, excludeColumns ...string) error { }) } +// UpdateQuery updates all rows matched by the query. The new values are read +// from the first argument, which must be a struct. The column names to be +// updated must be listed explicitly in subsequent arguments. The ID and +// CreatedAt columns are never updated. The UpdatedAt column is updated +// automatically. +// +// UpdateQuery does not execute (before|after)(Create|Update|Save) callbacks. +// +// Calling UpdateQuery with no columnNames will result in only the UpdatedAt +// column being updated. +func (q *Query) UpdateQuery(model interface{}, columnNames ...string) (int64, error) { + sm := NewModel(model, q.Connection.Context()) + modelKind := reflect.TypeOf(reflect.Indirect(reflect.ValueOf(model))).Kind() + if modelKind != reflect.Struct { + return 0, fmt.Errorf("model must be a struct; got %s", modelKind) + } + + cols := columns.NewColumnsWithAlias(sm.TableName(), sm.As, sm.IDField()) + cols.Add(columnNames...) + if _, err := sm.fieldByName("UpdatedAt"); err == nil { + cols.Add("updated_at") + } + cols.Remove(sm.IDField(), "created_at") + + now := nowFunc().Truncate(time.Microsecond) + sm.setUpdatedAt(now) + return q.Connection.Dialect.UpdateQuery(q.Connection.Store, sm, cols, *q) +} + // UpdateColumns writes changes from an entry to the database, including only the given columns // or all columns if no column names are provided. // It updates the `updated_at` column automatically if you include `updated_at` in columnNames. diff --git a/executors_test.go b/executors_test.go index d428e364..d96f2506 100644 --- a/executors_test.go +++ b/executors_test.go @@ -556,6 +556,13 @@ func Test_Embedded_Struct(t *testing.T) { r.NoError(tx.Find(&actual, entry.ID)) r.Equal(entry.AdditionalField, actual.AdditionalField) + entry.AdditionalField = entry.AdditionalField + "; updated again" + count, err := tx.Where("id = ?", entry.ID).UpdateQuery(entry, "additional_field") + r.NoError(err) + require.Equal(t, int64(1), count) + r.NoError(tx.Find(&actual, entry.ID)) + r.Equal(entry.AdditionalField, actual.AdditionalField) + r.NoError(tx.Destroy(entry)) }) } @@ -1231,6 +1238,150 @@ func Test_Eager_Creation_Without_Associations(t *testing.T) { }) } +func Test_Eager_Embedded_Struct(t *testing.T) { + if PDB == nil { + t.Skip("skipping integration tests") + } + transaction(func(tx *Connection) { + r := require.New(t) + + type AssocFields struct { + Books Books `has_many:"books" order_by:"title asc"` + FavoriteSong Song `has_one:"song" fk_id:"u_id"` + Houses Addresses `many_to_many:"users_addresses"` + } + + type User struct { + ID int `db:"id"` + UserName string `db:"user_name"` + Email string `db:"email"` + Name nulls.String `db:"name"` + Alive nulls.Bool `db:"alive"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + BirthDate nulls.Time `db:"birth_date"` + Bio nulls.String `db:"bio"` + Price nulls.Float64 `db:"price"` + FullName nulls.String `db:"full_name" select:"name as full_name"` + + AssocFields + } + + count, _ := tx.Count(&User{}) + user := User{ + UserName: "dumb-dumb", + Name: nulls.NewString("Arthur Dent"), + AssocFields: AssocFields{ + Books: Books{{Title: "The Hitchhiker's Guide to the Galaxy", Description: "Comedy Science Fiction somewhere in Space", Isbn: "PB42"}}, + FavoriteSong: Song{Title: "Wish You Were Here", ComposedBy: Composer{Name: "Pink Floyd"}}, + Houses: Addresses{ + Address{HouseNumber: 155, Street: "Country Lane"}, + }, + }, + } + + err := tx.Eager().Create(&user) + r.NoError(err) + r.NotZero(user.ID) + + ctx, _ := tx.Count(&User{}) + r.Equal(count+1, ctx) + + ctx, _ = tx.Count(&Book{}) + r.Equal(count+1, ctx) + + ctx, _ = tx.Count(&Song{}) + r.Equal(count+1, ctx) + + ctx, _ = tx.Count(&Address{}) + r.Equal(count+1, ctx) + + u := User{} + q := tx.Eager().Where("name = ?", user.Name.String) + err = q.First(&u) + r.NoError(err) + + r.Equal(user.Name.String, u.Name.String) + r.Len(u.Books, 1) + r.Equal(user.Books[0].Title, u.Books[0].Title) + r.Equal(user.FavoriteSong.Title, u.FavoriteSong.Title) + r.Len(u.Houses, 1) + r.Equal(user.Houses[0].Street, u.Houses[0].Street) + }) +} + +func Test_Eager_Embedded_Ptr_Struct(t *testing.T) { + if PDB == nil { + t.Skip("skipping integration tests") + } + transaction(func(tx *Connection) { + r := require.New(t) + + type AssocFields struct { + Books Books `has_many:"books" order_by:"title asc"` + FavoriteSong Song `has_one:"song" fk_id:"u_id"` + Houses Addresses `many_to_many:"users_addresses"` + } + + type User struct { + ID int `db:"id"` + UserName string `db:"user_name"` + Email string `db:"email"` + Name nulls.String `db:"name"` + Alive nulls.Bool `db:"alive"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + BirthDate nulls.Time `db:"birth_date"` + Bio nulls.String `db:"bio"` + Price nulls.Float64 `db:"price"` + FullName nulls.String `db:"full_name" select:"name as full_name"` + + *AssocFields + } + + count, _ := tx.Count(&User{}) + user := User{ + UserName: "dumb-dumb", + Name: nulls.NewString("Arthur Dent"), + AssocFields: &AssocFields{ + Books: Books{{Title: "The Hitchhiker's Guide to the Galaxy", Description: "Comedy Science Fiction somewhere in Space", Isbn: "PB42"}}, + FavoriteSong: Song{Title: "Wish You Were Here", ComposedBy: Composer{Name: "Pink Floyd"}}, + Houses: Addresses{ + Address{HouseNumber: 155, Street: "Country Lane"}, + }, + }, + } + + err := tx.Eager().Create(&user) + r.NoError(err) + r.NotZero(user.ID) + + ctx, _ := tx.Count(&User{}) + r.Equal(count+1, ctx) + + ctx, _ = tx.Count(&Book{}) + r.Equal(count+1, ctx) + + ctx, _ = tx.Count(&Song{}) + r.Equal(count+1, ctx) + + ctx, _ = tx.Count(&Address{}) + r.Equal(count+1, ctx) + + u := User{} + q := tx.Eager().Where("name = ?", user.Name.String) + err = q.First(&u) + r.NoError(err) + + r.Equal(user.Name.String, u.Name.String) + r.Len(u.Books, 1) + r.Equal(user.Books[0].Title, u.Books[0].Title) + r.Equal(user.FavoriteSong.Title, u.FavoriteSong.Title) + r.Len(u.Houses, 1) + r.Equal(user.Houses[0].Street, u.Houses[0].Street) + }) +} + func Test_Create_UUID(t *testing.T) { if PDB == nil { t.Skip("skipping integration tests") @@ -1349,6 +1500,107 @@ func Test_UpdateColumns(t *testing.T) { }) } +func Test_UpdateQuery_NoUpdatedAt(t *testing.T) { + if PDB == nil { + t.Skip("skipping integration tests") + } + transaction(func(tx *Connection) { + r := require.New(t) + r.NoError(PDB.Create(&NonStandardID{OutfacingID: "must-change"})) + count, err := PDB.Where("true").UpdateQuery(&NonStandardID{OutfacingID: "has-changed"}, "id") + r.NoError(err) + r.Equal(int64(1), count) + entity := NonStandardID{} + r.NoError(PDB.First(&entity)) + r.Equal("has-changed", entity.OutfacingID) + }) +} + +func Test_UpdateQuery_NoTransaction(t *testing.T) { + if PDB == nil { + t.Skip("skipping integration tests") + } + + r := require.New(t) + u1 := User{Name: nulls.NewString("Foo"), Bio: nulls.NewString("must-not-change-1")} + r.NoError(PDB.Create(&u1)) + r.NoError(PDB.Reload(&u1)) + count, err := PDB.Where("name = ?", "Nemo").UpdateQuery(&User{Bio: nulls.NewString("did-change")}, "bio") + r.NoError(err) + require.Equal(t, int64(0), count) + + count, err = PDB.Where("name = ?", "Foo").UpdateQuery(&User{Name: nulls.NewString("Bar")}, "name") + r.NoError(err) + r.Equal(int64(1), count) + + require.NoError(t, PDB.Destroy(&u1)) +} + +func Test_UpdateQuery(t *testing.T) { + if PDB == nil { + t.Skip("skipping integration tests") + } + transaction(func(tx *Connection) { + r := require.New(t) + + u1 := User{Name: nulls.NewString("Foo"), Bio: nulls.NewString("must-not-change-1")} + u2 := User{Name: nulls.NewString("Foo"), Bio: nulls.NewString("must-not-change-2")} + u3 := User{Name: nulls.NewString("Baz"), Bio: nulls.NewString("must-not-change-3")} + tx.Create(&u1) + tx.Create(&u2) + tx.Create(&u3) + r.NoError(tx.Reload(&u1)) + r.NoError(tx.Reload(&u2)) + r.NoError(tx.Reload(&u3)) + time.Sleep(time.Millisecond * 1) + + // No affected rows + count, err := tx.Where("name = ?", "Nemo").UpdateQuery(&User{Bio: nulls.NewString("did-change")}, "bio") + r.NoError(err) + require.Equal(t, int64(0), count) + mustUnchanged := &User{} + r.NoError(tx.Find(mustUnchanged, u1.ID)) + r.Equal(u1.Bio, mustUnchanged.Bio) + r.Equal(u1.UpdatedAt, mustUnchanged.UpdatedAt) + + // Correct rows are updated, including updated_at + count, err = tx.Where("name = ?", "Foo").UpdateQuery(&User{Name: nulls.NewString("Bar")}, "name") + r.NoError(err) + r.Equal(int64(2), count) + + u1b, u2b, u3b := &User{}, &User{}, &User{} + r.NoError(tx.Find(u1b, u1.ID)) + r.NoError(tx.Find(u2b, u2.ID)) + r.NoError(tx.Find(u3b, u3.ID)) + r.Equal(u1b.Name.String, "Bar") + r.Equal(u2b.Name.String, "Bar") + r.Equal(u3b.Name.String, "Baz") + r.Equal(u1b.Bio.String, "must-not-change-1") + r.Equal(u2b.Bio.String, "must-not-change-2") + r.Equal(u3b.Bio.String, "must-not-change-3") + if tx.Dialect.Name() != nameMySQL { // MySQL timestamps are in seconds + r.NotEqual(u1.UpdatedAt, u1b.UpdatedAt) + r.NotEqual(u2.UpdatedAt, u2b.UpdatedAt) + } + r.Equal(u3.UpdatedAt, u3b.UpdatedAt) + + // ID is ignored + count, err = tx.Where("true").UpdateQuery(&User{ID: 123, Name: nulls.NewString("Bar")}, "id", "name") + r.NoError(tx.Find(u1b, u1.ID)) + r.NoError(tx.Find(u2b, u2.ID)) + r.NoError(tx.Find(u3b, u3.ID)) + r.Equal(u1b.Name.String, "Bar") + r.Equal(u2b.Name.String, "Bar") + r.Equal(u3b.Name.String, "Bar") + + // Invalid column yields an error + count, err = tx.Where("name = ?", "Foo").UpdateQuery(&User{Name: nulls.NewString("Bar")}, "mistake") + r.Contains(err.Error(), "could not find name mistake") + + tx.Where("true").Delete(&User{}) + }) +} + func Test_UpdateColumns_UpdatedAt(t *testing.T) { if PDB == nil { t.Skip("skipping integration tests") @@ -1712,3 +1964,28 @@ func Test_Delete(t *testing.T) { r.Equal(count, ctx) }) } + +func Test_Create_Timestamps_With_NowFunc(t *testing.T) { + if PDB == nil { + t.Skip("skipping integration tests") + } + transaction(func(tx *Connection) { + r := require.New(t) + + originalNowFunc := nowFunc + // ensure the original function is restored + defer func() { + nowFunc = originalNowFunc + }() + + fakeNow, _ := time.Parse(time.RFC3339, "2019-07-14T00:00:00Z") + SetNowFunc(func() time.Time { return fakeNow }) + + friend := Friend{FirstName: "Yester", LastName: "Day"} + err := tx.Create(&friend) + r.NoError(err) + + r.Equal(fakeNow, friend.CreatedAt) + r.Equal(fakeNow, friend.UpdatedAt) + }) +} diff --git a/model.go b/model.go index dddbec66..7b03d78f 100644 --- a/model.go +++ b/model.go @@ -15,6 +15,11 @@ import ( var nowFunc = time.Now +// SetNowFunc allows an override of time.Now for customizing CreatedAt/UpdatedAt +func SetNowFunc(f func() time.Time) { + nowFunc = f +} + // Value is the contents of a `Model`. type Value interface{} diff --git a/pop_test.go b/pop_test.go index 629ea35a..95fd8648 100644 --- a/pop_test.go +++ b/pop_test.go @@ -52,7 +52,7 @@ func init() { dialect := os.Getenv("SODA_DIALECT") if dialect == "" { - log(logging.Info, "Skipping integration tests") + log(logging.Info, "Skipping integration tests because SODA_DIALECT is blank or unset") return }