diff --git a/README.md b/README.md index 6d6b9f7..5d694c7 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,62 @@ You must use Go 1.7 or newer because it uses the http context. ## Quick Start with MySQL -Use one of the following commands to start a MySQL container with Docker: +Use the following commands to start a MySQL container with Docker: -- Start MySQL without a password: `docker run -d -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes mysql:5.7` -- Start MySQL with a password: `docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=somepassword mysql:5.7` +```bash +# Start MySQL without a password. +docker run -d --name=mysql57 -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes mysql:5.7 +# or start MySQL with a password. +docker run -d --name=mysql57 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=somepassword mysql:5.7 + +# Create the database via docker exec. +docker exec mysql57 sh -c 'exec mysql -uroot -e "CREATE DATABASE IF NOT EXISTS webapi DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;"' +# Or create the database manually. +CREATE DATABASE webapi DEFAULT CHARSET = utf8 COLLATE = utf8_unicode_ci; + +# CD to the CLI tool. +cd src/app/webapi/cmd/cliapp + +# Build the CLI tool. +go build + +# Apply the database migrations without a password. +DB_USERNAME=root DB_HOSTNAME=127.0.0.1 DB_PORT=3306 DB_DATABASE=webapi ./cliapp migrate all ../../../../../migration/mysql-v0.sql +# or apply the database migrations with a password. +DB_USERNAME=root DB_PASSWORD=somepassword DB_HOSTNAME=127.0.0.1 DB_PORT=3306 DB_DATABASE=webapi ./cliapp migrate all ../../../../../migration/mysql-v0.sql +``` + +Using the database connection information above, follow the steps to set up the `config.json` file: + +```bash +# Copy the config.json from the root of the project to the CLI app folder. +cp config.json src/app/webapi/cmd/webapi/config.json -Start MySQL and import `migration/mysql.sql` to create the database and tables. +# Edit the `Database` section so the connection information matches your MySQL instance. +# The database password is read from the `config.json` file, but is overwritten by the environment variable, `DB_PASSWORD`, if it is set. -Copy `config.json` to `src/app/webapi/cmd/webapi/config.json` and edit the **Database** section so the connection information matches your MySQL instance. Also add a base64 encoded `JWT.Secret` to the config. You can generate it using the command line app in the repo - run these commands: -- `cd src/app/webapi/cmd/cliapp` -- `go run cliapp.go generate` +# Generate a base64 encoded secret. +./cliapp generate + +# Add the encoded secret above to the `JWT.Secret` section of the config. +``` + +Now you can start the API. + +```bash +# CD to the webapi app folder. +cd src/app/webapi/cmd/webapi -The database password is read from the `config.json` first, but is overwritten by the environment variable, `DB_PASSWORD`, if it is set. +# Build the app. +go build -Build and run from the root directory. Open your REST client to: http://localhost/v1. You should see the **welcome** message and status **OK**. +# Run the app. +./webapi + +# Open your browser to this URL to see the **welcome** message and status **OK**: http://localhost/v1 +``` + +To interact with the API, open your favorite REST client. You'll need to authenticate with at http://localhost/v1/auth before you can use any of the user endpoints. Once you have a token, add it to the request header with a name of `Authorization` and with a value of `Bearer {TOKEN HERE}`. To create a user, send a POST request to http://localhost/v1/user with the following fields: first_name, last_name, email, and password. @@ -312,7 +354,20 @@ func (p *Endpoint) Index(w http.ResponseWriter, r *http.Request) (int, error) { You can disable logging on the server by setting an environment variable: `WEBAPI_LOG_LEVEL=none` -## Test Coverage +## Testing + +All the tests use a database called: `webapitest`. The quickest way to get it set up is: + +```bash +# Launch MySQL in docker container. +docker run -d --name=mysql57 -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes mysql:5.7 + +# Create the database via docker exec. +docker exec mysql57 sh -c 'exec mysql -uroot -e "CREATE DATABASE IF NOT EXISTS webapitest DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;"' + +# Or create the database manually. +CREATE DATABASE webapitest DEFAULT CHARSET = utf8 COLLATE = utf8_unicode_ci; +``` You can use these commands to run tests: diff --git a/migration/tables-only.sql b/migration/mysql-v0.sql similarity index 76% rename from migration/tables-only.sql rename to migration/mysql-v0.sql index 9db5064..6526d0a 100644 --- a/migration/tables-only.sql +++ b/migration/mysql-v0.sql @@ -1,10 +1,6 @@ -SET NAMES utf8 COLLATE 'utf8_unicode_ci'; -SET foreign_key_checks = 1; -SET time_zone = '+00:00'; +--changeset josephspurrier:1 SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; -SET CHARACTER SET utf8; - -CREATE TABLE IF NOT EXISTS user_status ( +CREATE TABLE user_status ( id TINYINT(1) UNSIGNED NOT NULL AUTO_INCREMENT, status VARCHAR(25) NOT NULL, @@ -15,8 +11,17 @@ CREATE TABLE IF NOT EXISTS user_status ( PRIMARY KEY (id) ); +--rollback DROP TABLE user_status; + +--changeset josephspurrier:2 +INSERT INTO `user_status` (`id`, `status`, `created_at`, `updated_at`, `deleted`) VALUES +(1, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0), +(2, 'inactive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0); +--rollback TRUNCATE TABLE user_status; -CREATE TABLE IF NOT EXISTS user ( +--changeset josephspurrier:3 +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; +CREATE TABLE user ( id VARCHAR(36) NOT NULL, first_name VARCHAR(50) NOT NULL, @@ -35,7 +40,4 @@ CREATE TABLE IF NOT EXISTS user ( PRIMARY KEY (id) ); - -INSERT INTO `user_status` (`id`, `status`, `created_at`, `updated_at`, `deleted`) VALUES -(1, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0), -(2, 'inactive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0); \ No newline at end of file +--rollback DROP TABLE user; \ No newline at end of file diff --git a/src/app/webapi/cmd/cliapp/cliapp.go b/src/app/webapi/cmd/cliapp/cliapp.go index 6b9d327..473a4c5 100644 --- a/src/app/webapi/cmd/cliapp/cliapp.go +++ b/src/app/webapi/cmd/cliapp/cliapp.go @@ -3,9 +3,9 @@ package main import ( "encoding/base64" "fmt" - "log" "os" + "app/webapi/internal/basemigrate" "app/webapi/pkg/securegen" kingpin "gopkg.in/alecthomas/kingpin.v2" @@ -14,7 +14,22 @@ import ( var ( app = kingpin.New("cliapp", "A command-line application to perform tasks for the webapi.") - cGenerate = app.Command("generate", "Generate 256 bit (32 byte) base64 encoded JWT.") + cGenerate = app.Command("generate", "Generate 256 bit (32 byte) base64 encoded JWT.") + cDB = app.Command("migrate", "Perform actions on the database.") + cDBPrefix = cDB.Flag("envprefix", "Prefix for environment variables.").String() + cDBAll = cDB.Command("all", "Apply all changesets to the database.") + cDBAllFile = cDBAll.Arg("file", "Filename of the migration file [string].").Required().String() + + cDBUp = cDB.Command("up", "Apply a specific number of changesets to the database.") + cDBUpCount = cDBUp.Arg("count", "Number of changesets [int].").Required().Int() + cDBUpFile = cDBUp.Arg("file", "Filename of the migration file [string].").Required().String() + + cDBReset = cDB.Command("reset", "Run all rollbacks on the database.") + cDBResetFile = cDBReset.Arg("file", "Filename of the migration file [string].").Required().String() + + cDBDown = cDB.Command("down", "Apply a specific number of rollbacks to the database.") + cDBDownCount = cDBDown.Arg("count", "Number of rollbacks [int].").Required().Int() + cDBDownFile = cDBDown.Arg("file", "Filename of the migration file [string].").Required().String() ) func main() { @@ -25,10 +40,36 @@ func main() { case cGenerate.FullCommand(): b, err := securegen.Bytes(32) if err != nil { - log.Fatal(err) + fmt.Println(err) + os.Exit(1) } enc := base64.StdEncoding.EncodeToString(b) fmt.Println(enc) + case cDBAll.FullCommand(): + err := basemigrate.Migrate(*cDBAllFile, *cDBPrefix, 0, true) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + case cDBUp.FullCommand(): + err := basemigrate.Migrate(*cDBUpFile, *cDBPrefix, *cDBUpCount, true) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + case cDBReset.FullCommand(): + err := basemigrate.Reset(*cDBResetFile, *cDBPrefix, 0, true) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + case cDBDown.FullCommand(): + err := basemigrate.Reset(*cDBDownFile, *cDBPrefix, *cDBDownCount, true) + if err != nil { + fmt.Println(err) + os.Exit(1) + } } } diff --git a/src/app/webapi/cmd/cliapp/cliapp_test.go b/src/app/webapi/cmd/cliapp/cliapp_test.go index 774d466..ef31b22 100644 --- a/src/app/webapi/cmd/cliapp/cliapp_test.go +++ b/src/app/webapi/cmd/cliapp/cliapp_test.go @@ -6,6 +6,9 @@ import ( "os" "testing" + "app/webapi/internal/testutil" + "app/webapi/pkg/database" + "github.com/stretchr/testify/assert" ) @@ -37,3 +40,163 @@ func TestGenerate(t *testing.T) { // Ensure the length is 32 bytes. assert.Equal(t, 32, len(s)) } + +func TestMigrationAll(t *testing.T) { + _, unique := migrateAll(t) + testutil.TeardownDatabase(unique) +} + +func migrateAll(t *testing.T) (*database.DBW, string) { + db, unique := testutil.SetupDatabase() + + // Set the arguments. + os.Args = make([]string, 6) + os.Args[0] = "cliapp" + os.Args[1] = "migrate" + os.Args[2] = "all" + os.Args[3] = "testdata/success.sql" + os.Args[4] = "--envprefix" + os.Args[5] = unique + + // Redirect stdout. + backupd := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the application. + main() + + // Get the output. + w.Close() + out, err := ioutil.ReadAll(r) + assert.Nil(t, err) + os.Stdout = backupd + + assert.Contains(t, string(out), "Changeset applied") + + // Count the records. + rows := 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 3, rows) + + return db, unique +} + +func TestMigrationReset(t *testing.T) { + db, unique := migrateAll(t) + + // Set the arguments. + os.Args = make([]string, 6) + os.Args[0] = "cliapp" + os.Args[1] = "migrate" + os.Args[2] = "reset" + os.Args[3] = "testdata/success.sql" + os.Args[4] = "--envprefix" + os.Args[5] = unique + + // Redirect stdout. + backupd := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the application. + main() + + // Get the output. + w.Close() + out, err := ioutil.ReadAll(r) + assert.Nil(t, err) + os.Stdout = backupd + + assert.Contains(t, string(out), "Rollback applied") + + // Count the records. + rows := 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 0, rows) + + testutil.TeardownDatabase(unique) +} + +func TestMigrationUp(t *testing.T) { + _, unique := migrateUp(t) + testutil.TeardownDatabase(unique) +} + +func migrateUp(t *testing.T) (*database.DBW, string) { + db, unique := testutil.SetupDatabase() + + // Set the arguments. + os.Args = make([]string, 7) + os.Args[0] = "cliapp" + os.Args[1] = "migrate" + os.Args[2] = "up" + os.Args[3] = "2" + os.Args[4] = "testdata/success.sql" + os.Args[5] = "--envprefix" + os.Args[6] = unique + + // Redirect stdout. + backupd := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the application. + main() + + // Get the output. + w.Close() + out, err := ioutil.ReadAll(r) + assert.Nil(t, err) + os.Stdout = backupd + + assert.Contains(t, string(out), "Changeset applied") + + // Count the records. + rows := 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 2, rows) + + return db, unique +} + +func TestMigrationDown(t *testing.T) { + db, unique := migrateUp(t) + + // Set the arguments. + os.Args = make([]string, 7) + os.Args[0] = "cliapp" + os.Args[1] = "migrate" + os.Args[2] = "down" + os.Args[3] = "1" + os.Args[4] = "testdata/success.sql" + os.Args[5] = "--envprefix" + os.Args[6] = unique + + // Redirect stdout. + backupd := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the application. + main() + + // Get the output. + w.Close() + out, err := ioutil.ReadAll(r) + assert.Nil(t, err) + os.Stdout = backupd + + assert.Contains(t, string(out), "Rollback applied") + + // Count the records. + rows := 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 1, rows) + + testutil.TeardownDatabase(unique) +} diff --git a/migration/mysql.sql b/src/app/webapi/cmd/cliapp/testdata/success.sql similarity index 52% rename from migration/mysql.sql rename to src/app/webapi/cmd/cliapp/testdata/success.sql index 7a2cba0..6526d0a 100644 --- a/migration/mysql.sql +++ b/src/app/webapi/cmd/cliapp/testdata/success.sql @@ -1,27 +1,5 @@ -/* ***************************************************************************** -// Setup the preferences -// ****************************************************************************/ -SET NAMES utf8 COLLATE 'utf8_unicode_ci'; -SET foreign_key_checks = 1; -SET time_zone = '+00:00'; +--changeset josephspurrier:1 SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; -SET CHARACTER SET utf8; - -/* ***************************************************************************** -// Remove old database -// ****************************************************************************/ -DROP DATABASE IF EXISTS webapi; - -/* ***************************************************************************** -// Create new database -// ****************************************************************************/ -CREATE DATABASE webapi DEFAULT CHARSET = utf8 COLLATE = utf8_unicode_ci; -USE webapi; - -/* ***************************************************************************** -// Create the tables -// ****************************************************************************/ - CREATE TABLE user_status ( id TINYINT(1) UNSIGNED NOT NULL AUTO_INCREMENT, @@ -33,7 +11,16 @@ CREATE TABLE user_status ( PRIMARY KEY (id) ); +--rollback DROP TABLE user_status; +--changeset josephspurrier:2 +INSERT INTO `user_status` (`id`, `status`, `created_at`, `updated_at`, `deleted`) VALUES +(1, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0), +(2, 'inactive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0); +--rollback TRUNCATE TABLE user_status; + +--changeset josephspurrier:3 +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; CREATE TABLE user ( id VARCHAR(36) NOT NULL, @@ -53,7 +40,4 @@ CREATE TABLE user ( PRIMARY KEY (id) ); - -INSERT INTO `user_status` (`id`, `status`, `created_at`, `updated_at`, `deleted`) VALUES -(1, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0), -(2, 'inactive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0); \ No newline at end of file +--rollback DROP TABLE user; \ No newline at end of file diff --git a/src/app/webapi/cmd/hooks/main.go b/src/app/webapi/cmd/hooks/main.go index b42a112..0f1466b 100644 --- a/src/app/webapi/cmd/hooks/main.go +++ b/src/app/webapi/cmd/hooks/main.go @@ -88,9 +88,9 @@ func main() { // Set the Authorization header. t.Request.Headers["Authorization"] = "Bearer " + token - // Load the tables. - testutil.LoadDatabaseFromFile("../../../migration/tables-only.sql") - core, _ := component.NewCoreMock() + // Load the database with test data. + db, _ := testutil.LoadDatabaseFromFile("../../../migration/mysql-v0.sql", false) + core, _ := component.NewCoreMock(db) mux := router.New() user.New(core).Routes(mux) @@ -118,6 +118,8 @@ func main() { if t.Request.URI == "/v1/user/USERID" { t.FullPath = "/v1/user/" + id1 } + + //testutil.TeardownDatabase(unique) }) if false { diff --git a/src/app/webapi/component/auth/index_test.go b/src/app/webapi/component/auth/index_test.go index 51f8bfc..4071f38 100644 --- a/src/app/webapi/component/auth/index_test.go +++ b/src/app/webapi/component/auth/index_test.go @@ -10,13 +10,15 @@ import ( "app/webapi/component" "app/webapi/internal/testrequest" + "app/webapi/internal/testutil" "app/webapi/model" "github.com/stretchr/testify/assert" ) func TestIndex(t *testing.T) { - core, m := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, m := component.NewCoreMock(db) m.Token.GenerateFunc = func(userID string, duration time.Duration) (string, error) { b := []byte("0123456789ABCDEF0123456789ABCDEF") @@ -33,10 +35,13 @@ func TestIndex(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "OK", r.Body.Status) assert.Equal(t, "MDEyMzQ1Njc4OUFCQ0RFRjAxMjM0NTY3ODlBQkNERUY=", r.Body.Data.Token) + + testutil.TeardownDatabase(unique) } func TestIndexError(t *testing.T) { - core, m := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, m := component.NewCoreMock(db) m.Token.GenerateFunc = func(userID string, duration time.Duration) (string, error) { return "", errors.New("generate error") @@ -51,4 +56,6 @@ func TestIndexError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Equal(t, "Internal Server Error", r.Body.Status) assert.Equal(t, "generate error", r.Body.Message) + + testutil.TeardownDatabase(unique) } diff --git a/src/app/webapi/component/core_mock.go b/src/app/webapi/component/core_mock.go index bbcb10b..829a2bc 100644 --- a/src/app/webapi/component/core_mock.go +++ b/src/app/webapi/component/core_mock.go @@ -4,22 +4,22 @@ import ( "app/webapi/internal/bind" "app/webapi/internal/response" "app/webapi/internal/testutil" + "app/webapi/pkg/database" "app/webapi/pkg/query" ) // NewCoreMock returns all mocked dependencies. -func NewCoreMock() (Core, *CoreMock) { +func NewCoreMock(db *database.DBW) (Core, *CoreMock) { ml := new(testutil.MockLogger) - md := testutil.ConnectDatabase(true) - mq := query.New(md) + mq := query.New(db) mt := new(testutil.MockToken) resp := response.New() binder := bind.New() - core := NewCore(ml, md, mq, binder, resp, mt) + core := NewCore(ml, db, mq, binder, resp, mt) m := &CoreMock{ Log: ml, - DB: md, + DB: db, Q: mq, Bind: binder, Response: resp, diff --git a/src/app/webapi/component/root/index_test.go b/src/app/webapi/component/root/index_test.go index 64abbdf..2280f79 100644 --- a/src/app/webapi/component/root/index_test.go +++ b/src/app/webapi/component/root/index_test.go @@ -7,13 +7,15 @@ import ( "app/webapi/component" "app/webapi/internal/testrequest" + "app/webapi/internal/testutil" "app/webapi/model" "github.com/stretchr/testify/assert" ) func TestIndex(t *testing.T) { - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) w := testrequest.SendForm(t, core, "GET", "/v1", nil) @@ -24,4 +26,6 @@ func TestIndex(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "OK", r.Body.Status) assert.Equal(t, "hello", r.Body.Message) + + testutil.TeardownDatabase(unique) } diff --git a/src/app/webapi/component/user/create.go b/src/app/webapi/component/user/create.go index 1cac7d0..f0f58a4 100644 --- a/src/app/webapi/component/user/create.go +++ b/src/app/webapi/component/user/create.go @@ -48,7 +48,7 @@ func (p *Endpoint) Create(w http.ResponseWriter, r *http.Request) (int, error) { // Create the DB store. u := store.NewUser(p.DB, p.Q) - // Check for existing user. + // Check for existing item. exists, _, err := u.ExistsByField(u, "email", req.Email) if err != nil { return http.StatusInternalServerError, err diff --git a/src/app/webapi/component/user/create_test.go b/src/app/webapi/component/user/create_test.go index 672cf8a..392f0de 100644 --- a/src/app/webapi/component/user/create_test.go +++ b/src/app/webapi/component/user/create_test.go @@ -17,8 +17,8 @@ import ( ) func TestCreate(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) form := url.Values{} form.Add("first_name", "John") @@ -35,11 +35,13 @@ func TestCreate(t *testing.T) { assert.Equal(t, http.StatusCreated, w.Code) assert.Equal(t, "Created", r.Body.Status) assert.Equal(t, 36, len(r.Body.RecordID)) + + testutil.TeardownDatabase(unique) } func TestCreateUserAlreadyExists(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) u := store.NewUser(core.DB, core.Q) _, err := u.Create("John", "Smith", "jsmith@example.com", "password") @@ -60,11 +62,13 @@ func TestCreateUserAlreadyExists(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, "Bad Request", r.Body.Status) assert.Equal(t, "user already exists", r.Body.Message) + + testutil.TeardownDatabase(unique) } func TestCreateBadEmail(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) form := url.Values{} form.Add("first_name", "John") @@ -81,19 +85,23 @@ func TestCreateBadEmail(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, "Bad Request", r.Body.Status) assert.Contains(t, w.Body.String(), `failed`) + + testutil.TeardownDatabase(unique) } func TestCreateValidation(t *testing.T) { for _, v := range []string{ "POST /v1/user", } { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) arr := strings.Split(v, " ") w := testrequest.SendForm(t, core, arr[0], arr[1], nil) assert.Equal(t, http.StatusBadRequest, w.Code) + + testutil.TeardownDatabase(unique) } } diff --git a/src/app/webapi/component/user/destroy_all_test.go b/src/app/webapi/component/user/destroy_all_test.go index 4960987..1723173 100644 --- a/src/app/webapi/component/user/destroy_all_test.go +++ b/src/app/webapi/component/user/destroy_all_test.go @@ -1,7 +1,6 @@ package user_test import ( - "app/webapi/model" "encoding/json" "net/http" "strings" @@ -10,14 +9,15 @@ import ( "app/webapi/component" "app/webapi/internal/testrequest" "app/webapi/internal/testutil" + "app/webapi/model" "app/webapi/store" "github.com/stretchr/testify/assert" ) func TestDestroyAll(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) u := store.NewUser(core.DB, core.Q) _, err := u.Create("John", "Smith", "jsmith@example.com", "password") @@ -32,11 +32,13 @@ func TestDestroyAll(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "OK", r.Body.Status) assert.Equal(t, "users deleted", r.Body.Message) + + testutil.TeardownDatabase(unique) } func TestDestroyAllNoUsers(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) w := testrequest.SendForm(t, core, "DELETE", "/v1/user", nil) @@ -47,19 +49,23 @@ func TestDestroyAllNoUsers(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, "Bad Request", r.Body.Status) assert.Equal(t, "no users to delete", r.Body.Message) + + testutil.TeardownDatabase(unique) } func TestDestroyValidation(t *testing.T) { for _, v := range []string{ "DELETE /v1/user/1", } { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) arr := strings.Split(v, " ") w := testrequest.SendForm(t, core, arr[0], arr[1], nil) assert.Equal(t, http.StatusBadRequest, w.Code) + + testutil.TeardownDatabase(unique) } } diff --git a/src/app/webapi/component/user/destroy_test.go b/src/app/webapi/component/user/destroy_test.go index da14772..3f045be 100644 --- a/src/app/webapi/component/user/destroy_test.go +++ b/src/app/webapi/component/user/destroy_test.go @@ -16,8 +16,8 @@ import ( ) func TestDestroy(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) u := store.NewUser(core.DB, core.Q) ID, err := u.Create("John", "Smith", "jsmith@example.com", "password") @@ -42,4 +42,6 @@ func TestDestroy(t *testing.T) { found, err := u.FindOneByID(u, ID) assert.Nil(t, err) assert.False(t, found) + + testutil.TeardownDatabase(unique) } diff --git a/src/app/webapi/component/user/index.go b/src/app/webapi/component/user/index.go index 8a16b7f..35aa4b4 100644 --- a/src/app/webapi/component/user/index.go +++ b/src/app/webapi/component/user/index.go @@ -1,10 +1,10 @@ package user import ( - "app/webapi/pkg/structcopy" "net/http" "app/webapi/model" + "app/webapi/pkg/structcopy" "app/webapi/store" ) diff --git a/src/app/webapi/component/user/index_test.go b/src/app/webapi/component/user/index_test.go index b3f1ec3..7524b25 100644 --- a/src/app/webapi/component/user/index_test.go +++ b/src/app/webapi/component/user/index_test.go @@ -15,8 +15,8 @@ import ( ) func TestIndexEmpty(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) w := testrequest.SendForm(t, core, "GET", "/v1/user", nil) @@ -27,11 +27,13 @@ func TestIndexEmpty(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "OK", r.Body.Status) assert.Equal(t, 0, len(r.Body.Data)) + + testutil.TeardownDatabase(unique) } func TestIndexOne(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) u := store.NewUser(core.DB, core.Q) _, err := u.Create("John", "Smith", "jsmith@example.com", "password") @@ -48,4 +50,6 @@ func TestIndexOne(t *testing.T) { assert.Equal(t, "John", r.Body.Data[0].FirstName) assert.Equal(t, "Smith", r.Body.Data[0].LastName) assert.Equal(t, "jsmith@example.com", r.Body.Data[0].Email) + + testutil.TeardownDatabase(unique) } diff --git a/src/app/webapi/component/user/show.go b/src/app/webapi/component/user/show.go index 2a132a5..61765dd 100644 --- a/src/app/webapi/component/user/show.go +++ b/src/app/webapi/component/user/show.go @@ -45,7 +45,7 @@ func (p *Endpoint) Show(w http.ResponseWriter, r *http.Request) (int, error) { // Create the DB store. u := store.NewUser(p.DB, p.Q) - // Get a user. + // Get an item by ID. exists, err := u.FindOneByID(u, req.UserID) if err != nil { return http.StatusInternalServerError, err diff --git a/src/app/webapi/component/user/show_test.go b/src/app/webapi/component/user/show_test.go index d498bf1..20c919d 100644 --- a/src/app/webapi/component/user/show_test.go +++ b/src/app/webapi/component/user/show_test.go @@ -15,8 +15,8 @@ import ( ) func TestShowOne(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) u := store.NewUser(core.DB, core.Q) ID, err := u.Create("John", "Smith", "jsmith@example.com", "password") @@ -34,11 +34,13 @@ func TestShowOne(t *testing.T) { assert.Equal(t, "John", r.Body.Data[0].FirstName) assert.Equal(t, "Smith", r.Body.Data[0].LastName) assert.Equal(t, "jsmith@example.com", r.Body.Data[0].Email) + + testutil.TeardownDatabase(unique) } func TestShowNotFound(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) w := testrequest.SendForm(t, core, "GET", "/v1/user/1", nil) @@ -49,4 +51,6 @@ func TestShowNotFound(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, "Bad Request", r.Body.Status) assert.Equal(t, "user not found", r.Body.Message) + + testutil.TeardownDatabase(unique) } diff --git a/src/app/webapi/component/user/update.go b/src/app/webapi/component/user/update.go index 061ea4d..f71850d 100644 --- a/src/app/webapi/component/user/update.go +++ b/src/app/webapi/component/user/update.go @@ -51,7 +51,7 @@ func (p *Endpoint) Update(w http.ResponseWriter, r *http.Request) (int, error) { // Create the DB store. u := store.NewUser(p.DB, p.Q) - // Determine if the user exists. + // Determine if the item exists. exists, err := u.ExistsByID(u, req.UserID) if err != nil { return http.StatusInternalServerError, err diff --git a/src/app/webapi/component/user/update_test.go b/src/app/webapi/component/user/update_test.go index 4d2c171..ba548db 100644 --- a/src/app/webapi/component/user/update_test.go +++ b/src/app/webapi/component/user/update_test.go @@ -16,8 +16,8 @@ import ( ) func TestUpdateUserAllFields(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) u := store.NewUser(core.DB, core.Q) ID, err := u.Create("John", "Smith", "jsmith@example.com", "password") @@ -46,11 +46,13 @@ func TestUpdateUserAllFields(t *testing.T) { assert.Equal(t, "Smith2", u.LastName) assert.Equal(t, "jsmith3@example.com", u.Email) assert.Equal(t, "password4", u.Password) + + testutil.TeardownDatabase(unique) } func TestUpdateMissingFields(t *testing.T) { - testutil.LoadDatabase(t) - core, _ := component.NewCoreMock() + db, unique := testutil.LoadDatabase() + core, _ := component.NewCoreMock(db) u := store.NewUser(core.DB, core.Q) ID, err := u.Create("John", "Smith", "jsmith@example.com", "password") @@ -76,4 +78,6 @@ func TestUpdateMissingFields(t *testing.T) { assert.Equal(t, "Smith", u.LastName) assert.Equal(t, "jsmith@example.com", u.Email) assert.Equal(t, "password", u.Password) + + testutil.TeardownDatabase(unique) } diff --git a/src/app/webapi/internal/basemigrate/basemigrate.go b/src/app/webapi/internal/basemigrate/basemigrate.go new file mode 100644 index 0000000..d0421db --- /dev/null +++ b/src/app/webapi/internal/basemigrate/basemigrate.go @@ -0,0 +1,33 @@ +package basemigrate + +import ( + "errors" +) + +const ( + sqlChangelog = `CREATE TABLE IF NOT EXISTS databasechangelog ( + id varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + author varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + filename varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + dateexecuted datetime NOT NULL, + orderexecuted int(11) NOT NULL, + md5sum varchar(35) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + description varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + tag varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + version varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;` +) + +const ( + appVersion = "1.0" + elementChangeset = "--changeset " + elementRollback = "--rollback " + elementInclude = "--include " +) + +var ( + // ErrInvalidHeader is when the changeset header is invalid. + ErrInvalidHeader = errors.New("invalid changeset header") + // ErrInvalidFormat is when a changeset is not found. + ErrInvalidFormat = errors.New("invalid changeset format") +) diff --git a/src/app/webapi/internal/basemigrate/basemigrate_test.go b/src/app/webapi/internal/basemigrate/basemigrate_test.go new file mode 100644 index 0000000..a6a8938 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/basemigrate_test.go @@ -0,0 +1,126 @@ +package basemigrate_test + +import ( + "testing" + + "app/webapi/internal/basemigrate" + "app/webapi/internal/testutil" + + "github.com/stretchr/testify/assert" +) + +func TestMigration(t *testing.T) { + db, unique := testutil.SetupDatabase() + + // Run migration. + err := basemigrate.Migrate("testdata/success.sql", unique, 0, false) + assert.Nil(t, err) + + // Count the records. + rows := 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 3, rows) + + // Run migration again. + err = basemigrate.Migrate("testdata/success.sql", unique, 0, false) + assert.Nil(t, err) + + // Remove all migrations. + err = basemigrate.Reset("testdata/success.sql", unique, 0, false) + assert.Nil(t, err) + + rows = 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 0, rows) + + // Remove all migrations again. + err = basemigrate.Reset("testdata/success.sql", unique, 0, false) + assert.Nil(t, err) + + // Run 2 migrations. + err = basemigrate.Migrate("testdata/success.sql", unique, 2, false) + assert.Nil(t, err) + + rows = 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 2, rows) + + // Remove 1 migration. + err = basemigrate.Reset("testdata/success.sql", unique, 1, false) + assert.Nil(t, err) + + rows = 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 1, rows) + + testutil.TeardownDatabase(unique) +} + +func TestMigrationFailDuplicate(t *testing.T) { + db, unique := testutil.SetupDatabase() + + err := basemigrate.Migrate("testdata/fail-duplicate.sql", unique, 0, false) + assert.Contains(t, err.Error(), "checksum does not match") + + rows := 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 2, rows) + + testutil.TeardownDatabase(unique) +} + +func TestInclude(t *testing.T) { + db, unique := testutil.SetupDatabase() + + // Run migration. + err := basemigrate.Migrate("testdata/parent.sql", unique, 0, false) + assert.Nil(t, err) + + // Count the records. + rows := 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 3, rows) + + // Run migration again. + err = basemigrate.Migrate("testdata/parent.sql", unique, 0, false) + assert.Nil(t, err) + + // Remove all migrations. + err = basemigrate.Reset("testdata/parent.sql", unique, 0, false) + assert.Nil(t, err) + + rows = 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 0, rows) + + // Remove all migrations again. + err = basemigrate.Reset("testdata/parent.sql", unique, 0, false) + assert.Nil(t, err) + + // Run 2 migrations. + err = basemigrate.Migrate("testdata/parent.sql", unique, 2, false) + assert.Nil(t, err) + + rows = 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 2, rows) + + // Remove 1 migration. + err = basemigrate.Reset("testdata/parent.sql", unique, 1, false) + assert.Nil(t, err) + + rows = 0 + err = db.Get(&rows, `SELECT count(*) from databasechangelog`) + assert.Nil(t, err) + assert.Equal(t, 1, rows) + + testutil.TeardownDatabase(unique) +} diff --git a/src/app/webapi/internal/basemigrate/changeset.go b/src/app/webapi/internal/basemigrate/changeset.go new file mode 100644 index 0000000..e1ea952 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/changeset.go @@ -0,0 +1,69 @@ +package basemigrate + +import ( + "strings" +) + +// Changeset is a SQL changeset. +type Changeset struct { + id string + author string + filename string + md5 string + description string + version string + + change []string + rollback []string +} + +// ParseHeader will parse the header information. +func (cs *Changeset) ParseHeader(line string) error { + arr := strings.Split(line, ":") + if len(arr) != 2 { + return ErrInvalidHeader + } + + cs.author = arr[0] + cs.id = arr[1] + + return nil +} + +// SetFileInfo will set the file information. +func (cs *Changeset) SetFileInfo(filename string, description string, version string) { + cs.filename = filename + cs.description = description + cs.version = version +} + +// AddRollback will add a rollback command. +func (cs *Changeset) AddRollback(line string) { + if len(cs.rollback) == 0 { + cs.rollback = make([]string, 0) + } + cs.rollback = append(cs.rollback, line) +} + +// AddChange will add a change command. +func (cs *Changeset) AddChange(line string) { + if len(cs.change) == 0 { + cs.change = make([]string, 0) + } + cs.change = append(cs.change, line) +} + +// Changes will return all the changes. +func (cs *Changeset) Changes() string { + return strings.Join(cs.change, "\n") +} + +// Rollbacks will return all the rollbacks. +func (cs *Changeset) Rollbacks() string { + return strings.Join(cs.rollback, "\n") +} + +// Checksum returns an MD5 checksum for the changeset. +func (cs *Changeset) Checksum() string { + return md5sum(cs.Changes()) +} diff --git a/src/app/webapi/internal/basemigrate/helper.go b/src/app/webapi/internal/basemigrate/helper.go new file mode 100644 index 0000000..6a5b037 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/helper.go @@ -0,0 +1,34 @@ +package basemigrate + +import ( + "bytes" + "crypto/md5" + "fmt" + "io" + + "app/webapi/pkg/database" + "app/webapi/pkg/env" + + "github.com/jmoiron/sqlx" +) + +// connect will connect to the database. +func connect(prefix string) (*sqlx.DB, error) { + dbc := new(database.Connection) + + // Load the struct from environment variables. + err := env.Unmarshal(dbc, prefix) + if err != nil { + return nil, err + } + + return dbc.Connect(true) +} + +// md5sum will return a checksum from a string. +func md5sum(s string) string { + h := md5.New() + r := bytes.NewReader([]byte(s)) + _, _ = io.Copy(h, r) + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/src/app/webapi/internal/basemigrate/migrate.go b/src/app/webapi/internal/basemigrate/migrate.go new file mode 100644 index 0000000..7cfafaa --- /dev/null +++ b/src/app/webapi/internal/basemigrate/migrate.go @@ -0,0 +1,118 @@ +package basemigrate + +import ( + "database/sql" + "fmt" + "strings" +) + +// Migrate will perform all the migrations in a file. If max is 0, all +// migrations are run. +func Migrate(filename string, prefix string, max int, verbose bool) error { + db, err := connect(prefix) + if err != nil { + return err + } + + // Create the DATABASECHANGELOG. + _, err = db.Exec(sqlChangelog) + if err != nil { + return err + } + + // Get the changesets. + arr, err := parseFileToArray(filename) + if err != nil { + return err + } + + maxCounter := 0 + + // Loop through each changeset. + for _, cs := range arr { + checksum := "" + newChecksum := cs.Checksum() + + // Determine if the changeset was already applied. + // Count the number of rows. + err = db.Get(&checksum, `SELECT md5sum + FROM databasechangelog + WHERE id = ? + AND author = ? + AND filename = ?`, cs.id, cs.author, cs.filename) + if err == nil { + // Determine if the checksums match. + if checksum != newChecksum { + return fmt.Errorf("checksum does not match - existing changeset %v:%v has checksum %v, but new changeset has checksum %v", + cs.author, cs.id, checksum, newChecksum) + } + + if verbose { + fmt.Printf("Changeset already applied: %v:%v\n", cs.author, cs.id) + } + continue + } else if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("internal error on changeset %v:%v - %v", cs.author, cs.id, err.Error()) + } + + arrQueries := strings.Split(cs.Changes(), ";") + + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("sql error begin transaction - %v", err.Error()) + } + + // Loop through each change. + for _, q := range arrQueries { + if len(q) == 0 { + continue + } + + // Execute the query. + _, err = tx.Exec(q) + if err != nil { + return fmt.Errorf("sql error on changeset %v:%v - %v", cs.author, cs.id, err.Error()) + } + } + + err = tx.Commit() + if err != nil { + errr := tx.Rollback() + if errr != nil { + return fmt.Errorf("sql error on commit rollback %v:%v - %v", cs.author, cs.id, errr.Error()) + } + return fmt.Errorf("sql error on commit %v:%v - %v", cs.author, cs.id, err.Error()) + } + + // Count the number of rows. + count := 0 + err = db.Get(&count, `SELECT COUNT(*) FROM databasechangelog`) + if err != nil { + return err + } + + // Insert the record. + _, err = db.Exec(` + INSERT INTO databasechangelog + (id,author,filename,dateexecuted,orderexecuted,md5sum,description,version) + VALUES(?,?,?,NOW(),?,?,?,?) + `, cs.id, cs.author, cs.filename, count+1, newChecksum, cs.description, cs.version) + if err != nil { + return err + } + + if verbose { + fmt.Printf("Changeset applied: %v:%v\n", cs.author, cs.id) + } + + // Only perform the maxium number of changes based on the max value. + maxCounter++ + if max != 0 { + if maxCounter >= max { + break + } + } + } + + return nil +} diff --git a/src/app/webapi/internal/basemigrate/parse.go b/src/app/webapi/internal/basemigrate/parse.go new file mode 100644 index 0000000..835c3c8 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/parse.go @@ -0,0 +1,120 @@ +package basemigrate + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +// parseFileToArray will parse a file into changesets. +func parseFileToArray(filename string) ([]*Changeset, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + return parseToArray(f, filename) +} + +// parseToArray will split the SQL migration into an ordered array. +func parseToArray(r io.Reader, filename string) ([]*Changeset, error) { + scanner := bufio.NewScanner(r) + scanner.Split(bufio.ScanLines) + + // Array of changesets. + arr := make([]*Changeset, 0) + + for scanner.Scan() { + // Get the line without leading or trailing spaces. + line := strings.TrimSpace(scanner.Text()) + + // Skip blank lines. + if len(line) == 0 { + continue + } + + // Determine if the line is an `include`. + if strings.HasPrefix(line, elementInclude) { + // Load the file and add to the array. + fp := strings.TrimPrefix(line, elementInclude) + rfp := filepath.Join(filepath.Dir(filename), fp) + cs, err := parseFileToArray(rfp) + if err != nil { + return nil, err + } + arr = append(arr, cs...) + continue + } + + // Start recording the changeset. + if strings.HasPrefix(line, elementChangeset) { + // Create a new changeset. + cs := new(Changeset) + cs.ParseHeader(strings.TrimPrefix(line, elementChangeset)) + cs.SetFileInfo(path.Base(filename), "sql", appVersion) + arr = append(arr, cs) + continue + } + + // If the length of the array is 0, then the first changeset is missing. + if len(arr) == 0 { + return nil, ErrInvalidFormat + } + + // Determine if the line is a rollback. + if strings.HasPrefix(line, elementRollback) { + cs := arr[len(arr)-1] + cs.AddRollback(strings.TrimPrefix(line, elementRollback)) + continue + } + + // Determine if the line is comment, ignore it. + if strings.HasPrefix(line, "--") { + continue + } + + // Add the line as a changeset. + cs := arr[len(arr)-1] + cs.AddChange(line) + } + + return arr, nil +} + +// parseFileToMap will parse a file into a map. +func parseFileToMap(filename string) (map[string]Changeset, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + return parseToMap(f, filename) +} + +// parseToMap will parse a reader to a map. +func parseToMap(r io.Reader, filename string) (map[string]Changeset, error) { + arr, err := parseToArray(r, filename) + if err != nil { + return nil, err + } + + m := make(map[string]Changeset) + + for _, cs := range arr { + id := fmt.Sprintf("%v:%v:%v", cs.author, cs.id, cs.filename) + if _, found := m[id]; found { + return nil, errors.New("Duplicate entry found: " + id) + } + + m[id] = *cs + } + + return m, nil +} diff --git a/src/app/webapi/internal/basemigrate/reset.go b/src/app/webapi/internal/basemigrate/reset.go new file mode 100644 index 0000000..0d8b899 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/reset.go @@ -0,0 +1,111 @@ +package basemigrate + +import ( + "errors" + "fmt" + "strings" +) + +// DBChangeset represents the database table records. +type DBChangeset struct { + ID string `db:"id"` + Author string `db:"author"` + Filename string `db:"filename"` + OrderExecuted int `db:"orderexecuted"` +} + +// Reset will remove all migrations. If max is 0, all rollbacks are run. +func Reset(filename string, prefix string, max int, verbose bool) (err error) { + db, err := connect(prefix) + if err != nil { + return err + } + + // Get the changesets in a map. + m, err := parseFileToMap(filename) + if err != nil { + return err + } + + // Get each changeset from the database. + results := make([]DBChangeset, 0) + err = db.Select(&results, ` + SELECT id, author, filename, orderexecuted + FROM databasechangelog + ORDER BY orderexecuted DESC;`) + if err != nil { + return err + } + + if len(results) == 0 { + if verbose { + fmt.Println("No rollbacks to perform.") + return nil + } + } + + maxCounter := 0 + + // Loop through each changeset. + for _, r := range results { + id := fmt.Sprintf("%v:%v:%v", r.Author, r.ID, r.Filename) + + cs, ok := m[id] + if !ok { + return errors.New("changeset is missing: " + id) + } + + arrQueries := strings.Split(cs.Rollbacks(), ";") + + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("sql error begin transaction - %v", err.Error()) + } + + // Loop through each rollback. + for _, q := range arrQueries { + if len(q) == 0 { + continue + } + + // Execute the query. + _, err = tx.Exec(q) + if err != nil { + return fmt.Errorf("sql error on rollback %v:%v - %v", cs.author, cs.id, err.Error()) + } + } + + err = tx.Commit() + if err != nil { + errr := tx.Rollback() + if errr != nil { + return fmt.Errorf("sql error on commit rollback %v:%v - %v", cs.author, cs.id, errr.Error()) + } + return fmt.Errorf("sql error on commit %v:%v - %v", cs.author, cs.id, err.Error()) + } + + // Delete the record. + _, err = db.Exec(` + DELETE FROM databasechangelog + WHERE id = ? AND author = ? AND filename = ? + LIMIT 1 + `, cs.id, cs.author, cs.filename) + if err != nil { + return err + } + + if verbose { + fmt.Printf("Rollback applied: %v:%v\n", cs.author, cs.id) + } + + // Only perform the maxium number of changes based on the max value. + maxCounter++ + if max != 0 { + if maxCounter >= max { + break + } + } + } + + return +} diff --git a/src/app/webapi/internal/basemigrate/testdata/child1.sql b/src/app/webapi/internal/basemigrate/testdata/child1.sql new file mode 100644 index 0000000..c509931 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/testdata/child1.sql @@ -0,0 +1,20 @@ +--changeset josephspurrier:1 +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; +CREATE TABLE user_status ( + id TINYINT(1) UNSIGNED NOT NULL AUTO_INCREMENT, + + status VARCHAR(25) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + + PRIMARY KEY (id) +); +--rollback DROP TABLE user_status; + +--changeset josephspurrier:2 +INSERT INTO `user_status` (`id`, `status`, `created_at`, `updated_at`, `deleted`) VALUES +(1, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0), +(2, 'inactive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0); +--rollback TRUNCATE TABLE user_status; \ No newline at end of file diff --git a/src/app/webapi/internal/basemigrate/testdata/child2.sql b/src/app/webapi/internal/basemigrate/testdata/child2.sql new file mode 100644 index 0000000..3fc8bf0 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/testdata/child2.sql @@ -0,0 +1,22 @@ +--changeset josephspurrier:3 +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; +CREATE TABLE user ( + id VARCHAR(36) NOT NULL, + + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + email VARCHAR(100) NOT NULL, + password CHAR(60) NOT NULL, + + status_id TINYINT(1) UNSIGNED NOT NULL DEFAULT 1, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT 0, + + UNIQUE KEY (email), + CONSTRAINT `f_user_status` FOREIGN KEY (`status_id`) REFERENCES `user_status` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + + PRIMARY KEY (id) +); +--rollback DROP TABLE user; \ No newline at end of file diff --git a/src/app/webapi/internal/basemigrate/testdata/fail-duplicate.sql b/src/app/webapi/internal/basemigrate/testdata/fail-duplicate.sql new file mode 100644 index 0000000..fcd6846 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/testdata/fail-duplicate.sql @@ -0,0 +1,43 @@ +--changeset josephspurrier:1 +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; +CREATE TABLE user_status ( + id TINYINT(1) UNSIGNED NOT NULL AUTO_INCREMENT, + + status VARCHAR(25) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + + PRIMARY KEY (id) +); +--rollback DROP TABLE user_status; + +--changeset josephspurrier:2 +INSERT INTO `user_status` (`id`, `status`, `created_at`, `updated_at`, `deleted`) VALUES +(1, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0), +(2, 'inactive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0); +--rollback TRUNCATE TABLE user_status; + +--changeset josephspurrier:2 +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; +CREATE TABLE user ( + id VARCHAR(36) NOT NULL, + + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + email VARCHAR(100) NOT NULL, + password CHAR(60) NOT NULL, + + status_id TINYINT(1) UNSIGNED NOT NULL DEFAULT 1, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT 0, + + UNIQUE KEY (email), + CONSTRAINT `f_user_status` FOREIGN KEY (`status_id`) REFERENCES `user_status` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + + PRIMARY KEY (id) +); +--rollback DROP TABLE user; \ No newline at end of file diff --git a/src/app/webapi/internal/basemigrate/testdata/parent.sql b/src/app/webapi/internal/basemigrate/testdata/parent.sql new file mode 100644 index 0000000..5502cb4 --- /dev/null +++ b/src/app/webapi/internal/basemigrate/testdata/parent.sql @@ -0,0 +1,2 @@ +--include child1.sql +--include child2.sql \ No newline at end of file diff --git a/src/app/webapi/internal/basemigrate/testdata/success.sql b/src/app/webapi/internal/basemigrate/testdata/success.sql new file mode 100644 index 0000000..6526d0a --- /dev/null +++ b/src/app/webapi/internal/basemigrate/testdata/success.sql @@ -0,0 +1,43 @@ +--changeset josephspurrier:1 +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; +CREATE TABLE user_status ( + id TINYINT(1) UNSIGNED NOT NULL AUTO_INCREMENT, + + status VARCHAR(25) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + + PRIMARY KEY (id) +); +--rollback DROP TABLE user_status; + +--changeset josephspurrier:2 +INSERT INTO `user_status` (`id`, `status`, `created_at`, `updated_at`, `deleted`) VALUES +(1, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0), +(2, 'inactive', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0); +--rollback TRUNCATE TABLE user_status; + +--changeset josephspurrier:3 +SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; +CREATE TABLE user ( + id VARCHAR(36) NOT NULL, + + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + email VARCHAR(100) NOT NULL, + password CHAR(60) NOT NULL, + + status_id TINYINT(1) UNSIGNED NOT NULL DEFAULT 1, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT 0, + + UNIQUE KEY (email), + CONSTRAINT `f_user_status` FOREIGN KEY (`status_id`) REFERENCES `user_status` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + + PRIMARY KEY (id) +); +--rollback DROP TABLE user; \ No newline at end of file diff --git a/src/app/webapi/internal/testrequest/form.go b/src/app/webapi/internal/testrequest/form.go index ef31930..f4d0d62 100644 --- a/src/app/webapi/internal/testrequest/form.go +++ b/src/app/webapi/internal/testrequest/form.go @@ -1,13 +1,13 @@ package testrequest import ( - "app/webapi" "io" "net/http/httptest" "net/url" "strings" "testing" + "app/webapi" "app/webapi/component" ) diff --git a/src/app/webapi/internal/testutil/database.go b/src/app/webapi/internal/testutil/database.go index 3699665..103eb74 100644 --- a/src/app/webapi/internal/testutil/database.go +++ b/src/app/webapi/internal/testutil/database.go @@ -1,28 +1,50 @@ package testutil import ( - "io/ioutil" + "fmt" "log" + "math/rand" "os" - "strings" - "testing" + "time" + "app/webapi/internal/basemigrate" "app/webapi/pkg/database" + "app/webapi/pkg/env" ) -// ConnectDatabase returns a test database connection. -func ConnectDatabase(dbSpecificDB bool) *database.DBW { +func init() { + rand.Seed(time.Now().UnixNano()) +} + +func setEnv(unique string) { + os.Setenv(unique+"DB_HOSTNAME", "127.0.0.1") + os.Setenv(unique+"DB_PORT", "3306") + os.Setenv(unique+"DB_USERNAME", "root") + os.Setenv(unique+"DB_PASSWORD", "") + os.Setenv(unique+"DB_DATABASE", "webapitest"+unique) + os.Setenv(unique+"DB_PARAMETER", "parseTime=true&allowNativePasswords=true") +} + +func unsetEnv(unique string) { + os.Unsetenv(unique + "DB_HOSTNAME") + os.Unsetenv(unique + "DB_PORT") + os.Unsetenv(unique + "DB_USERNAME") + os.Unsetenv(unique + "DB_PASSWORD") + os.Unsetenv(unique + "DB_DATABASE") + os.Unsetenv(unique + "DB_PARAMETER") +} + +// connectDatabase returns a test database connection. +func connectDatabase(dbSpecificDB bool, unique string) *database.DBW { dbc := new(database.Connection) - dbc.Hostname = "127.0.0.1" - dbc.Port = 3306 - dbc.Username = "root" - dbc.Password = "" - dbc.Database = "webapitest" - dbc.Parameter = "parseTime=true&allowNativePasswords=true" + err := env.Unmarshal(dbc, unique) + if err != nil { + fmt.Println("DB ENV Error:", err) + } connection, err := dbc.Connect(dbSpecificDB) if err != nil { - log.Println("DB Error:", err) + fmt.Println("DB Error:", err) } dbw := database.New(connection) @@ -30,53 +52,68 @@ func ConnectDatabase(dbSpecificDB bool) *database.DBW { return dbw } -// LoadDatabase will set up the DB for the tests. -func LoadDatabase(t *testing.T) { - db := ConnectDatabase(false) - db.Exec(`DROP DATABASE IF EXISTS webapitest`) - db.Exec(`CREATE DATABASE webapitest DEFAULT CHARSET = utf8 COLLATE = utf8_unicode_ci`) +// SetupDatabase will create the test database and set the environment +// variables. +func SetupDatabase() (*database.DBW, string) { + unique := "T" + fmt.Sprint(rand.Intn(500)) + setEnv(unique) - db = ConnectDatabase(true) - b, err := ioutil.ReadFile("../../../../../migration/tables-only.sql") + db := connectDatabase(false, unique) + _, err := db.Exec(`DROP DATABASE IF EXISTS webapitest` + unique) if err != nil { - t.Error(err) + fmt.Println("DB DROP SETUP Error:", err) } - - // Split each statement. - stmts := strings.Split(string(b), ";") - for i, s := range stmts { - if i == len(stmts)-1 { - break - } - _, err = db.Exec(s) - if err != nil { - t.Error(err) - } + _, err = db.Exec(`CREATE DATABASE webapitest` + unique + ` DEFAULT CHARSET = utf8 COLLATE = utf8_unicode_ci`) + if err != nil { + fmt.Println("DB CREATE Error:", err) } -} -// LoadDatabaseFromFile will set up the DB for the tests. -func LoadDatabaseFromFile(file string) { - db := ConnectDatabase(false) - db.Exec(`DROP DATABASE IF EXISTS webapitest`) - db.Exec(`CREATE DATABASE webapitest DEFAULT CHARSET = utf8 COLLATE = utf8_unicode_ci`) + return connectDatabase(true, unique), unique +} - db = ConnectDatabase(true) - b, err := ioutil.ReadFile(file) +// TeardownDatabase will destroy the test database and unset the environment +// variables. +func TeardownDatabase(unique string) { + db := connectDatabase(false, unique) + _, err := db.Exec(`DROP DATABASE IF EXISTS webapitest` + unique) if err != nil { - log.Println(err) - os.Exit(1) + fmt.Println("DB DROP TEARDOWN Error:", err) } - // Split each statement. - stmts := strings.Split(string(b), ";") - for i, s := range stmts { - if i == len(stmts)-1 { - break + unsetEnv(unique) +} + +// LoadDatabase will set up the DB and apply migrations for the tests. +func LoadDatabase() (*database.DBW, string) { + return LoadDatabaseFromFile("../../../../../migration/mysql-v0.sql", true) +} + +// LoadDatabaseFromFile will set up the DB for the tests. +func LoadDatabaseFromFile(file string, usePrefix bool) (*database.DBW, string) { + unique := "" + var db *database.DBW + + if usePrefix { + db, unique = SetupDatabase() + } else { + setEnv(unique) + db = connectDatabase(false, "") + _, err := db.Exec(`DROP DATABASE IF EXISTS webapitest`) + if err != nil { + fmt.Println("DB DROP SETUP Error:", err) } - _, err = db.Exec(s) + _, err = db.Exec(`CREATE DATABASE webapitest DEFAULT CHARSET = utf8 COLLATE = utf8_unicode_ci`) if err != nil { - log.Println(err) + fmt.Println("DB CREATE Error:", err) } + + db = connectDatabase(true, unique) } + + err := basemigrate.Migrate(file, unique, 0, false) + if err != nil { + log.Println("DB Error:", err) + } + + return db, unique } diff --git a/src/app/webapi/pkg/database/connection.go b/src/app/webapi/pkg/database/connection.go index 630b198..40df6d2 100644 --- a/src/app/webapi/pkg/database/connection.go +++ b/src/app/webapi/pkg/database/connection.go @@ -10,14 +10,14 @@ import ( // Connection holds the details for the MySQL connection. type Connection struct { - Username string `json:"Username"` - Password string `json:"Password"` - Database string `json:"Database"` - Charset string `json:"Charset"` - Collation string `json:"Collation"` - Hostname string `json:"Hostname"` - Port int `json:"Port"` - Parameter string `json:"Parameter"` + Username string `json:"Username" env:"DB_USERNAME"` + Password string `json:"Password" env:"DB_PASSWORD"` + Database string `json:"Database" env:"DB_DATABASE"` + Charset string `json:"Charset" env:"DB_CHARSET"` + Collation string `json:"Collation" env:"DB_COLLATION"` + Hostname string `json:"Hostname" env:"DB_HOSTNAME"` + Port int `json:"Port" env:"DB_PORT"` + Parameter string `json:"Parameter" env:"DB_PARAMETER"` } // ***************************************************************************** diff --git a/src/app/webapi/pkg/env/env.go b/src/app/webapi/pkg/env/env.go new file mode 100644 index 0000000..759fe34 --- /dev/null +++ b/src/app/webapi/pkg/env/env.go @@ -0,0 +1,68 @@ +// Package env will fill a struct from environment variables. +package env + +import ( + "fmt" + "os" + "reflect" + "strconv" +) + +// Unmarshal will fill a struct from environment variables. It supports struct +// values of string, int, and bool. +func Unmarshal(dst interface{}, prefix string) (err error) { + // Ensure a pointer is passed in. + vdst := reflect.ValueOf(dst) + if vdst.Kind() != reflect.Ptr { + return fmt.Errorf("dst type not pointer - expected 'struct pointer' but got '%v'", vdst.Kind()) + } + + // Ensure a struct is passed in. + vd := reflect.Indirect(reflect.ValueOf(dst)) + if vd.Kind() != reflect.Struct { + return fmt.Errorf("dst type not struct - expected 'struct pointer' but got '%v pointer'", vd.Kind()) + } + + // Loop through each field. + keys := vd.Type() + for j := 0; j < vd.NumField(); j++ { + field := keys.Field(j) + tag := keys.Field(j).Tag + + // Get the env tag. + envname := tag.Get("env") + + // Get the environment variable from the tag. + val := os.Getenv(prefix + envname) + + // If the environment variable exists, set the value. + if len(val) > 0 { + vr := reflect.ValueOf(val) + + // If the types are the same, then set the field. + if vr.Type() == field.Type { + vd.Field(j).Set(vr) + continue + } + + // If the types are not the same, perform type conversion. + f := vd.Field(j) + switch f.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i64, err := strconv.ParseInt(val, 10, 0) + if err != nil { + return err + } + f.SetInt(i64) + case reflect.Bool: + b, err := strconv.ParseBool(val) + if err != nil { + return err + } + f.SetBool(b) + } + } + } + + return +} diff --git a/src/app/webapi/pkg/env/env_test.go b/src/app/webapi/pkg/env/env_test.go new file mode 100644 index 0000000..3a8a182 --- /dev/null +++ b/src/app/webapi/pkg/env/env_test.go @@ -0,0 +1,103 @@ +package env_test + +import ( + "os" + "testing" + + "app/webapi/pkg/env" + + "github.com/stretchr/testify/assert" +) + +// Connection holds the details for the MySQL connection. +type Connection struct { + Username string `json:"Username" env:"DB_USERNAME"` + Password string `json:"Password" env:"DB_PASSWORD"` + Database string `json:"Database" env:"DB_DATABASE"` + Port int `json:"Port" env:"DB_PORT"` + SSL bool `json:"SSL" env:"DB_SSL"` +} + +func TestUnmarshalEmpty(t *testing.T) { + c := new(Connection) + err := env.Unmarshal(c, "") + assert.Nil(t, err) + + assert.Equal(t, "", c.Username) + assert.Equal(t, "", c.Password) + assert.Equal(t, "", c.Database) + assert.Equal(t, 0, c.Port) + assert.Equal(t, false, c.SSL) +} + +func TestUnmarshalFilled(t *testing.T) { + os.Setenv("DB_USERNAME", "a") + os.Setenv("DB_PASSWORD", "B") + os.Setenv("DB_DATABASE", "c123") + os.Setenv("DB_PORT", "3306") + os.Setenv("DB_SSL", "TRUE") + + c := new(Connection) + err := env.Unmarshal(c, "") + assert.Nil(t, err) + + assert.Equal(t, "a", c.Username) + assert.Equal(t, "B", c.Password) + assert.Equal(t, "c123", c.Database) + assert.Equal(t, 3306, c.Port) + assert.Equal(t, true, c.SSL) + + os.Unsetenv("DB_USERNAME") + os.Unsetenv("DB_PASSWORD") + os.Unsetenv("DB_DATABASE") + os.Unsetenv("DB_PORT") + os.Unsetenv("DB_SSL") +} + +func TestUnmarshalFilledPrefix(t *testing.T) { + os.Setenv("TEST_DB_USERNAME", "a") + os.Setenv("TEST_DB_PASSWORD", "B") + os.Setenv("TEST_DB_DATABASE", "c123") + os.Setenv("TEST_DB_PORT", "3306") + os.Setenv("TEST_DB_SSL", "TRUE") + + c := new(Connection) + err := env.Unmarshal(c, "TEST_") + assert.Nil(t, err) + + assert.Equal(t, "a", c.Username) + assert.Equal(t, "B", c.Password) + assert.Equal(t, "c123", c.Database) + assert.Equal(t, 3306, c.Port) + assert.Equal(t, true, c.SSL) + + os.Unsetenv("TEST_DB_USERNAME") + os.Unsetenv("TEST_DB_PASSWORD") + os.Unsetenv("TEST_DB_DATABASE") + os.Unsetenv("TEST_DB_PORT") + os.Unsetenv("TEST_DB_SSL") +} + +func TestUnmarshalError(t *testing.T) { + c := "string" + err := env.Unmarshal(c, "") + assert.Contains(t, err.Error(), "type not pointer") + + d := "string" + err = env.Unmarshal(&d, "") + assert.Contains(t, err.Error(), "type not struct") + + os.Setenv("DB_SSL", "TRUEX") + f := new(Connection) + err = env.Unmarshal(f, "") + assert.NotNil(t, err) + assert.Equal(t, false, f.SSL) + os.Unsetenv("DB_SSL") + + os.Setenv("DB_PORT", "bad") + g := new(Connection) + err = env.Unmarshal(f, "") + assert.NotNil(t, err) + assert.Equal(t, false, g.SSL) + os.Unsetenv("DB_PORT") +} diff --git a/src/app/webapi/pkg/structcopy/structcopy.go b/src/app/webapi/pkg/structcopy/structcopy.go index 3e6e45f..e3920de 100644 --- a/src/app/webapi/pkg/structcopy/structcopy.go +++ b/src/app/webapi/pkg/structcopy/structcopy.go @@ -34,13 +34,12 @@ func ByTag(src interface{}, srcTag string, dst interface{}, dstTag string) (err // Loop through each field. keysSrc := vs.Type() keysDst := vd.Type() - for jS := 0; jS < vs.NumField(); jS++ { - fieldS := vs.Field(jS) - tagS := keysSrc.Field(jS).Tag - - for jD := 0; jD < vd.NumField(); jD++ { - fieldD := vd.Field(jD) - tagD := keysDst.Field(jD).Tag + for jD := 0; jD < vd.NumField(); jD++ { + fieldD := vd.Field(jD) + tagD := keysDst.Field(jD).Tag + for jS := 0; jS < vs.NumField(); jS++ { + fieldS := vs.Field(jS) + tagS := keysSrc.Field(jS).Tag // If the tags match, copy the value from src to dst field. if tagS.Get(srcTag) == tagD.Get(dstTag) { diff --git a/src/app/webapi/store/user.go b/src/app/webapi/store/user.go index bb3a95e..75442f2 100644 --- a/src/app/webapi/store/user.go +++ b/src/app/webapi/store/user.go @@ -60,19 +60,13 @@ func (x UserGroup) PrimaryKey() string { return "id" } -// ***************************************************************************** -// Create -// ***************************************************************************** - // Create adds a new user. func (x *User) Create(firstName, lastName, email, password string) (string, error) { - // Generate a UUID. uuid, err := securegen.UUID() if err != nil { return "", err } - // Create the user. _, err = x.db.Exec(` INSERT INTO user (id, first_name, last_name, email, password, status_id) @@ -84,13 +78,8 @@ func (x *User) Create(firstName, lastName, email, password string) (string, erro return uuid, err } -// ***************************************************************************** -// Update -// ***************************************************************************** - -// Update makes changes to one entity. +// Update makes changes to a user. func (x *User) Update(ID, firstName, lastName, email, password string) (err error) { - // Update the entity. _, err = x.db.Exec(` UPDATE user SET