Repository: gobuffalo/pop
Pull request #716 (2022-05-15 02:15:10)

Latest Development


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4eb7821..d9ca426 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/.gitignore b/.gitignore
index d587321..74d9ca9 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 f22cfd3..5588bc8 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 893e46e..24d4eab 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 bdd7e18..529b210 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 1b87847..0e49b16 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 16d4d96..5fb7e32 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 d087629..5b1d76f 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 1cfda3c..7b5022b 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 fe4d023..6ce6156 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 1ac8691..d8400a5 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 0c51baa..0000000
--- 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 0000000..286dd78
--- /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 54b9098..2201bec 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 04478f6..37b03b3 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/model.go b/model.go
index dddbec6..7b03d78 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{}
 


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4e98acf..d3f9c48 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/executors_test.go b/executors_test.go
index d428e36..d96f250 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/pop_test.go b/pop_test.go
index 629ea35..95fd864 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
 	}