From dd13cf93824854097c98ba6dc8a0c4a50f0f62d2 Mon Sep 17 00:00:00 2001 From: Sergey Podgornyy Date: Sun, 28 Jun 2020 00:06:30 +0200 Subject: [PATCH] Initial commit --- .gitignore | 3 + .travis.yml | 40 ++ LICENSE.md | 25 ++ Makefile | 68 ++++ README.md | 217 +++++++++++ column.go | 533 ++++++++++++++++++++++++++ column_test.go | 545 ++++++++++++++++++++++++++ foreign.go | 58 +++ foreign_test.go | 72 ++++ go.mod | 8 + go.sum | 14 + key.go | 48 +++ key_test.go | 66 ++++ logo.png | Bin 0 -> 48800 bytes migration.go | 88 +++++ migration_test.go | 204 ++++++++++ migrator.go | 309 +++++++++++++++ migrator_test.go | 841 +++++++++++++++++++++++++++++++++++++++++ schema.go | 68 ++++ schema_command.go | 117 ++++++ schema_command_test.go | 232 ++++++++++++ schema_test.go | 69 ++++ table.go | 139 +++++++ table_command.go | 206 ++++++++++ table_command_test.go | 221 +++++++++++ table_test.go | 207 ++++++++++ 26 files changed, 4398 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 column.go create mode 100644 column_test.go create mode 100644 foreign.go create mode 100644 foreign_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 key.go create mode 100644 key_test.go create mode 100644 logo.png create mode 100644 migration.go create mode 100644 migration_test.go create mode 100644 migrator.go create mode 100644 migrator_test.go create mode 100644 schema.go create mode 100644 schema_command.go create mode 100644 schema_command_test.go create mode 100644 schema_test.go create mode 100644 table.go create mode 100644 table_command.go create mode 100644 table_command_test.go create mode 100644 table_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f02f79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +coverage.out +tmp.out +profile.out diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..41b4bcf --- /dev/null +++ b/.travis.yml @@ -0,0 +1,40 @@ +language: go + +matrix: + fast_finish: true + include: + - go: 1.11.x + env: GO111MODULE=on + - go: 1.12.x + env: GO111MODULE=on + - go: 1.13.x + - go: 1.13.x + env: + - TESTTAGS=nomsgpack + - go: 1.14.x + - go: 1.14.x + env: + - TESTTAGS=nomsgpack + - go: master + +git: + depth: 10 + +before_install: + - if [[ "${GO111MODULE}" = "on" ]]; then mkdir "${HOME}/go"; export GOPATH="${HOME}/go"; fi + +install: + - if [[ "${GO111MODULE}" = "on" ]]; then go mod download; fi + - if [[ "${GO111MODULE}" = "on" ]]; then export PATH="${GOPATH}/bin:${GOROOT}/bin:${PATH}"; fi + - if [[ "${GO111MODULE}" = "on" ]]; then make tools; fi + +go_import_path: github.com/larapulse/migrator + +script: + - make vet + - make fmt-check + - make misspell-check + - make test + +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..240ff22 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,25 @@ +MIT License +----------- + +Copyright (c) 2020 Sergey Podgornyy (https://podgornyy.rocks) + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ac727e8 --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +GOFMT ?= gofmt "-s" +PACKAGES ?= $(shell go list ./...) +GOFILES := $(shell find . -name "*.go") +TESTTAGS ?= "" + +.PHONY: test +test: + echo "mode: count" > coverage.out + for d in $(PACKAGES); do \ + go test -timeout 30s -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out $$d > tmp.out; \ + cat tmp.out; \ + if grep -q "^--- FAIL" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "build failed" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "setup failed" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + fi; \ + if [ -f profile.out ]; then \ + cat profile.out | grep -v "mode:" >> coverage.out; \ + rm profile.out; \ + fi; \ + done + +.PHONY: fmt +fmt: + $(GOFMT) -w $(GOFILES) + +.PHONY: fmt-check +fmt-check: + @diff=$$($(GOFMT) -d $(GOFILES)); \ + if [ -n "$$diff" ]; then \ + echo "Please run 'make fmt' and commit the result:"; \ + echo "$${diff}"; \ + exit 1; \ + fi; + +vet: + go vet $(PACKAGES) + +.PHONY: lint +lint: + @hash golint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + go get -u golang.org/x/lint/golint; \ + fi + for PKG in $(PACKAGES); do golint -set_exit_status $$PKG || exit 1; done; + +.PHONY: misspell-check +misspell-check: + @hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + go get -u github.com/client9/misspell/cmd/misspell; \ + fi + misspell -error $(GOFILES) + +.PHONY: misspell +misspell: + @hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + go get -u github.com/client9/misspell/cmd/misspell; \ + fi + misspell -w $(GOFILES) + +.PHONY: tools +tools: + go install golang.org/x/lint/golint; \ + go install github.com/client9/misspell/cmd/misspell; diff --git a/README.md b/README.md new file mode 100644 index 0000000..3976569 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# MySQL database migrator + + + +[![Build Status](https://travis-ci.org/larapulse/migrator.svg)](https://travis-ci.org/larapulse/migrator) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) +[![codecov](https://codecov.io/gh/larapulse/migrator/branch/master/graph/badge.svg)](https://codecov.io/gh/larapulse/migrator) +[![Go Report Card](https://goreportcard.com/badge/github.com/larapulse/migrator)](https://goreportcard.com/report/github.com/larapulse/migrator) +[![GoDoc](https://godoc.org/github.com/larapulse/migrator?status.svg)](https://pkg.go.dev/github.com/larapulse/migrator?tab=doc) +[![Release](https://img.shields.io/github/release/larapulse/migrator.svg)](https://github.com/larapulse/migrator/releases) +[![TODOs](https://badgen.net/https/api.tickgit.com/badgen/github.com/larapulse/migrator)](https://www.tickgit.com/browse?repo=github.com/larapulse/migrator) + +MySQL database migrator designed to run migrations to your features and manage database schema update with intuitive go code. It is compatible with latest MySQL v8. + +## Installation + +To install `migrator` package, you need to install Go and set your Go workspace first. + +1. The first need [Go](https://golang.org/) installed (**version 1.13+ is required**), then you can use the below Go command to install `migrator`. + +```sh +$ go get -u github.com/larapulse/migrator +``` + +2. Import it in your code: + +```go +import "github.com/larapulse/migrator" +``` + +## Quick start + +Initialize migrator with migration entries: + +```go +var migrations = []migrator.Migration{ + { + Name: "19700101_0001_create_posts_table", + Up: func() migrator.Schema { + var s migrator.Schema + posts := migrator.Table{Name: "posts"} + + posts.UniqueID("id") + posts.Column("title", migrator.String{Precision: 64}) + posts.Column("content", migrator.Text{}) + posts.Timestamps() + + s.CreateTable(posts) + + return s + }, + Down: func() migrator.Schema { + var s migrator.Schema + + s.DropTable("posts") + + return s + }, + }, + { + Name: "19700101_0002_create_comments_table", + Up: func() migrator.Schema { + var s migrator.Schema + comments := migrator.Table{Name: "comments"} + + comments.UniqueID("id") + comments.UUID("post_id", "", false) + comments.Column("title", migrator.String{Precision: 64}) + comments.Column("content", migrator.Text{}) + comments.Timestamps() + + comments.Foreign("post_id", "id", "posts", "RESTRICT", "RESTRICT") + + s.CreateTable(comments) + + return s + }, + Down: func() migrator.Schema { + var s migrator.Schema + + s.DropTable("comments") + + return s + }, + }, +} + +m := migrator.Migrator{Pool: migrations} +migrated, err = m.Migrate(db) + +if err != nil { + fmt.Errorf("Could not migrate: %v", err) + os.Exit(1) +} + +if len(migrated) == 0 { + fmt.Println("Nothing were migrated.") +} + +for _, m := range migrated { + fmt.Println("Migration: "+m+" was migrated ✅") +} + +fmt.Println("Migration did run successfully") +``` + +After first migration run, `migrations` table will be created: + +``` ++----+-------------------------------------+-------+---------------------+ +| id | name | batch | applied_at | ++----+-------------------------------------+-------+---------------------+ +| 1 | 19700101_0001_create_posts_table | 1 | 2020-06-27 00:00:00 | +| 2 | 19700101_0002_create_comments_table | 1 | 2020-06-27 00:00:00 | ++----+-------------------------------------+-------+---------------------+ +``` + +If you want to use another name for migration table, change it `Migrator` before running migrations: + +```go +m := migrator.Migrator{TableName: "_my_app_migrations"} +``` + +### Transactional migration + +In case you have multiple commands within one migration and you want to be sure it is migrated properly, you might enable transactional execution per migration: + +```go +var migration = migrator.Migration{ + Name: "19700101_0001_create_posts_and_users_tables", + Up: func() migrator.Schema { + var s migrator.Schema + posts := migrator.Table{Name: "posts"} + posts.UniqueID("id") + posts.Timestamps() + + users := migrator.Table{Name: "users"} + users.UniqueID("id") + users.Timestamps() + + s.CreateTable(posts) + s.CreateTable(users) + + return s + }, + Down: func() migrator.Schema { + var s migrator.Schema + + s.DropTable("users") + s.DropTable("posts") + + return s + }, + Transaction: true, +} +``` + +### Rollback and revert + +In case you need to revert your deploy and DB, you can revert last migrated batch: + +```go +m := migrator.Migrator{Pool: migrations} +reverted, err := m.Rollback(db) + +if err != nil { + fmt.Errorf("Could not roll back migrations: %v", err) + os.Exit(1) +} + +if len(reverted) == 0 { + fmt.Println("Nothing were rolled back.") +} + +for _, m := range reverted { + fmt.Println("Migration: "+m+" was rolled back ✅") +} +``` + +To revert all migrated items back, you have to call `Revert()` on your `migrator`: + +```go +m := migrator.Migrator{Pool: migrations} +reverted, err := m.Revert(db) +``` + +## Customize queries + +You may add any column definition to the database on your own, just be sure you implement `columnType` interface: + +```go +type customType string + +func (ct customType) buildRow() string { + return string(ct) +} + +posts := migrator.Table{Name: "posts"} +posts.UniqueID("id") +posts.Column("data", customType("json not null")) +posts.Timestamps() +``` + +Same logic is for adding custom commands to the Schema to be migrated or reverted, just be sure you implement `command` interface: + +```go +type customCommand string + +func (cc customCommand) toSQL() string { + return string(cc) +} + +var s migrator.Schema + +c := customCommand("DROP PROCEDURE abc") +s.CustomCommand(c) +``` diff --git a/column.go b/column.go new file mode 100644 index 0000000..c709d4e --- /dev/null +++ b/column.go @@ -0,0 +1,533 @@ +// Package migrator represents MySQL database migrator +package migrator + +import ( + "fmt" + "strconv" + "strings" +) + +type columns []column + +func (c columns) render() string { + rows := []string{} + + for _, item := range c { + rows = append(rows, fmt.Sprintf("`%s` %s", item.field, item.definition.buildRow())) + } + + return strings.Join(rows, ", ") +} + +type column struct { + field string + definition columnType +} + +type columnType interface { + buildRow() string +} + +// Integer represents integer value in DB: {tiny,small,medium,big}int +// +// Default migrator.Integer will build a sql row: `int NOT NULL` +// Examples: +// tinyint ➡️ migrator.Integer{Prefix: "tiny", Unsigned: true, Precision: 1, Default: "0"} +// ↪️ tinyint(1) unsigned NOT NULL DEFAULT 0 +// int ➡️ migrator.Integer{Nullable: true, OnUpdate: "set null", Comment: "nullable counter"} +// ↪️ int NULL ON UPDATE set null COMMENT 'nullable counter' +// mediumint ➡️ migrator.Integer{Prefix: "medium", Precision: "255"} +// ↪️ mediumint(255) NOT NULL +// bigint ➡️ migrator.Integer{Prefix: "big", Unsigned: true, Precision: "255", Autoincrement: true} +// ↪️ bigint(255) unsigned NOT NULL AUTO_INCREMENT +type Integer struct { + Default string + Nullable bool + Comment string + OnUpdate string + + Prefix string // tiny, small, medium, big + Unsigned bool + Precision uint16 + Autoincrement bool +} + +func (i Integer) buildRow() string { + sql := i.Prefix + "int" + if i.Precision > 0 { + sql += fmt.Sprintf("(%s)", strconv.Itoa(int(i.Precision))) + } + + if i.Unsigned { + sql += " unsigned" + } + + if i.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if i.Default != "" { + sql += " DEFAULT " + i.Default + } + + if i.Autoincrement { + sql += " AUTO_INCREMENT" + } + + if i.OnUpdate != "" { + sql += " ON UPDATE " + i.OnUpdate + } + + if i.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", i.Comment) + } + + return sql +} + +// Floatable replresents number with floating point in DB: +// `float`, `double` or `decimal` +// +// Default migrator.Floatable will build a sql row: `float NOT NULL` +// Examples: +// float ➡️ migrator.Floatable{Precision: 2, Nullable: true} +// ↪️ float(2) NULL +// real ➡️ migrator.Floatable{Type: "real", Precision: 5, Scale: 2} +// ↪️ real(5,2) NOT NULL +// double ➡️ migrator.Floatable{Type: "double", Scale: 2, Unsigned: true} +// ↪️ double(0,2) unsigned NOT NULL +// decimal ➡️ migrator.Floatable{Type: "decimal", Precision: 15, Scale: 2, OnUpdate: "0.0", Comment: "money"} +// ↪️ decimal(15,2) NOT NULL ON UPDATE 0.0 COMMENT 'money' +// numeric ➡️ migrator.Floatable{Type: "numeric", Default: "0.0"} +// ↪️ numeric NOT NULL DEFAULT 0.0 +type Floatable struct { + Default string + Nullable bool + Comment string + OnUpdate string + + Type string // float, real, double, decimal, numeric + Unsigned bool + Precision uint16 + Scale uint16 +} + +func (f Floatable) buildRow() string { + sql := f.Type + + if sql == "" { + sql = "float" + } + + if f.Scale > 0 { + sql += fmt.Sprintf("(%s,%s)", strconv.Itoa(int(f.Precision)), strconv.Itoa(int(f.Scale))) + } else if f.Precision > 0 { + sql += fmt.Sprintf("(%s)", strconv.Itoa(int(f.Precision))) + } + + if f.Unsigned { + sql += " unsigned" + } + + if f.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if f.Default != "" { + sql += " DEFAULT " + f.Default + } + + if f.OnUpdate != "" { + sql += " ON UPDATE " + f.OnUpdate + } + + if f.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", f.Comment) + } + + return sql +} + +// Timable represents DB representation of timable column type: +// `date`, `datetime`, `timestamp`, `time` or `year` +// +// Default migrator.Timable will build a sql row: `timestamp NOT NULL` +// Examples: +// date ➡️ migrator.Timable{Type: "date", Nullable: true} +// ↪️ date NULL +// datetime ➡️ migrator.Timable{Type: "datetime", Default: "CURRENT_TIMESTAMP"} +// ↪️ datetime NOT NULL DEFAULT CURRENT_TIMESTAMP +// timestamp ➡️ migrator.Timable{Default: "CURRENT_TIMESTAMP", OnUpdate: "CURRENT_TIMESTAMP"} +// ↪️ timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +// time ➡️ migrator.Timable{Type: "time", Comment: "meeting time"} +// ↪️ time NOT NULL COMMENT 'meeting time' +// year ➡️ migrator.Timable{Type: "year", Nullable: true} +// ↪️ year NULL +type Timable struct { + Default string + Nullable bool + Comment string + OnUpdate string + + Type string // date, time, datetime, timestamp, year +} + +func (t Timable) buildRow() string { + sql := t.Type + + if sql == "" { + sql = "timestamp" + } + + if t.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if t.Default != "" { + sql += " DEFAULT " + t.Default + } + + if t.OnUpdate != "" { + sql += " ON UPDATE " + t.OnUpdate + } + + if t.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", t.Comment) + } + + return sql +} + +// String represents basic DB string column type: `char` or `varchar` +// +// Default migrator.String will build a sql row: `varchar COLLATE utf8mb4_unicode_ci NOT NULL` +// Examples: +// char ➡️ migrator.String{Fixed: true, Precision: 36, Nullable: true, OnUpdate: "set null", Comment: "uuid"} +// ↪️ char(36) COLLATE utf8mb4_unicode_ci NULL ON UPDATE set null COMMENT 'uuid' +// varchar ➡️ migrator.String{Precision: 255, Default: "active", Charset: "utf8mb4", Collate: "utf8mb4_general_ci"} +// ↪️ varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'active' +type String struct { + Default string + Nullable bool + Comment string + OnUpdate string + + Charset string + Collate string + + Fixed bool // char for fixed, otherwise varchar + Precision uint16 +} + +func (s String) buildRow() string { + sql := "" + + if !s.Fixed { + sql += "var" + } + + sql += "char" + + if s.Precision > 0 { + sql += fmt.Sprintf("(%s)", strconv.Itoa(int(s.Precision))) + } + + if s.Charset != "" { + sql += " CHARACTER SET " + s.Charset + } + + if s.Collate != "" { + sql += " COLLATE " + s.Collate + } else if s.Charset == "" { + // use default + sql += " COLLATE utf8mb4_unicode_ci" + } + + if s.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if s.Default != "" { + sql += fmt.Sprintf(" DEFAULT '%s'", s.Default) + } + + if s.OnUpdate != "" { + sql += " ON UPDATE " + s.OnUpdate + } + + if s.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", s.Comment) + } + + return sql +} + +// Text represents long text column type represented in DB as: +// - {tiny,medium,long}text +// - {tiny,medium,long}blob +// +// Default migrator.Text will build a sql row: `text COLLATE utf8mb4_unicode_ci NOT NULL` +// Examples: +// tinytext ➡️ migrator.Text{Prefix: "tiny"} +// ↪️ tinytext COLLATE utf8mb4_unicode_ci NOT NULL +// text ➡️ migrator.Text{Nullable: true, OnUpdate: "set null", Comment: "write your comment here"} +// ↪️ text COLLATE utf8mb4_unicode_ci NULL ON UPDATE set null COMMENT 'write your comment here' +// mediumtext ➡️ migrator.Text{Prefix: "medium"} +// ↪️ mediumtext COLLATE utf8mb4_unicode_ci NOT NULL +// longtext ➡️ migrator.Text{Prefix: "long", Default: "write you text", Charset: "utf8mb4", Collate: "utf8mb4_general_ci"} +// ↪️ longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'write you text' +// tinyblob ➡️ migrator.Text{Prefix: "tiny", Blob: true} +// ↪️ tinyblob COLLATE utf8mb4_unicode_ci NOT NULL +// blob ➡️ migrator.Text{Blob: true} +// ↪️ blob COLLATE utf8mb4_unicode_ci NOT NULL +// mediumblob ➡️ migrator.Text{Prefix: "medium", Blob: true} +// ↪️ mediumblob COLLATE utf8mb4_unicode_ci NOT NULL +// longblob ➡️ migrator.Text{Prefix: "long", Blob: true} +// ↪️ longblob COLLATE utf8mb4_unicode_ci NOT NULL +type Text struct { + Default string + Nullable bool + Comment string + OnUpdate string + + Charset string + Collate string + + Prefix string // tiny, medium, long + Blob bool // for binary +} + +func (t Text) buildRow() string { + sql := t.Prefix + + if t.Blob { + sql += "blob" + } else { + sql += "text" + } + + if t.Charset != "" { + sql += " CHARACTER SET " + t.Charset + } + + if t.Collate != "" { + sql += " COLLATE " + t.Collate + } else if t.Charset == "" { + // use default + sql += " COLLATE utf8mb4_unicode_ci" + } + + if t.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if t.Default != "" { + sql += fmt.Sprintf(" DEFAULT '%s'", t.Default) + } + + if t.OnUpdate != "" { + sql += " ON UPDATE " + t.OnUpdate + } + + if t.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", t.Comment) + } + + return sql +} + +// JSON represents DB column type `json` +// +// Default migrator.JSON will build a sql row: `json NOT NULL` +// Examples: +// ➡️ migrator.JSON{Nullable: true, Comment: "user data"} +// ↪️ json NULL COMMENT 'user data' +// ➡️ migrator.JSON{Default: "{}", OnUpdate: "{}"} +// ↪️ json NOT NULL DEFAULT '{}' ON UPDATE {} +type JSON struct { + Default string + Nullable bool + Comment string + OnUpdate string +} + +func (j JSON) buildRow() string { + sql := "json" + + if j.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if j.Default != "" { + sql += fmt.Sprintf(" DEFAULT '%s'", j.Default) + } + + if j.OnUpdate != "" { + sql += " ON UPDATE " + j.OnUpdate + } + + if j.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", j.Comment) + } + + return sql +} + +// Enum represents choisable value. In database represented by: `enum` or `set` +// +// Default migrator.Enum will build a sql row: `enum('') NOT NULL` +// Examples: +// enum ➡️ migrator.Enum{Values: []string{"on", "off"}, Default: "off", Nullable: true, OnUpdate: "set null"} +// ↪️ enum('on', 'off') NULL DEFAULT 'off' ON UPDATE set null +// set ➡️ migrator.Enum{Values: []string{"1", "2", "3"}, Comment: "options"} +// ↪️ set('1', '2', '3') NOT NULL COMMENT 'options' +type Enum struct { + Default string + Nullable bool + Comment string + OnUpdate string + + Values []string + Multiple bool // "set", otherwise "enum" +} + +func (e Enum) buildRow() string { + sql := "" + + if e.Multiple { + sql += "set" + } else { + sql += "enum" + } + + sql += "('" + strings.Join(e.Values, "', '") + "')" + + if e.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if e.Default != "" { + sql += fmt.Sprintf(" DEFAULT '%s'", e.Default) + } + + if e.OnUpdate != "" { + sql += " ON UPDATE " + e.OnUpdate + } + + if e.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", e.Comment) + } + + return sql +} + +// Bit represents default `bit` column type +// +// Default migrator.Bit will build a sql row: `bit NOT NULL` +// Examples: +// ➡️ migrator.Bit{Precision: 8, Default: "1", Comment: "mario game code"} +// ↪️ bit(8) NOT NULL DEFAULT 1 COMMENT 'mario game code' +// ➡️ migrator.Bit{Precision: 64, Nullable: true, OnUpdate: "set null"} +// ↪️ bit(64) NULL ON UPDATE set null +type Bit struct { + Default string + Nullable bool + Comment string + OnUpdate string + + Precision uint16 +} + +func (b Bit) buildRow() string { + sql := "bit" + + if b.Precision > 0 { + sql += "(" + strconv.Itoa(int(b.Precision)) + ")" + } + + if b.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if b.Default != "" { + sql += " DEFAULT " + b.Default + } + + if b.OnUpdate != "" { + sql += " ON UPDATE " + b.OnUpdate + } + + if b.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", b.Comment) + } + + return sql +} + +// Binary represents binary column type: `binary` or `varbinary` +// +// Default migrator.Binary will build a sql row: `varbinary NOT NULL` +// Examples: +// binary ➡️ migrator.Binary{Fixed: true, Precision: 36, Default: "1", Comment: "uuid"} +// ↪️ binary(36) NOT NULL DEFAULT 1 COMMENT 'uuid' +// varbinary ➡️ migrator.Binary{Precision: 255, Nullable: true, OnUpdate: "set null"} +// ↪️ varbinary(255) NULL ON UPDATE set null +type Binary struct { + Default string + Nullable bool + Comment string + OnUpdate string + + Fixed bool // binary for fixed, otherwise varbinary + Precision uint16 +} + +func (b Binary) buildRow() string { + sql := "" + + if !b.Fixed { + sql += "var" + } + + sql += "binary" + + if b.Precision > 0 { + sql += fmt.Sprintf("(%s)", strconv.Itoa(int(b.Precision))) + } + + if b.Nullable { + sql += " NULL" + } else { + sql += " NOT NULL" + } + + if b.Default != "" { + sql += " DEFAULT " + b.Default + } + + if b.OnUpdate != "" { + sql += " ON UPDATE " + b.OnUpdate + } + + if b.Comment != "" { + sql += fmt.Sprintf(" COMMENT '%s'", b.Comment) + } + + return sql +} diff --git a/column_test.go b/column_test.go new file mode 100644 index 0000000..ac556e2 --- /dev/null +++ b/column_test.go @@ -0,0 +1,545 @@ +package migrator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type testColumnType string + +func (c testColumnType) buildRow() string { + return string(c) +} + +func TestColumnRender(t *testing.T) { + t.Run("it renders row from one column", func(t *testing.T) { + c := columns{column{"test", testColumnType("run")}} + + assert.Equal(t, "`test` run", c.render()) + }) + + t.Run("it renders row from multiple columns", func(t *testing.T) { + c := columns{ + column{"test", testColumnType("run")}, + column{"again", testColumnType("me")}, + } + + assert.Equal(t, "`test` run, `again` me", c.render()) + }) +} + +func TestInteger(t *testing.T) { + t.Run("it builds basic column type", func(t *testing.T) { + c := Integer{} + assert.Equal(t, "int NOT NULL", c.buildRow()) + }) + + t.Run("it build with prefix", func(t *testing.T) { + c := Integer{Prefix: "super"} + assert.Equal(t, "superint NOT NULL", c.buildRow()) + }) + + t.Run("it builds with precision", func(t *testing.T) { + c := Integer{Precision: 20} + assert.Equal(t, "int(20) NOT NULL", c.buildRow()) + }) + + t.Run("it builds unsigned", func(t *testing.T) { + c := Integer{Unsigned: true} + assert.Equal(t, "int unsigned NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := Integer{Nullable: true} + assert.Equal(t, "int NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := Integer{Default: "0"} + assert.Equal(t, "int NOT NULL DEFAULT 0", c.buildRow()) + }) + + t.Run("it builds with autoincrement", func(t *testing.T) { + c := Integer{Autoincrement: true} + assert.Equal(t, "int NOT NULL AUTO_INCREMENT", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := Integer{OnUpdate: "set null"} + assert.Equal(t, "int NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := Integer{Comment: "test"} + assert.Equal(t, "int NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := Integer{ + Prefix: "big", + Precision: 10, + Unsigned: true, + Nullable: true, + Default: "100", + Autoincrement: true, + OnUpdate: "set null", + Comment: "test", + } + + assert.Equal( + t, + "bigint(10) unsigned NULL DEFAULT 100 AUTO_INCREMENT ON UPDATE set null COMMENT 'test'", + c.buildRow(), + ) + }) +} + +func TestFloatable(t *testing.T) { + t.Run("it builds with default type", func(t *testing.T) { + c := Floatable{} + assert.Equal(t, "float NOT NULL", c.buildRow()) + }) + + t.Run("it builds basic column type", func(t *testing.T) { + c := Floatable{Type: "real"} + assert.Equal(t, "real NOT NULL", c.buildRow()) + }) + + t.Run("it builds with precision", func(t *testing.T) { + c := Floatable{Type: "double", Precision: 20} + assert.Equal(t, "double(20) NOT NULL", c.buildRow()) + }) + + t.Run("it builds with precision and scale", func(t *testing.T) { + c := Floatable{Type: "decimal", Precision: 10, Scale: 2} + assert.Equal(t, "decimal(10,2) NOT NULL", c.buildRow()) + }) + + t.Run("it builds unsigned", func(t *testing.T) { + c := Floatable{Unsigned: true} + assert.Equal(t, "float unsigned NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := Floatable{Nullable: true} + assert.Equal(t, "float NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := Floatable{Default: "0.0"} + assert.Equal(t, "float NOT NULL DEFAULT 0.0", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := Floatable{OnUpdate: "set null"} + assert.Equal(t, "float NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := Floatable{Comment: "test"} + assert.Equal(t, "float NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := Floatable{ + Type: "decimal", + Precision: 10, + Scale: 2, + Unsigned: true, + Nullable: true, + Default: "100.0", + OnUpdate: "set null", + Comment: "test", + } + + assert.Equal( + t, + "decimal(10,2) unsigned NULL DEFAULT 100.0 ON UPDATE set null COMMENT 'test'", + c.buildRow(), + ) + }) +} + +func TestTimeable(t *testing.T) { + t.Run("it builds with default type", func(t *testing.T) { + c := Timable{} + assert.Equal(t, "timestamp NOT NULL", c.buildRow()) + }) + + t.Run("it builds basic column type", func(t *testing.T) { + c := Timable{Type: "datetime"} + assert.Equal(t, "datetime NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := Timable{Nullable: true} + assert.Equal(t, "timestamp NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := Timable{Default: "CURRENT_TIMESTAMP"} + assert.Equal(t, "timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := Timable{OnUpdate: "set null"} + assert.Equal(t, "timestamp NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := Timable{Comment: "test"} + assert.Equal(t, "timestamp NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := Timable{ + Type: "datetime", + Nullable: true, + Default: "CURRENT_TIMESTAMP", + OnUpdate: "CURRENT_TIMESTAMP", + Comment: "test", + } + + assert.Equal( + t, + "datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'test'", + c.buildRow(), + ) + }) +} + +func TestString(t *testing.T) { + t.Run("it builds with default type", func(t *testing.T) { + c := String{} + assert.Equal(t, "varchar COLLATE utf8mb4_unicode_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds fixed", func(t *testing.T) { + c := String{Fixed: true} + assert.Equal(t, "char COLLATE utf8mb4_unicode_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds with precision", func(t *testing.T) { + c := String{Precision: 255} + assert.Equal(t, "varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds with charset", func(t *testing.T) { + c := String{Charset: "utf8"} + assert.Equal(t, "varchar CHARACTER SET utf8 NOT NULL", c.buildRow()) + }) + + t.Run("it builds with collate", func(t *testing.T) { + c := String{Collate: "utf8mb4_general_ci"} + assert.Equal(t, "varchar COLLATE utf8mb4_general_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := String{Nullable: true} + assert.Equal(t, "varchar COLLATE utf8mb4_unicode_ci NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := String{Default: "done"} + assert.Equal(t, "varchar COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'done'", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := String{OnUpdate: "set null"} + assert.Equal(t, "varchar COLLATE utf8mb4_unicode_ci NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := String{Comment: "test"} + assert.Equal(t, "varchar COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := String{ + Fixed: true, + Precision: 36, + Nullable: true, + Charset: "utf8mb4", + Collate: "utf8mb4_general_ci", + Default: "nice", + OnUpdate: "set null", + Comment: "test", + } + + assert.Equal( + t, + "char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'nice' ON UPDATE set null COMMENT 'test'", + c.buildRow(), + ) + }) +} + +func TestText(t *testing.T) { + t.Run("it builds with default type", func(t *testing.T) { + c := Text{} + assert.Equal(t, "text COLLATE utf8mb4_unicode_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds with prefix", func(t *testing.T) { + c := Text{Prefix: "medium"} + assert.Equal(t, "mediumtext COLLATE utf8mb4_unicode_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds blob", func(t *testing.T) { + c := Text{Blob: true} + assert.Equal(t, "blob COLLATE utf8mb4_unicode_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds blob with prefix", func(t *testing.T) { + c := Text{Prefix: "tiny", Blob: true} + assert.Equal(t, "tinyblob COLLATE utf8mb4_unicode_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds with charset", func(t *testing.T) { + c := Text{Charset: "utf8"} + assert.Equal(t, "text CHARACTER SET utf8 NOT NULL", c.buildRow()) + }) + + t.Run("it builds with collate", func(t *testing.T) { + c := Text{Collate: "utf8mb4_general_ci"} + assert.Equal(t, "text COLLATE utf8mb4_general_ci NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := Text{Nullable: true} + assert.Equal(t, "text COLLATE utf8mb4_unicode_ci NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := Text{Default: "done"} + assert.Equal(t, "text COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'done'", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := Text{OnUpdate: "set null"} + assert.Equal(t, "text COLLATE utf8mb4_unicode_ci NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := Text{Comment: "test"} + assert.Equal(t, "text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := Text{ + Prefix: "long", + Blob: true, + Nullable: true, + Charset: "utf8mb4", + Collate: "utf8mb4_general_ci", + Default: "nice", + OnUpdate: "set null", + Comment: "test", + } + + assert.Equal( + t, + "longblob CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'nice' ON UPDATE set null COMMENT 'test'", + c.buildRow(), + ) + }) +} + +func TestJson(t *testing.T) { + t.Run("it builds with default type", func(t *testing.T) { + c := JSON{} + assert.Equal(t, "json NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := JSON{Nullable: true} + assert.Equal(t, "json NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := JSON{Default: "{}"} + assert.Equal(t, "json NOT NULL DEFAULT '{}'", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := JSON{OnUpdate: "set null"} + assert.Equal(t, "json NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := JSON{Comment: "test"} + assert.Equal(t, "json NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := JSON{ + Nullable: true, + Default: "{}", + OnUpdate: "set null", + Comment: "test", + } + + assert.Equal( + t, + "json NULL DEFAULT '{}' ON UPDATE set null COMMENT 'test'", + c.buildRow(), + ) + }) +} + +func TestEnum(t *testing.T) { + t.Run("it builds with default type", func(t *testing.T) { + c := Enum{} + assert.Equal(t, "enum('') NOT NULL", c.buildRow()) + }) + + t.Run("it builds with multiple flag", func(t *testing.T) { + c := Enum{Multiple: true} + assert.Equal(t, "set('') NOT NULL", c.buildRow()) + }) + + t.Run("it builds with values", func(t *testing.T) { + c := Enum{Values: []string{"active", "inactive"}} + assert.Equal(t, "enum('active', 'inactive') NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := Enum{Nullable: true} + assert.Equal(t, "enum('') NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := Enum{Default: "valid"} + assert.Equal(t, "enum('') NOT NULL DEFAULT 'valid'", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := Enum{OnUpdate: "set null"} + assert.Equal(t, "enum('') NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := Enum{Comment: "test"} + assert.Equal(t, "enum('') NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := Enum{ + Multiple: true, + Values: []string{"male", "female", "other"}, + Nullable: true, + Default: "male,female", + OnUpdate: "set null", + Comment: "test", + } + + assert.Equal( + t, + "set('male', 'female', 'other') NULL DEFAULT 'male,female' ON UPDATE set null COMMENT 'test'", + c.buildRow(), + ) + }) +} + +func TestBit(t *testing.T) { + t.Run("it builds basic column type", func(t *testing.T) { + c := Bit{} + assert.Equal(t, "bit NOT NULL", c.buildRow()) + }) + + t.Run("it builds with precision", func(t *testing.T) { + c := Bit{Precision: 20} + assert.Equal(t, "bit(20) NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := Bit{Nullable: true} + assert.Equal(t, "bit NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := Bit{Default: "1"} + assert.Equal(t, "bit NOT NULL DEFAULT 1", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := Bit{OnUpdate: "set null"} + assert.Equal(t, "bit NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := Bit{Comment: "test"} + assert.Equal(t, "bit NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := Bit{ + Precision: 10, + Nullable: true, + Default: "0", + OnUpdate: "set null", + Comment: "test", + } + + assert.Equal( + t, + "bit(10) NULL DEFAULT 0 ON UPDATE set null COMMENT 'test'", + c.buildRow(), + ) + }) +} + +func TestBinary(t *testing.T) { + t.Run("it builds with default type", func(t *testing.T) { + c := Binary{} + assert.Equal(t, "varbinary NOT NULL", c.buildRow()) + }) + + t.Run("it builds fixed", func(t *testing.T) { + c := Binary{Fixed: true} + assert.Equal(t, "binary NOT NULL", c.buildRow()) + }) + + t.Run("it builds with precision", func(t *testing.T) { + c := Binary{Precision: 255} + assert.Equal(t, "varbinary(255) NOT NULL", c.buildRow()) + }) + + t.Run("it builds nullable column type", func(t *testing.T) { + c := Binary{Nullable: true} + assert.Equal(t, "varbinary NULL", c.buildRow()) + }) + + t.Run("it builds with default value", func(t *testing.T) { + c := Binary{Default: "1"} + assert.Equal(t, "varbinary NOT NULL DEFAULT 1", c.buildRow()) + }) + + t.Run("it builds with on_update setter", func(t *testing.T) { + c := Binary{OnUpdate: "set null"} + assert.Equal(t, "varbinary NOT NULL ON UPDATE set null", c.buildRow()) + }) + + t.Run("it builds with comment", func(t *testing.T) { + c := Binary{Comment: "test"} + assert.Equal(t, "varbinary NOT NULL COMMENT 'test'", c.buildRow()) + }) + + t.Run("it builds string with all parameters", func(t *testing.T) { + c := Binary{ + Fixed: true, + Precision: 36, + Nullable: true, + Default: "1", + OnUpdate: "set null", + Comment: "test", + } + + assert.Equal( + t, + "binary(36) NULL DEFAULT 1 ON UPDATE set null COMMENT 'test'", + c.buildRow(), + ) + }) +} diff --git a/foreign.go b/foreign.go new file mode 100644 index 0000000..d24c94e --- /dev/null +++ b/foreign.go @@ -0,0 +1,58 @@ +// Package migrator represents MySQL database migrator +package migrator + +import ( + "fmt" + "strings" +) + +type foreigns []foreign + +func (f foreigns) render() string { + values := []string{} + + for _, foreign := range f { + values = append(values, foreign.render()) + } + + return strings.Join(values, ", ") +} + +type foreign struct { + key string + column string + reference string // reference field + on string // reference table + onUpdate string + onDelete string +} + +func (f foreign) render() string { + if f.key == "" || f.column == "" || f.on == "" || f.reference == "" { + return "" + } + + sql := fmt.Sprintf("CONSTRAINT `%s` FOREIGN KEY (`%s`) REFERENCES `%s` (`%s`)", f.key, f.column, f.on, f.reference) + if referenceOptions.has(strings.ToUpper(f.onDelete)) { + sql += " ON DELETE " + strings.ToUpper(f.onDelete) + } + if referenceOptions.has(strings.ToUpper(f.onUpdate)) { + sql += " ON UPDATE " + strings.ToUpper(f.onUpdate) + } + + return sql +} + +var referenceOptions = list{"SET NULL", "CASCADE", "RESTRICT", "NO ACTION", "SET DEFAULT"} + +type list []string + +func (l list) has(value string) bool { + for _, item := range l { + if item == value { + return true + } + } + + return false +} diff --git a/foreign_test.go b/foreign_test.go new file mode 100644 index 0000000..09836ed --- /dev/null +++ b/foreign_test.go @@ -0,0 +1,72 @@ +package migrator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestForeigns(t *testing.T) { + t.Run("it returns empty on empty keys", func(t *testing.T) { + f := foreigns{foreign{}} + + assert.Equal(t, "", f.render()) + }) + + t.Run("it renders row from one foreign", func(t *testing.T) { + f := foreigns{foreign{key: "idx_foreign", column: "test_id", reference: "id", on: "tests"}} + + assert.Equal(t, "CONSTRAINT `idx_foreign` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`)", f.render()) + }) + + t.Run("it renders row from multiple foreigns", func(t *testing.T) { + f := foreigns{ + foreign{key: "idx_foreign", column: "test_id", reference: "id", on: "tests"}, + foreign{key: "foreign_idx", column: "random_id", reference: "id", on: "randoms"}, + } + + assert.Equal( + t, + "CONSTRAINT `idx_foreign` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`), CONSTRAINT `foreign_idx` FOREIGN KEY (`random_id`) REFERENCES `randoms` (`id`)", + f.render(), + ) + }) +} + +func TestForeign(t *testing.T) { + t.Run("it builds base constraint", func(t *testing.T) { + f := foreign{key: "foreign_idx", column: "test_id", reference: "id", on: "tests"} + + assert.Equal(t, "CONSTRAINT `foreign_idx` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`)", f.render()) + }) + + t.Run("it builds contraint with on_update", func(t *testing.T) { + f := foreign{key: "foreign_idx", column: "test_id", reference: "id", on: "tests", onUpdate: "no action"} + + assert.Equal(t, "CONSTRAINT `foreign_idx` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`) ON UPDATE NO ACTION", f.render()) + }) + + t.Run("it builds contraint without invalid on_update", func(t *testing.T) { + f := foreign{key: "foreign_idx", column: "test_id", reference: "id", on: "tests", onUpdate: "null"} + + assert.Equal(t, "CONSTRAINT `foreign_idx` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`)", f.render()) + }) + + t.Run("it builds contraint with on_update", func(t *testing.T) { + f := foreign{key: "foreign_idx", column: "test_id", reference: "id", on: "tests", onDelete: "set default"} + + assert.Equal(t, "CONSTRAINT `foreign_idx` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`) ON DELETE SET DEFAULT", f.render()) + }) + + t.Run("it builds contraint without invalid on_update", func(t *testing.T) { + f := foreign{key: "foreign_idx", column: "test_id", reference: "id", on: "tests", onDelete: "default"} + + assert.Equal(t, "CONSTRAINT `foreign_idx` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`)", f.render()) + }) + + t.Run("it builds full contraint", func(t *testing.T) { + f := foreign{key: "foreign_idx", column: "test_id", reference: "id", on: "tests", onUpdate: "cascade", onDelete: "restrict"} + + assert.Equal(t, "CONSTRAINT `foreign_idx` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE", f.render()) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..92b57c5 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/larapulse/migrator + +go 1.13 + +require ( + github.com/DATA-DOG/go-sqlmock v1.4.1 + github.com/stretchr/testify v1.6.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f5ac1e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/DATA-DOG/go-sqlmock v1.4.1 h1:ThlnYciV1iM/V0OSF/dtkqWb6xo5qITT1TJBG1MRDJM= +github.com/DATA-DOG/go-sqlmock v1.4.1/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/key.go b/key.go new file mode 100644 index 0000000..ad49a09 --- /dev/null +++ b/key.go @@ -0,0 +1,48 @@ +// Package migrator represents MySQL database migrator +package migrator + +import "strings" + +type keys []key + +func (k keys) render() string { + values := []string{} + + for _, key := range k { + value := key.render() + if value != "" { + values = append(values, value) + } + } + + return strings.Join(values, ", ") +} + +type key struct { + name string + typ string // primary, unique + columns []string +} + +var keyTypes = list{"PRIMARY", "UNIQUE"} + +func (k key) render() string { + if len(k.columns) == 0 { + return "" + } + + sql := "" + if keyTypes.has(strings.ToUpper(k.typ)) { + sql += strings.ToUpper(k.typ) + " " + } + + sql += "KEY" + + if k.name != "" { + sql += " `" + k.name + "`" + } + + sql += " (`" + strings.Join(k.columns, "`, `") + "`)" + + return sql +} diff --git a/key_test.go b/key_test.go new file mode 100644 index 0000000..1cdc950 --- /dev/null +++ b/key_test.go @@ -0,0 +1,66 @@ +package migrator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeys(t *testing.T) { + t.Run("it returns empty on empty keys", func(t *testing.T) { + k := keys{key{}} + + assert.Equal(t, "", k.render()) + }) + + t.Run("it renders row from one key", func(t *testing.T) { + k := keys{key{columns: []string{"test_id"}}} + + assert.Equal(t, "KEY (`test_id`)", k.render()) + }) + + t.Run("it renders row from multiple keys", func(t *testing.T) { + k := keys{ + key{columns: []string{"test_id"}}, + key{columns: []string{"random_id"}}, + } + + assert.Equal( + t, + "KEY (`test_id`), KEY (`random_id`)", + k.render(), + ) + }) +} + +func TestKey(t *testing.T) { + t.Run("it returns empty on empty keys", func(t *testing.T) { + k := key{} + + assert.Equal(t, "", k.render()) + }) + + t.Run("it skips type if it is not in valid list", func(t *testing.T) { + k := key{typ: "random", columns: []string{"test_id"}} + + assert.Equal(t, "KEY (`test_id`)", k.render()) + }) + + t.Run("it renders with type", func(t *testing.T) { + k := key{typ: "primary", columns: []string{"test_id"}} + + assert.Equal(t, "PRIMARY KEY (`test_id`)", k.render()) + }) + + t.Run("it renders with multiple columns", func(t *testing.T) { + k := key{typ: "unique", columns: []string{"test_id", "random_id"}} + + assert.Equal(t, "UNIQUE KEY (`test_id`, `random_id`)", k.render()) + }) + + t.Run("it renders with name", func(t *testing.T) { + k := key{name: "random_idx", columns: []string{"test_id"}} + + assert.Equal(t, "KEY `random_idx` (`test_id`)", k.render()) + }) +} diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1b62af79385c5a1ed6b6c9a1d2623785bcf08023 GIT binary patch literal 48800 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 UDy7lB1P1y*4`GPciW-OfALO*)&Hw-a literal 0 HcmV?d00001 diff --git a/migration.go b/migration.go new file mode 100644 index 0000000..d26148d --- /dev/null +++ b/migration.go @@ -0,0 +1,88 @@ +// Package migrator represents MySQL database migrator +package migrator + +import "database/sql" + +type executableSQL interface { + Exec(query string, args ...interface{}) (sql.Result, error) +} + +// Migration represents migration entity +// +// Name should be unique name to specify migration. It is up to you to choose the name you like +// Up() should return Schema with prepared commands to be migrated +// Down() should return Schema with prepared commands to be reverted +// Transaction optinal flag to enable transaction for migration +// +// Example: +// var migration = migrator.Migration{ +// Name: "19700101_0001_create_posts_table", +// Up: func() migrator.Schema { +// var s migrator.Schema +// posts := migrator.Table{Name: "posts"} +// +// posts.UniqueID("id") +// posts.Column("title", migrator.String{Precision: 64}) +// posts.Column("content", migrator.Text{}) +// posts.Timestamps() +// +// s.CreateTable(posts) +// +// return s +// }, +// Down: func() migrator.Schema { +// var s migrator.Schema +// +// s.DropTable("posts") +// +// return s +// }, +// } +type Migration struct { + Name string + Up func() Schema + Down func() Schema + Transaction bool +} + +func (m Migration) exec(db *sql.DB, commands ...command) error { + if m.Transaction { + return runInTransaction(db, commands...) + } + + return run(db, commands...) +} + +func runInTransaction(db *sql.DB, commands ...command) error { + tx, err := db.Begin() + if err != nil { + return err + } + + err = run(tx, commands...) + if err != nil { + tx.Rollback() + return err + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func run(db executableSQL, commands ...command) error { + for _, command := range commands { + sql := command.toSQL() + if sql == "" { + return ErrNoSQLCommandsToRun + } + if _, err := db.Exec(sql); err != nil { + return err + } + } + + return nil +} diff --git a/migration_test.go b/migration_test.go new file mode 100644 index 0000000..33c7fac --- /dev/null +++ b/migration_test.go @@ -0,0 +1,204 @@ +package migrator + +import ( + "database/sql" + "errors" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" +) + +var ( + errTestDBExecFailed = errors.New("DB exec command failed") + errTestDBQueryFailed = errors.New("DB query command failed") + errTestDBTransactionFailed = errors.New("DB transaction failed") + errTestLastInsertID = errors.New("Failed to get last insert ID") + errTestAffectedRows = errors.New("Failed to amount of affected rows") +) + +type testDummyCommand string + +func (c testDummyCommand) toSQL() string { + return string(c) +} + +func testDBConnection(t *testing.T) (db *sql.DB, mock sqlmock.Sqlmock, resetDB func()) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + + resetDB = func() { + defer db.Close() + + // we make sure that all expectations were met + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } + } + + return +} + +func TestMigrationExec(t *testing.T) { + t.Run("it executes migration in transaction", func(t *testing.T) { + m := Migration{Transaction: true} + + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{ + testCommand("test"), + testDummyCommand("test"), + } + + mock.ExpectBegin() + mock.ExpectExec(commands[0].toSQL()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(commands[1].toSQL()).WillReturnResult(sqlmock.NewResult(2, 1)) + mock.ExpectCommit() + + // now we execute our method + if err := m.exec(db, commands...); err != nil { + t.Errorf("error was not expected while running query: %s", err) + } + }) + + t.Run("it executes general transaction", func(t *testing.T) { + m := Migration{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{ + testCommand("test"), + testDummyCommand("test"), + } + mock.ExpectExec(commands[0].toSQL()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(commands[1].toSQL()).WillReturnResult(sqlmock.NewResult(2, 1)) + + // now we execute our method + if err := m.exec(db, commands...); err != nil { + t.Errorf("error was not expected while running query: %s", err) + } + }) +} + +func TestRunInTransaction(t *testing.T) { + t.Run("it returns an error if transaction wasn't started", func(t *testing.T) { + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{} + want := sqlmock.ErrCancelled + mock.ExpectBegin().WillReturnError(want) + + // now we execute our method + got := runInTransaction(db, commands...) + assert.Equal(t, want, got) + }) + + t.Run("it rolled back transaction in case of error", func(t *testing.T) { + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{testDummyCommand("run")} + want := sqlmock.ErrCancelled + + mock.ExpectBegin() + mock.ExpectExec(commands[0].toSQL()).WillReturnError(want) + mock.ExpectRollback() + + // now we execute our method + got := runInTransaction(db, commands...) + assert.Equal(t, want, got) + }) + + t.Run("it returns an error if committing transaction was unsuccessful", func(t *testing.T) { + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{} + want := sqlmock.ErrCancelled + + mock.ExpectBegin() + mock.ExpectCommit().WillReturnError(want) + + // now we execute our method + got := runInTransaction(db, commands...) + assert.Equal(t, want, got) + }) + + t.Run("it executes all commands", func(t *testing.T) { + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{ + testCommand("test"), + testDummyCommand("test"), + } + + mock.ExpectBegin() + mock.ExpectExec(commands[0].toSQL()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(commands[1].toSQL()).WillReturnResult(sqlmock.NewResult(2, 1)) + mock.ExpectCommit() + + // now we execute our method + if err := runInTransaction(db, commands...); err != nil { + t.Errorf("error was not expected while running query: %s", err) + } + }) +} + +func TestRun(t *testing.T) { + t.Run("it returns an error on invalid command", func(t *testing.T) { + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{ + testCommand("test"), + testDummyCommand(""), + } + + mock.ExpectExec(commands[0].toSQL()).WillReturnResult(sqlmock.NewResult(1, 1)) + + err := run(db, commands...) + + assert.Error(t, err) + assert.Equal(t, ErrNoSQLCommandsToRun, err) + }) + + t.Run("it returns an error on DB command execution", func(t *testing.T) { + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{ + testCommand("test"), + testDummyCommand("dead"), + } + + mock.ExpectExec(commands[0].toSQL()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(commands[1].toSQL()).WillReturnError(errTestDBExecFailed) + + err := run(db, commands...) + + assert.Error(t, err) + assert.Equal(t, errTestDBExecFailed, err) + }) + + t.Run("it executes all commands", func(t *testing.T) { + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + commands := []command{ + testCommand("test"), + testDummyCommand("test"), + } + + mock.ExpectExec(commands[0].toSQL()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(commands[1].toSQL()).WillReturnResult(sqlmock.NewResult(2, 1)) + + err := run(db, commands...) + + assert.Nil(t, err) + }) +} diff --git a/migrator.go b/migrator.go new file mode 100644 index 0000000..dbb58d2 --- /dev/null +++ b/migrator.go @@ -0,0 +1,309 @@ +// Package migrator represents MySQL database migrator +package migrator + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +const migrationTable = "migrations" + +var ( + // ErrTableNotExists returned when migration table not found + ErrTableNotExists = errors.New("Migration table does not exists") + + // ErrNoMigrationDefined returned when no migations defined in the migrations pool + ErrNoMigrationDefined = errors.New("No migrations defined") + + // ErrEmptyRollbackStack returned when nothing can be reverted + ErrEmptyRollbackStack = errors.New("Nothing to rollback, there are no migration executed") + + // ErrMissingMigrationName returned when migration name is missing + ErrMissingMigrationName = errors.New("Missing migration name") + + // ErrNoSQLCommandsToRun returned when migration is invalid and has not commands in the pool + ErrNoSQLCommandsToRun = errors.New("There is no command to be executed") +) + +type migrationEntry struct { + id uint64 + name string + batch uint64 + appliedAt time.Time +} + +// Migrator represents a struct with migrations, that should be executed +// +// Default migration table name is `migrations`, but it can be re-defined +// Pool is a list of migrations that should be migrated +type Migrator struct { + // Name of the table to track executed migrations + TableName string + // stack of migrations + Pool []Migration + executed []migrationEntry +} + +// Migrate run all migrations from pool and stores in migration table executed migration +func (m Migrator) Migrate(db *sql.DB) (migrated []string, err error) { + if len(m.Pool) == 0 { + return migrated, ErrNoMigrationDefined + } + + if err := m.checkMigrationPool(); err != nil { + return migrated, err + } + + if err := m.createMigrationTable(db); err != nil { + return migrated, fmt.Errorf("Migration table failed to be created: %v", err) + } + + if err := m.fetchExecuted(db); err != nil { + return migrated, err + } + + batch := m.batch() + 1 + table := m.table() + + for _, item := range m.Pool { + if m.isExecuted(item.Name) { + continue + } + + s := item.Up() + if len(s.pool) == 0 { + return migrated, ErrNoSQLCommandsToRun + } + if err := item.exec(db, s.pool...); err != nil { + return migrated, err + } + + entry := migrationEntry{name: item.Name, batch: batch} + sql := fmt.Sprintf("INSERT INTO `%s` (`name`, `batch`) VALUES (\"%s\", %d)", table, entry.name, entry.batch) + + if _, err := db.Exec(sql); err != nil { + return migrated, err + } + + migrated = append(migrated, item.Name) + } + + return migrated, nil +} + +// Rollback reverts last executed batch of migratios +func (m Migrator) Rollback(db *sql.DB) (reverted []string, err error) { + if len(m.Pool) == 0 { + return reverted, ErrNoMigrationDefined + } + + if err := m.checkMigrationPool(); err != nil { + return reverted, err + } + + if !m.hasTable(db) { + return reverted, ErrTableNotExists + } + + if err := m.fetchExecuted(db); err != nil { + return reverted, err + } + + if len(m.executed) == 0 { + return reverted, ErrEmptyRollbackStack + } + + table := m.table() + revertable := m.lastBatchExecuted() + + for i := len(revertable) - 1; i >= 0; i-- { + name := revertable[i].name + + for j := len(m.Pool) - 1; j >= 0; j-- { + item := m.Pool[j] + + if item.Name == name { + s := item.Down() + if len(s.pool) == 0 { + return reverted, ErrNoSQLCommandsToRun + } + if err := item.exec(db, s.pool...); err != nil { + return reverted, err + } + + if _, err := db.Exec(fmt.Sprintf("DELETE FROM %s WHERE id = ?", table), revertable[i].id); err != nil { + return reverted, err + } + + reverted = append(reverted, name) + } + } + } + + return reverted, nil +} + +// Revert reverts all executed migration from the pool +func (m Migrator) Revert(db *sql.DB) (reverted []string, err error) { + if len(m.Pool) == 0 { + return reverted, ErrNoMigrationDefined + } + + if err := m.checkMigrationPool(); err != nil { + return reverted, err + } + + if !m.hasTable(db) { + return reverted, ErrTableNotExists + } + + if err := m.fetchExecuted(db); err != nil { + return reverted, err + } + + if len(m.executed) == 0 { + return reverted, ErrEmptyRollbackStack + } + + table := m.table() + + for i := len(m.executed) - 1; i >= 0; i-- { + name := m.executed[i].name + + for j := len(m.Pool) - 1; j >= 0; j-- { + item := m.Pool[j] + + if item.Name == name { + s := item.Down() + if len(s.pool) == 0 { + return reverted, ErrNoSQLCommandsToRun + } + if err := item.exec(db, s.pool...); err != nil { + return reverted, err + } + + if _, err := db.Exec(fmt.Sprintf("DELETE FROM %s WHERE id = ?", table), m.executed[i].id); err != nil { + return reverted, err + } + + reverted = append(reverted, name) + } + } + } + + return reverted, nil +} + +func (m Migrator) checkMigrationPool() error { + var names []string + + for _, item := range m.Pool { + if item.Name == "" { + return ErrMissingMigrationName + } + + for _, exist := range names { + if exist == item.Name { + return fmt.Errorf(`Migration "%s" is duplicated in the pool`, exist) + } + } + + names = append(names, item.Name) + } + + return nil +} + +func (m Migrator) createMigrationTable(db *sql.DB) error { + if m.hasTable(db) { + return nil + } + + sql := fmt.Sprintf( + "CREATE TABLE %s (%s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + m.table(), + strings.Join([]string{ + "id int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY", + "name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL", + "batch int(11) NOT NULL", + "applied_at timestamp NULL DEFAULT CURRENT_TIMESTAMP", + }, ", "), + ) + + _, err := db.Exec(sql) + + return err +} + +func (m Migrator) hasTable(db *sql.DB) bool { + _, hasTable := db.Query("SELECT * FROM " + m.table()) + + return hasTable == nil +} + +func (m Migrator) table() string { + table := m.TableName + if table == "" { + table = migrationTable + } + + return table +} + +func (m Migrator) batch() uint64 { + var batch uint64 + + for _, item := range m.executed { + if item.batch > batch { + batch = item.batch + } + } + + return batch +} + +func (m *Migrator) fetchExecuted(db *sql.DB) error { + rows, err := db.Query("SELECT id, name, batch, applied_at FROM " + m.table() + " ORDER BY applied_at ASC") + if err != nil { + return err + } + m.executed = []migrationEntry{} + + for rows.Next() { + var entry migrationEntry + + if err := rows.Scan(&entry.id, &entry.name, &entry.batch, &entry.appliedAt); err != nil { + return err + } + + m.executed = append(m.executed, entry) + } + + return nil +} + +func (m Migrator) isExecuted(name string) bool { + for _, item := range m.executed { + if item.name == name { + return true + } + } + + return false +} + +func (m Migrator) lastBatchExecuted() []migrationEntry { + batch := m.batch() + var result []migrationEntry + + for _, item := range m.executed { + if item.batch == batch { + result = append(result, item) + } + } + + return result +} diff --git a/migrator_test.go b/migrator_test.go new file mode 100644 index 0000000..0f17e14 --- /dev/null +++ b/migrator_test.go @@ -0,0 +1,841 @@ +package migrator + +import ( + "fmt" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" +) + +func TestMigrate(t *testing.T) { + t.Run("it fails when migration pool is empty", func(t *testing.T) { + m := Migrator{} + db, _, resetDB := testDBConnection(t) + defer resetDB() + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoMigrationDefined, err) + }) + + t.Run("it fails when there is invalid item in the migration pool", func(t *testing.T) { + migration := Migration{} + m := Migrator{Pool: []Migration{migration}} + db, _, resetDB := testDBConnection(t) + defer resetDB() + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 0) + assert.Error(t, err) + assert.Equal(t, ErrMissingMigrationName, err) + }) + + t.Run("it fails when migration table creation failed", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT").WillReturnRows().WillReturnError(errTestDBQueryFailed) + mock.ExpectExec("CREATE").WillReturnError(errTestDBExecFailed) + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 0) + assert.Error(t, err) + assert.Equal(t, fmt.Errorf("Migration table failed to be created: %v", errTestDBExecFailed), err) + }) + + t.Run("it fails while fetching executed list", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnError(errTestDBExecFailed) + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 0) + assert.Error(t, err) + assert.Equal(t, errTestDBExecFailed, err) + }) + + t.Run("it skips execution when it was already executed", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "test", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 0) + assert.Nil(t, err) + }) + + t.Run("it fails executing empty list of migrations", func(t *testing.T) { + migration := Migration{Name: "test", Up: func() Schema { + var s Schema + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "new", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoSQLCommandsToRun, err) + }) + + t.Run("it fails executing migration commands", func(t *testing.T) { + migration := Migration{Name: "test", Up: func() Schema { + var s Schema + s.pool = append(s.pool, testDummyCommand("")) + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "new", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoSQLCommandsToRun, err) + }) + + t.Run("it fails while storing executed migration info", func(t *testing.T) { + migration := Migration{Name: "test", Up: func() Schema { + var s Schema + s.DropTable("test", false, "") + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "new", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + mock.ExpectExec("DROP").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("INSERT").WillReturnError(errTestDBExecFailed) + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 0) + assert.Error(t, err) + assert.Equal(t, errTestDBExecFailed, err) + }) + + t.Run("it executes migrations and returns list of migrated items", func(t *testing.T) { + migration := Migration{Name: "test", Up: func() Schema { + var s Schema + s.DropTable("test", false, "") + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "new", 4, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + mock.ExpectExec("DROP").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(`INSERT .* VALUES \("test", 5\)`).WillReturnResult(sqlmock.NewResult(1, 1)) + + migrated, err := m.Migrate(db) + + assert.Len(t, migrated, 1) + assert.Equal(t, migrated[0], "test") + assert.Nil(t, err) + }) +} + +func TestRollback(t *testing.T) { + t.Run("it fails when migration pool is empty", func(t *testing.T) { + m := Migrator{} + db, _, resetDB := testDBConnection(t) + defer resetDB() + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoMigrationDefined, err) + }) + + t.Run("it fails when there is invalid item in the migration pool", func(t *testing.T) { + migration := Migration{} + m := Migrator{Pool: []Migration{migration}} + db, _, resetDB := testDBConnection(t) + defer resetDB() + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrMissingMigrationName, err) + }) + + t.Run("it fails when migration table missing", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT").WillReturnRows().WillReturnError(errTestDBQueryFailed) + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrTableNotExists, err) + }) + + t.Run("it fails while fetching executed list", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnError(errTestDBExecFailed) + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, errTestDBExecFailed, err) + }) + + t.Run("it exits when executed list is empty", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(sqlmock.NewRows([]string{})) + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrEmptyRollbackStack, err) + }) + + t.Run("it does nothing when executed migration not in the migration pool", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + s.pool = append(s.pool, testDummyCommand("")) + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "new", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Nil(t, err) + }) + + t.Run("it fails executing empty list of commands", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "test", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoSQLCommandsToRun, err) + }) + + t.Run("it fails executing migration commands", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + s.pool = append(s.pool, testDummyCommand("")) + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "test", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoSQLCommandsToRun, err) + }) + + t.Run("it fails while removing executed migration info", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + s.DropTable("test", false, "") + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "test", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + mock.ExpectExec("DROP").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("DELETE").WillReturnError(errTestDBExecFailed) + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, errTestDBExecFailed, err) + }) + + t.Run("it roll back migrations and returns list of reverted items", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + s.DropTable("test", false, "") + return s + }} + m := Migrator{Pool: []Migration{migration, {Name: "new"}}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}). + AddRow(1, "test", 4, time.Now()). + AddRow(2, "new", 3, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + mock.ExpectExec("DROP").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("DELETE FROM migrations WHERE id = ?").WithArgs(1).WillReturnResult(sqlmock.NewResult(1, 1)) + + reverted, err := m.Rollback(db) + + assert.Len(t, reverted, 1) + assert.Equal(t, reverted[0], "test") + assert.Nil(t, err) + }) +} + +func TestRevert(t *testing.T) { + t.Run("it fails when migration pool is empty", func(t *testing.T) { + m := Migrator{} + db, _, resetDB := testDBConnection(t) + defer resetDB() + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoMigrationDefined, err) + }) + + t.Run("it fails when there is invalid item in the migration pool", func(t *testing.T) { + migration := Migration{} + m := Migrator{Pool: []Migration{migration}} + db, _, resetDB := testDBConnection(t) + defer resetDB() + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrMissingMigrationName, err) + }) + + t.Run("it fails when migration table missing", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT").WillReturnRows().WillReturnError(errTestDBQueryFailed) + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrTableNotExists, err) + }) + + t.Run("it fails while fetching executed list", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnError(errTestDBExecFailed) + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, errTestDBExecFailed, err) + }) + + t.Run("it exits when executed list is empty", func(t *testing.T) { + migration := Migration{Name: "test"} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(sqlmock.NewRows([]string{})) + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrEmptyRollbackStack, err) + }) + + t.Run("it does nothing when executed migration not in the migration pool", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + s.pool = append(s.pool, testDummyCommand("")) + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "new", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Nil(t, err) + }) + + t.Run("it fails executing empty list of commands", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "test", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoSQLCommandsToRun, err) + }) + + t.Run("it fails executing migration commands", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + s.pool = append(s.pool, testDummyCommand("")) + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "test", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, ErrNoSQLCommandsToRun, err) + }) + + t.Run("it fails while removing executed migration info", func(t *testing.T) { + migration := Migration{Name: "test", Down: func() Schema { + var s Schema + s.DropTable("test", false, "") + return s + }} + m := Migrator{Pool: []Migration{migration}} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}).AddRow(1, "test", 1, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + mock.ExpectExec("DROP").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("DELETE").WillReturnError(errTestDBExecFailed) + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 0) + assert.Error(t, err) + assert.Equal(t, errTestDBExecFailed, err) + }) + + t.Run("it roll back migrations and returns list of reverted items", func(t *testing.T) { + m := Migrator{Pool: []Migration{ + {Name: "test", Down: func() Schema { + var s Schema + s.DropTable("test", false, "") + return s + }}, + {Name: "new", Down: func() Schema { + var s Schema + s.DropTable("test", false, "") + return s + }}, + }} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}). + AddRow(1, "test", 4, time.Now()). + AddRow(2, "new", 3, time.Now()) + + mock.ExpectQuery("SELECT").WillReturnRows() + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + mock.ExpectExec("DROP").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("DELETE FROM migrations WHERE id = ?").WithArgs(2).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("DROP").WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("DELETE FROM migrations WHERE id = ?").WithArgs(1).WillReturnResult(sqlmock.NewResult(1, 1)) + + reverted, err := m.Revert(db) + + assert.Len(t, reverted, 2) + assert.Equal(t, reverted[0], "new") + assert.Equal(t, reverted[1], "test") + assert.Nil(t, err) + }) +} + +func TestCheckMigrationPool(t *testing.T) { + t.Run("it is successful on empty pool", func(t *testing.T) { + m := Migrator{} + err := m.checkMigrationPool() + + assert.Nil(t, err) + }) + + t.Run("It is successful for proper pool", func(t *testing.T) { + m := Migrator{Pool: []Migration{ + {Name: "test"}, + {Name: "random"}, + }} + err := m.checkMigrationPool() + + assert.Nil(t, err) + }) + + t.Run("it returns an error on missing migration name", func(t *testing.T) { + m := Migrator{Pool: []Migration{ + {Name: "test"}, + {Name: "random"}, + {Name: ""}, + }} + err := m.checkMigrationPool() + + assert.Error(t, err) + assert.Equal(t, ErrMissingMigrationName, err) + }) + + t.Run("it returns an error on duplicated migration name", func(t *testing.T) { + m := Migrator{Pool: []Migration{ + {Name: "test"}, + {Name: "random"}, + {Name: "again"}, + {Name: "migration"}, + {Name: "again"}, + }} + err := m.checkMigrationPool() + + assert.NotNil(t, err) + assert.Equal(t, `Migration "again" is duplicated in the pool`, err.Error()) + }) +} + +func TestCreateMigrationTable(t *testing.T) { + t.Run("it ignores creation if table exists", func(t *testing.T) { + m := Migrator{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery(`SELECT \* FROM migrations`).WillReturnRows().WillReturnError(nil) + + err := m.createMigrationTable(db) + + assert.Nil(t, err) + }) + + t.Run("it creates migration table", func(t *testing.T) { + m := Migrator{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery(`SELECT \* FROM migrations`).WillReturnError(errTestDBQueryFailed) + sql := `CREATE TABLE migrations \(id int\(10\) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY, name varchar\(255\) COLLATE utf8mb4_unicode_ci NOT NULL, batch int\(11\) NOT NULL, applied_at timestamp NULL DEFAULT CURRENT_TIMESTAMP\) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` + mock.ExpectExec(sql).WillReturnResult(sqlmock.NewResult(1, 1)) + + err := m.createMigrationTable(db) + + assert.Nil(t, err) + }) + + t.Run("it fails creating table", func(t *testing.T) { + m := Migrator{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery(`SELECT \* FROM migrations`).WillReturnError(errTestDBQueryFailed) + sql := `CREATE TABLE migrations \(` + + `id int\(10\) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY, ` + + `name varchar\(255\) COLLATE utf8mb4_unicode_ci NOT NULL, ` + + `batch int\(11\) NOT NULL, applied_at timestamp NULL DEFAULT CURRENT_TIMESTAMP\) ` + + `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` + mock.ExpectExec(sql).WillReturnError(errTestDBExecFailed) + + err := m.createMigrationTable(db) + + assert.Error(t, err) + assert.Equal(t, errTestDBExecFailed, err) + }) +} + +func TestHasTable(t *testing.T) { + t.Run("it returns true if table exists", func(t *testing.T) { + m := Migrator{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery(`SELECT \* FROM migrations`).WillReturnRows().WillReturnError(nil) + got := m.hasTable(db) + + assert.Equal(t, true, got) + }) + + t.Run("it returns false if table does not exists", func(t *testing.T) { + m := Migrator{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery(`SELECT \* FROM migrations`).WillReturnError(errTestDBQueryFailed) + got := m.hasTable(db) + + assert.Equal(t, false, got) + }) +} + +func TestMigrationTable(t *testing.T) { + t.Run("it returns default table name", func(t *testing.T) { + m := Migrator{} + got := m.table() + + assert.Equal(t, "migrations", got) + }) + + t.Run("it returns selected table name", func(t *testing.T) { + m := Migrator{TableName: "table"} + got := m.table() + + assert.Equal(t, "table", got) + }) +} + +func TestBatch(t *testing.T) { + t.Run("it returns zero on empty executed list", func(t *testing.T) { + m := Migrator{} + got := m.batch() + + assert.Equal(t, uint64(0), got) + }) + + t.Run("it returns zero if migration batch is zero", func(t *testing.T) { + m := Migrator{ + executed: []migrationEntry{ + {batch: uint64(0)}, + }, + } + got := m.batch() + + assert.Equal(t, uint64(0), got) + }) + + t.Run("it returns the biggest batch from migration list", func(t *testing.T) { + m := Migrator{ + executed: []migrationEntry{ + {batch: uint64(6)}, + {batch: uint64(3)}, + {batch: uint64(15)}, + {batch: uint64(12)}, + }, + } + got := m.batch() + + assert.Equal(t, uint64(15), got) + }) +} + +func TestPoolExecuted(t *testing.T) { + t.Run("it fails executing query", func(t *testing.T) { + m := Migrator{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnError(errTestDBQueryFailed) + + err := m.fetchExecuted(db) + + assert.Error(t, err) + assert.Equal(t, errTestDBQueryFailed, err) + assert.Nil(t, m.executed) + }) + + t.Run("it fails scanning row", func(t *testing.T) { + m := Migrator{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}). + AddRow(1, "first", 1, time.Now()). + AddRow(2, "second", 1, "test") + + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + got := m.fetchExecuted(db) + + assert.Error(t, got) + assert.NotNil(t, m.executed) + assert.Len(t, m.executed, 1) + }) + + t.Run("it returns a list of executed migrations", func(t *testing.T) { + m := Migrator{} + db, mock, resetDB := testDBConnection(t) + defer resetDB() + + rows := sqlmock.NewRows([]string{"id", "name", "batch", "applied_at"}). + AddRow(1, "first", 1, time.Now()). + AddRow(2, "second", 1, time.Now()) + + mock.ExpectQuery("SELECT id, name, batch, applied_at FROM migrations").WillReturnRows(rows) + + err := m.fetchExecuted(db) + + assert.Nil(t, err) + assert.NotNil(t, m.executed) + assert.Len(t, m.executed, 2) + }) +} + +func TestIsExecuted(t *testing.T) { + t.Run("it returns false on empty executed list", func(t *testing.T) { + m := Migrator{} + got := m.isExecuted("test") + + assert.Equal(t, false, got) + }) + + t.Run("it returns false if migration wasn't executed yet", func(t *testing.T) { + m := Migrator{ + executed: []migrationEntry{ + {name: "test"}, + {name: "random"}, + {name: "lorem"}, + {name: "ipsum"}, + }, + } + got := m.isExecuted("") + + assert.Equal(t, false, got) + }) + + t.Run("it returns true if migration was executed", func(t *testing.T) { + m := Migrator{ + executed: []migrationEntry{ + {name: "test"}, + {name: "random"}, + {name: "lorem"}, + {name: "ipsum"}, + }, + } + got := m.isExecuted("random") + + assert.Equal(t, true, got) + }) +} + +func TestLastExecutedForBatch(t *testing.T) { + t.Run("it returns an empty list if nothing found for biggest batch", func(t *testing.T) { + m := Migrator{} + got := m.lastBatchExecuted() + + assert.Len(t, got, 0) + }) + + t.Run("", func(t *testing.T) { + m := Migrator{ + executed: []migrationEntry{ + {name: "test", batch: 1}, + {name: "again", batch: 3}, + {name: "random", batch: 2}, + {name: "lorem", batch: 3}, + {name: "ipsum", batch: 3}, + }, + } + got := m.lastBatchExecuted() + + assert.Len(t, got, 3) + }) +} diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..977a8ad --- /dev/null +++ b/schema.go @@ -0,0 +1,68 @@ +// Package migrator represents MySQL database migrator +package migrator + +// Schema allows to add commands on schema. +// It should be used within migration to add migration commands. +type Schema struct { + pool []command +} + +// CreateTable allows to create table in schema +// +// Example: +// var s migrator.Schema +// t := migrator.Table{Name: "test"} +// +// s.CreateTable(t) +func (s *Schema) CreateTable(t Table) { + s.pool = append(s.pool, createTableCommand{t}) +} + +// DropTable removes table from schema +// Warning ⚠️ BC incompatible +// +// Example: +// var s migrator.Schema +// s.DropTable("test", false, "") +// +// Soft delete (drop if exists) +// s.DropTable("test", true, "") +func (s *Schema) DropTable(name string, soft bool, option string) { + s.pool = append(s.pool, dropTableCommand{name, soft, option}) +} + +// RenameTable executes command to rename table +// Warning ⚠️ BC incompatible +// +// Example: +// var s migrator.Schema +// s.RenameTable("old", "new") +func (s *Schema) RenameTable(old string, new string) { + s.pool = append(s.pool, renameTableCommand{old: old, new: new}) +} + +// AlterTable makes changes on table level +// +// Example: +// var s migrator.Schema +// var c TableCommands +// s.AlterTable("test", c) +func (s *Schema) AlterTable(name string, c TableCommands) { + s.pool = append(s.pool, alterTableCommand{name, c}) +} + +// CustomCommand allows to add custom command to the Schema +// +// Example: +// type customCommand string +// +// func (c customCommand) toSQL() string { +// return string(c) +// } +// +// c := customCommand("DROP PROCEDURE abc") +// var s migrator.Schema +// s.CustomCommand(c) +func (s *Schema) CustomCommand(c command) { + s.pool = append(s.pool, c) +} diff --git a/schema_command.go b/schema_command.go new file mode 100644 index 0000000..2324b5c --- /dev/null +++ b/schema_command.go @@ -0,0 +1,117 @@ +// Package migrator represents MySQL database migrator +package migrator + +import ( + "fmt" + "strings" +) + +type command interface { + toSQL() string +} + +type createTableCommand struct { + t Table +} + +func (c createTableCommand) toSQL() string { + if c.t.Name == "" { + return "" + } + + context := c.t.columns.render() + if context == "" { + context = "`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT" + } + + if res := c.t.indexes.render(); res != "" { + context += ", " + res + } + + if res := c.t.foreigns.render(); res != "" { + context += ", " + res + } + + engine := c.t.Engine + if engine == "" { + engine = "InnoDB" + } + + charset := c.t.Charset + collation := c.t.Collation + if charset == "" && collation == "" { + charset = "utf8mb4" + collation = "utf8mb4_unicode_ci" + } + if charset == "" && collation != "" { + parts := strings.Split(collation, "_") + charset = parts[0] + } + if charset != "" && collation == "" { + collation = charset + "_unicode_ci" + } + + return fmt.Sprintf( + "CREATE TABLE `%s` (%s) ENGINE=%s DEFAULT CHARSET=%s COLLATE=%s", + c.t.Name, + context, + engine, + charset, + collation, + ) +} + +type dropTableCommand struct { + table string + soft bool + option string +} + +func (c dropTableCommand) toSQL() string { + sql := "DROP TABLE" + + if c.soft { + sql += " IF EXISTS" + } + + sql += fmt.Sprintf(" `%s`", c.table) + + var validOptions = list{"RESTRICT", "CASCADE"} + if validOptions.has(strings.ToUpper(c.option)) { + sql += " " + strings.ToUpper(c.option) + } + + return sql +} + +type renameTableCommand struct { + old string + new string +} + +func (c renameTableCommand) toSQL() string { + return fmt.Sprintf("RENAME TABLE `%s` TO `%s`", c.old, c.new) +} + +type alterTableCommand struct { + name string + pool TableCommands +} + +func (c alterTableCommand) toSQL() string { + if c.name == "" || len(c.pool) == 0 { + return "" + } + + return "ALTER TABLE `" + c.name + "` " + c.poolToSQL() +} + +func (c alterTableCommand) poolToSQL() string { + var sql []string + + for _, tc := range c.pool { + sql = append(sql, tc.toSQL()) + } + + return strings.Join(sql, ", ") +} diff --git a/schema_command_test.go b/schema_command_test.go new file mode 100644 index 0000000..1e3b063 --- /dev/null +++ b/schema_command_test.go @@ -0,0 +1,232 @@ +package migrator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type testCommand string + +func (c testCommand) toSQL() string { + return "Do action on " + string(c) +} + +func TestCreateTableCommand(t *testing.T) { + t.Run("it returns empty string when table name missing", func(t *testing.T) { + tb := Table{} + c := createTableCommand{tb} + + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it renders default table", func(t *testing.T) { + tb := Table{Name: "test"} + c := createTableCommand{tb} + + assert.Equal( + t, + "CREATE TABLE `test` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + c.toSQL(), + ) + }) + + t.Run("it renders columns", func(t *testing.T) { + tb := Table{ + Name: "test", + columns: []column{ + {"test", testColumnType("random thing")}, + {"random", testColumnType("another thing")}, + }, + } + c := createTableCommand{tb} + + assert.Equal( + t, + "CREATE TABLE `test` (`test` random thing, `random` another thing) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + c.toSQL(), + ) + }) + + t.Run("it renders indexes", func(t *testing.T) { + tb := Table{ + Name: "test", + indexes: []key{ + {name: "idx_rand", columns: []string{"id"}}, + {columns: []string{"id", "name"}}, + }, + } + c := createTableCommand{tb} + + assert.Equal( + t, + strings.Join([]string{ + "CREATE TABLE `test` (", + "`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, ", + "KEY `idx_rand` (`id`), KEY (`id`, `name`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, ""), + c.toSQL(), + ) + }) + + t.Run("it renders foreigns", func(t *testing.T) { + tb := Table{ + Name: "test", + foreigns: []foreign{ + {key: "idx_foreign", column: "test_id", reference: "id", on: "tests"}, + {key: "foreign_idx", column: "random_id", reference: "id", on: "randoms"}, + }, + } + c := createTableCommand{tb} + + assert.Equal( + t, + strings.Join([]string{ + "CREATE TABLE `test` (", + "`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, ", + "CONSTRAINT `idx_foreign` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`), ", + "CONSTRAINT `foreign_idx` FOREIGN KEY (`random_id`) REFERENCES `randoms` (`id`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, ""), + c.toSQL(), + ) + }) + + t.Run("it renders engine", func(t *testing.T) { + tb := Table{Name: "test", Engine: "MyISAM"} + c := createTableCommand{tb} + + assert.Equal( + t, + "CREATE TABLE `test` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + c.toSQL(), + ) + }) + + t.Run("it renders charset and collation", func(t *testing.T) { + tb := Table{Name: "test", Charset: "rand", Collation: "random_io"} + c := createTableCommand{tb} + + assert.Equal( + t, + "CREATE TABLE `test` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT) ENGINE=InnoDB DEFAULT CHARSET=rand COLLATE=random_io", + c.toSQL(), + ) + }) + + t.Run("it renders charset and manually add collation", func(t *testing.T) { + tb := Table{Name: "test", Charset: "utf8"} + c := createTableCommand{tb} + + assert.Equal( + t, + "CREATE TABLE `test` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci", + c.toSQL(), + ) + }) + + t.Run("it renders collation and manually add charset", func(t *testing.T) { + tb := Table{Name: "test", Collation: "utf8_general_ci"} + c := createTableCommand{tb} + + assert.Equal( + t, + "CREATE TABLE `test` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci", + c.toSQL(), + ) + }) + + t.Run("it renders all together", func(t *testing.T) { + tb := Table{ + Name: "test", + columns: []column{ + {"test", testColumnType("random thing")}, + {"random", testColumnType("another thing")}, + }, + indexes: []key{ + {name: "idx_rand", columns: []string{"id"}}, + {columns: []string{"id", "name"}}, + }, + foreigns: []foreign{ + {key: "idx_foreign", column: "test_id", reference: "id", on: "tests"}, + {key: "foreign_idx", column: "random_id", reference: "id", on: "randoms"}, + }, + Engine: "MyISAM", + Charset: "rand", + Collation: "random_io", + } + c := createTableCommand{tb} + + assert.Equal( + t, + strings.Join([]string{ + "CREATE TABLE `test` (", + "`test` random thing, `random` another thing, ", + "KEY `idx_rand` (`id`), KEY (`id`, `name`), ", + "CONSTRAINT `idx_foreign` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`), ", + "CONSTRAINT `foreign_idx` FOREIGN KEY (`random_id`) REFERENCES `randoms` (`id`)", + ") ENGINE=MyISAM DEFAULT CHARSET=rand COLLATE=random_io", + }, ""), + c.toSQL(), + ) + }) +} + +func TestDropTableCommand(t *testing.T) { + t.Run("it drops table", func(t *testing.T) { + c := dropTableCommand{"test", false, ""} + assert.Equal(t, "DROP TABLE `test`", c.toSQL()) + }) + + t.Run("it drops table if exists", func(t *testing.T) { + c := dropTableCommand{"test", true, ""} + assert.Equal(t, "DROP TABLE IF EXISTS `test`", c.toSQL()) + }) + + t.Run("it drops table with cascade flag", func(t *testing.T) { + c := dropTableCommand{"test", false, "cascade"} + assert.Equal(t, "DROP TABLE `test` CASCADE", c.toSQL()) + }) + + t.Run("it drops table if exists with restrict flag", func(t *testing.T) { + c := dropTableCommand{"test", true, "restrict"} + assert.Equal(t, "DROP TABLE IF EXISTS `test` RESTRICT", c.toSQL()) + }) +} + +func TestRenameTableCommand(t *testing.T) { + c := renameTableCommand{"from", "to"} + + assert.Equal(t, "RENAME TABLE `from` TO `to`", c.toSQL()) +} + +func TestAlterTableCommand(t *testing.T) { + t.Run("it returns an empty command if table name is missing", func(t *testing.T) { + c := alterTableCommand{pool: TableCommands{testCommand("test")}} + + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty command if pool is empty", func(t *testing.T) { + c := alterTableCommand{name: "test"} + + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it renders command with one alter sub-command", func(t *testing.T) { + c := alterTableCommand{name: "test", pool: TableCommands{testCommand("test")}} + + assert.Equal(t, "ALTER TABLE `test` Do action on test", c.toSQL()) + }) + + t.Run("it renders command with multiple alter sub-command", func(t *testing.T) { + c := alterTableCommand{ + name: "test", + pool: TableCommands{testCommand("test"), testCommand("bang")}, + } + + assert.Equal(t, "ALTER TABLE `test` Do action on test, Do action on bang", c.toSQL()) + }) +} diff --git a/schema_test.go b/schema_test.go new file mode 100644 index 0000000..ac77f0e --- /dev/null +++ b/schema_test.go @@ -0,0 +1,69 @@ +package migrator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSchemaCreateTable(t *testing.T) { + assert := assert.New(t) + + s := Schema{} + assert.Len(s.pool, 0) + + tb := Table{Name: "test"} + s.CreateTable(tb) + + assert.Len(s.pool, 1) + assert.Equal(createTableCommand{tb}, s.pool[0]) +} + +func TestSchemaDropTable(t *testing.T) { + assert := assert.New(t) + + s := Schema{} + assert.Len(s.pool, 0) + + s.DropTable("test", false, "") + + assert.Len(s.pool, 1) + assert.Equal(dropTableCommand{"test", false, ""}, s.pool[0]) +} + +func TestSchemaRenameTable(t *testing.T) { + assert := assert.New(t) + + s := Schema{} + assert.Len(s.pool, 0) + + s.RenameTable("from", "to") + + assert.Len(s.pool, 1) + assert.Equal(renameTableCommand{"from", "to"}, s.pool[0]) +} + +func TestSchemaAlterTable(t *testing.T) { + assert := assert.New(t) + + s := Schema{} + assert.Len(s.pool, 0) + + s.AlterTable("table", TableCommands{}) + + assert.Len(s.pool, 1) + assert.Equal(alterTableCommand{"table", TableCommands{}}, s.pool[0]) +} + +func TestSchemaCustomCommand(t *testing.T) { + assert := assert.New(t) + c := testDummyCommand("DROP PROCEDURE abc") + + s := Schema{} + assert.Len(s.pool, 0) + + s.CustomCommand(c) + + assert.Len(s.pool, 1) + assert.Equal(c, s.pool[0]) +} diff --git a/table.go b/table.go new file mode 100644 index 0000000..4f45c2e --- /dev/null +++ b/table.go @@ -0,0 +1,139 @@ +// Package migrator represents MySQL database migrator +package migrator + +import "strings" + +// Table is an entity to create table +// +// Name table name +// Engine default: InnoDB +// Charset default: utf8mb4 or first part of collation (if set) +// Collation default: utf8mb4_unicode_ci or charset with `_unicode_ci` suffix +// Comment optional comment on table +type Table struct { + Name string + columns columns + indexes keys + foreigns foreigns + Engine string + Charset string + Collation string + Comment string +} + +// Column adds column to the table +func (t *Table) Column(name string, c columnType) { + t.columns = append(t.columns, column{field: name, definition: c}) +} + +// ID adds bigint `id` column that is primary key +func (t *Table) ID(name string) { + t.Column(name, Integer{ + Prefix: "big", + Unsigned: true, + Autoincrement: true, + }) + t.Primary(name) +} + +// UniqueID adds unique id column (represented as UUID) that is primary key +func (t *Table) UniqueID(name string) { + t.UUID(name, "(UUID())", false) + t.Primary(name) +} + +// Boolean represented in DB as tinyint +func (t *Table) Boolean(name string, def string) { + // tinyint(1) + t.Column(name, Integer{ + Prefix: "tiny", + Unsigned: true, + Precision: 1, + Default: def, + }) +} + +// UUID adds char(36) column +func (t *Table) UUID(name string, def string, nullable bool) { + // char(36) + t.Column(name, String{ + Fixed: true, + Precision: 36, + Default: def, + Nullable: nullable, + }) +} + +// Timestamps adds default timestamps: `created_at` and `updated_at` +func (t *Table) Timestamps() { + // created_at not null default CURRENT_TIMESTAMP + t.Column("created_at", Timable{ + Type: "timestamp", + Default: "CURRENT_TIMESTAMP", + }) + // updated_at not null default CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + t.Column("updated_at", Timable{ + Type: "timestamp", + Default: "CURRENT_TIMESTAMP", + OnUpdate: "CURRENT_TIMESTAMP", + }) +} + +// Primary adds primary key +func (t *Table) Primary(columns ...string) { + if len(columns) == 0 { + return + } + + t.indexes = append(t.indexes, key{ + typ: "primary", + columns: columns, + }) +} + +// Unique adds unique key on selected columns +func (t *Table) Unique(columns ...string) { + if len(columns) == 0 { + return + } + + t.indexes = append(t.indexes, key{ + name: t.buildUniqueKeyName(columns...), + typ: "unique", + columns: columns, + }) +} + +// Index adds index (key) on selected columns +func (t *Table) Index(name string, columns ...string) { + if len(columns) == 0 { + return + } + + t.indexes = append(t.indexes, key{name: name, columns: columns}) +} + +// Foreign adds foreign key constraints +func (t *Table) Foreign(column string, reference string, on string, onUpdate string, onDelete string) { + name := t.buildForeignKeyName(column) + t.indexes = append(t.indexes, key{ + name: name, + columns: []string{column}, + }) + t.foreigns = append(t.foreigns, foreign{ + key: name, + column: column, + reference: reference, + on: on, + onUpdate: onUpdate, + onDelete: onDelete, + }) +} + +func (t *Table) buildUniqueKeyName(columns ...string) string { + return t.Name + "_" + strings.Join(columns, "_") + "_unique" +} + +func (t *Table) buildForeignKeyName(column string) string { + return t.Name + "_" + column + "_foreign" +} diff --git a/table_command.go b/table_command.go new file mode 100644 index 0000000..0edb60f --- /dev/null +++ b/table_command.go @@ -0,0 +1,206 @@ +// Package migrator represents MySQL database migrator +package migrator + +import ( + "fmt" + "strings" +) + +// TableCommands is pool of commands to be executed on table +// https://dev.mysql.com/doc/refman/8.0/en/alter-table.html +type TableCommands []command + +func (tc TableCommands) toSQL() string { + rows := []string{} + + for _, c := range tc { + rows = append(rows, c.toSQL()) + } + + return strings.Join(rows, ", ") +} + +// AddColumnCommand is a command to add column to the table +type AddColumnCommand struct { + Name string + Column columnType + After string + First bool +} + +func (c AddColumnCommand) toSQL() string { + if c.Column == nil { + return "" + } + + definition := c.Column.buildRow() + if c.Name == "" || definition == "" { + return "" + } + + sql := "ADD COLUMN `" + c.Name + "` " + definition + + if c.After != "" { + sql += " AFTER " + c.After + } else if c.First { + sql += " FIRST" + } + + return sql +} + +// RenameColumnCommand is a command to rename column in the table +// Warning ⚠️ BC incompatible +// Info ℹ️ extensions for Oracle compatibility +type RenameColumnCommand struct { + Old string + New string +} + +func (c RenameColumnCommand) toSQL() string { + if c.Old == "" || c.New == "" { + return "" + } + + return fmt.Sprintf("RENAME COLUMN `%s` TO `%s`", c.Old, c.New) +} + +// ModifyColumnCommand is a command to modify column type +// Warning ⚠️ BC incompatible +// Info ℹ️ extensions for Oracle compatibility +type ModifyColumnCommand struct { + Name string + Column columnType +} + +func (c ModifyColumnCommand) toSQL() string { + if c.Column == nil { + return "" + } + + definition := c.Column.buildRow() + if c.Name == "" || definition == "" { + return "" + } + + return fmt.Sprintf("MODIFY `%s` %s", c.Name, definition) +} + +// ChangeColumnCommand is a default command to change column +// Warning ⚠️ BC incompatible +type ChangeColumnCommand struct { + From string + To string + Column columnType +} + +func (c ChangeColumnCommand) toSQL() string { + if c.Column == nil { + return "" + } + + definition := c.Column.buildRow() + if c.From == "" || c.To == "" || definition == "" { + return "" + } + + return fmt.Sprintf("CHANGE `%s` `%s` %s", c.From, c.To, c.Column.buildRow()) +} + +// DropColumnCommand is a command to drop column from the table +// Warning ⚠️ BC incompatible +type DropColumnCommand string + +// campatible with Oracle +func (c DropColumnCommand) toSQL() string { + if c == "" { + return "" + } + + return fmt.Sprintf("DROP COLUMN `%s`", c) +} + +// AddIndexCommand adds a key to the table +type AddIndexCommand struct { + Name string + Columns []string +} + +func (c AddIndexCommand) toSQL() string { + if c.Name == "" || len(c.Columns) == 0 { + return "" + } + + return fmt.Sprintf("ADD KEY `%s` (`%s`)", c.Name, strings.Join(c.Columns, "`, `")) +} + +// DropIndexCommand removes the key from the table +type DropIndexCommand string + +func (c DropIndexCommand) toSQL() string { + if c == "" { + return "" + } + + return fmt.Sprintf("DROP KEY `%s`", c) +} + +// AddForeignCommand adds the foreign key contraint to the table +type AddForeignCommand struct { + Foreign foreign +} + +func (c AddForeignCommand) toSQL() string { + if c.Foreign.render() == "" { + return "" + } + + return "ADD " + c.Foreign.render() +} + +// DropForeignCommand is a command to remove foreign key contraint +type DropForeignCommand string + +func (c DropForeignCommand) toSQL() string { + if c == "" { + return "" + } + + return fmt.Sprintf("DROP FOREIGN KEY `%s`", c) +} + +// AddUniqueIndexCommand is a command to add unique key to the table on some columns +type AddUniqueIndexCommand struct { + Key string + Columns []string +} + +func (c AddUniqueIndexCommand) toSQL() string { + if c.Key == "" || len(c.Columns) == 0 { + return "" + } + + return fmt.Sprintf("ADD UNIQUE KEY `%s` (`%s`)", c.Key, strings.Join(c.Columns, "`, `")) +} + +// AddPrimaryIndexCommand is a command to add a primary key +type AddPrimaryIndexCommand string + +func (c AddPrimaryIndexCommand) toSQL() string { + if c == "" { + return "" + } + + return fmt.Sprintf("ADD PRIMARY KEY (`%s`)", c) +} + +// DropPrimaryIndexCommand is a command to remove primary key from the table +type DropPrimaryIndexCommand struct{} + +func (c DropPrimaryIndexCommand) toSQL() string { + return "DROP PRIMARY KEY" +} + +// ADD {FULLTEXT | SPATIAL} [INDEX | KEY] [index_name] (key_part,...) [index_option] ... +// DROP {CHECK | CONSTRAINT} symbol +// RENAME {INDEX | KEY} old_index_name TO new_index_name diff --git a/table_command_test.go b/table_command_test.go new file mode 100644 index 0000000..ae63677 --- /dev/null +++ b/table_command_test.go @@ -0,0 +1,221 @@ +package migrator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTableCommands(t *testing.T) { + t.Run("it returns empty on empty commands list", func(t *testing.T) { + c := TableCommands{} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it renders row from one command", func(t *testing.T) { + c := TableCommands{testCommand("test")} + assert.Equal(t, "Do action on test", c.toSQL()) + }) + + t.Run("it renders row from multiple commands", func(t *testing.T) { + c := TableCommands{testCommand("test"), testCommand("bang")} + assert.Equal(t, "Do action on test, Do action on bang", c.toSQL()) + }) +} + +func TestAddColumnCommand(t *testing.T) { + t.Run("it returns an empty string if column definition missing", func(t *testing.T) { + c := AddColumnCommand{Name: "tests"} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if column name missing", func(t *testing.T) { + c := AddColumnCommand{Column: testColumnType("test")} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if column definition empty", func(t *testing.T) { + c := AddColumnCommand{Name: "tests", Column: testColumnType("")} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns base row", func(t *testing.T) { + c := AddColumnCommand{Name: "test_id", Column: testColumnType("definition")} + assert.Equal(t, "ADD COLUMN `test_id` definition", c.toSQL()) + }) + + t.Run("it returns row with after column", func(t *testing.T) { + c := AddColumnCommand{Name: "test_id", Column: testColumnType("definition"), After: "id"} + assert.Equal(t, "ADD COLUMN `test_id` definition AFTER id", c.toSQL()) + }) + + t.Run("it returns row with first flag", func(t *testing.T) { + c := AddColumnCommand{Name: "test_id", Column: testColumnType("definition"), First: true} + assert.Equal(t, "ADD COLUMN `test_id` definition FIRST", c.toSQL()) + }) +} + +func TestRenameColumnCommand(t *testing.T) { + t.Run("it returns an empty string if old name missing", func(t *testing.T) { + c := RenameColumnCommand{New: "test"} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if new name missing", func(t *testing.T) { + c := RenameColumnCommand{Old: "test"} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := RenameColumnCommand{Old: "from", New: "to"} + assert.Equal(t, "RENAME COLUMN `from` TO `to`", c.toSQL()) + }) +} + +func TestModifyColumnCommand(t *testing.T) { + t.Run("it returns an empty string if column definition missing", func(t *testing.T) { + c := ModifyColumnCommand{Name: "tests"} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if column name missing", func(t *testing.T) { + c := ModifyColumnCommand{Column: testColumnType("test")} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if column definition empty", func(t *testing.T) { + c := ModifyColumnCommand{Name: "tests", Column: testColumnType("")} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := ModifyColumnCommand{Name: "test_id", Column: testColumnType("definition")} + assert.Equal(t, "MODIFY `test_id` definition", c.toSQL()) + }) +} + +func TestChangeColumnCommand(t *testing.T) { + t.Run("it returns an empty string if column definition missing", func(t *testing.T) { + c := ChangeColumnCommand{From: "tests", To: "something"} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if column from name missing", func(t *testing.T) { + c := ChangeColumnCommand{To: "something", Column: testColumnType("test")} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if column to name missing", func(t *testing.T) { + c := ChangeColumnCommand{From: "tests", Column: testColumnType("test")} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if column definition empty", func(t *testing.T) { + c := ChangeColumnCommand{From: "tests", To: "something", Column: testColumnType("")} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := ChangeColumnCommand{From: "tests", To: "something", Column: testColumnType("definition")} + assert.Equal(t, "CHANGE `tests` `something` definition", c.toSQL()) + }) +} + +func TestDropColumnCommand(t *testing.T) { + t.Run("it returns an empty string if column name missing", func(t *testing.T) { + c := DropColumnCommand("") + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := DropColumnCommand("test_id") + assert.Equal(t, "DROP COLUMN `test_id`", c.toSQL()) + }) +} + +func TestAddIndexCommand(t *testing.T) { + t.Run("it returns an empty string if index name missing", func(t *testing.T) { + c := AddIndexCommand{Columns: []string{"test"}} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if columns list empty", func(t *testing.T) { + c := AddIndexCommand{Name: "test", Columns: []string{}} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := AddIndexCommand{Name: "test_idx", Columns: []string{"test"}} + assert.Equal(t, "ADD KEY `test_idx` (`test`)", c.toSQL()) + }) +} + +func TestDropIndexCommand(t *testing.T) { + t.Run("it returns an empty string if index name missing", func(t *testing.T) { + c := DropIndexCommand("") + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := DropIndexCommand("test_idx") + assert.Equal(t, "DROP KEY `test_idx`", c.toSQL()) + }) +} + +func TestAddForeignCommand(t *testing.T) { + t.Run("it returns an empty string on missing foreign key", func(t *testing.T) { + c := AddForeignCommand{} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it builds a proper row", func(t *testing.T) { + c := AddForeignCommand{foreign{key: "idx_foreign", column: "test_id", reference: "id", on: "tests"}} + assert.Equal(t, "ADD CONSTRAINT `idx_foreign` FOREIGN KEY (`test_id`) REFERENCES `tests` (`id`)", c.toSQL()) + }) +} + +func TestDropForeignCommand(t *testing.T) { + t.Run("it returns an empty string if index name missing", func(t *testing.T) { + c := DropForeignCommand("") + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := DropForeignCommand("test_idx") + assert.Equal(t, "DROP FOREIGN KEY `test_idx`", c.toSQL()) + }) +} + +func TestAddUniqueIndexCommand(t *testing.T) { + t.Run("it returns an empty string if index name missing", func(t *testing.T) { + c := AddUniqueIndexCommand{Columns: []string{"test"}} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns an empty string if columns list empty", func(t *testing.T) { + c := AddUniqueIndexCommand{Key: "test", Columns: []string{}} + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := AddUniqueIndexCommand{Key: "test_idx", Columns: []string{"test"}} + assert.Equal(t, "ADD UNIQUE KEY `test_idx` (`test`)", c.toSQL()) + }) +} + +func TestAddPrimaryIndexCommand(t *testing.T) { + t.Run("it returns an empty string if index name missing", func(t *testing.T) { + c := AddPrimaryIndexCommand("") + assert.Equal(t, "", c.toSQL()) + }) + + t.Run("it returns a proper row", func(t *testing.T) { + c := AddPrimaryIndexCommand("test_idx") + assert.Equal(t, "ADD PRIMARY KEY (`test_idx`)", c.toSQL()) + }) +} + +func TestDropPrimaryIndexCommand(t *testing.T) { + c := DropPrimaryIndexCommand{} + assert.Equal(t, "DROP PRIMARY KEY", c.toSQL()) +} diff --git a/table_test.go b/table_test.go new file mode 100644 index 0000000..8fd9fc9 --- /dev/null +++ b/table_test.go @@ -0,0 +1,207 @@ +package migrator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTableColumns(t *testing.T) { + c := testColumnType("test") + + assert := assert.New(t) + + table := Table{} + assert.Len(table.columns, 0) + + table.Column("test", c) + + assert.Len(table.columns, 1) + assert.Equal(columns{column{"test", c}}, table.columns) +} + +func TestIDColumn(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.columns) + assert.Len(table.indexes, 0) + + table.ID("id") + + assert.Len(table.columns, 1) + assert.Equal("id", table.columns[0].field) + assert.Equal(Integer{Prefix: "big", Unsigned: true, Autoincrement: true}, table.columns[0].definition) + assert.Len(table.indexes, 1) + assert.Equal(key{typ: "primary", columns: []string{"id"}}, table.indexes[0]) +} + +func TestUniqueIDColumn(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.columns) + assert.Len(table.indexes, 0) + + table.UniqueID("id") + + assert.Len(table.columns, 1) + assert.Equal("id", table.columns[0].field) + assert.Equal(String{Default: "(UUID())", Fixed: true, Precision: 36}, table.columns[0].definition) + assert.Len(table.indexes, 1) + assert.Equal(key{typ: "primary", columns: []string{"id"}}, table.indexes[0]) +} + +func TestBooleanColumn(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.columns) + + table.Boolean("flag", "1") + + assert.Len(table.columns, 1) + assert.Equal("flag", table.columns[0].field) + assert.Equal(Integer{Prefix: "tiny", Default: "1", Unsigned: true, Precision: 1}, table.columns[0].definition) +} + +func TestUUIDColumn(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.columns) + + table.UUID("uuid", "1111", true) + + assert.Len(table.columns, 1) + assert.Equal("uuid", table.columns[0].field) + assert.Equal(String{Default: "1111", Fixed: true, Precision: 36, Nullable: true}, table.columns[0].definition) +} + +func TestTimestampsColumn(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.columns) + + table.Timestamps() + + assert.Len(table.columns, 2) + assert.Equal("created_at", table.columns[0].field) + assert.Equal(Timable{Type: "timestamp", Default: "CURRENT_TIMESTAMP"}, table.columns[0].definition) + assert.Equal("updated_at", table.columns[1].field) + assert.Equal(Timable{Type: "timestamp", Default: "CURRENT_TIMESTAMP", OnUpdate: "CURRENT_TIMESTAMP"}, table.columns[1].definition) +} + +func TestTablePrimaryIndex(t *testing.T) { + t.Run("it skips adding key on empty columns list", func(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.indexes) + + table.Primary() + + assert.Nil(table.indexes) + }) + + t.Run("it adds primary key", func(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.indexes) + + table.Primary("id", "name") + + assert.Len(table.indexes, 1) + assert.Equal(key{typ: "primary", columns: []string{"id", "name"}}, table.indexes[0]) + }) +} + +func TestTableUniqueIndex(t *testing.T) { + t.Run("it skips adding key on empty columns list", func(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.indexes) + + table.Unique() + + assert.Nil(table.indexes) + }) + + t.Run("it adds unique key", func(t *testing.T) { + assert := assert.New(t) + table := Table{Name: "table"} + + assert.Nil(table.indexes) + + table.Unique("id", "name") + + assert.Len(table.indexes, 1) + assert.Equal(key{name: "table_id_name_unique", typ: "unique", columns: []string{"id", "name"}}, table.indexes[0]) + }) +} + +func TestTableIndex(t *testing.T) { + t.Run("it skips adding key on empty columns list", func(t *testing.T) { + assert := assert.New(t) + table := Table{} + + assert.Nil(table.indexes) + + table.Index("test") + + assert.Nil(table.indexes) + }) + + t.Run("it adds unique key", func(t *testing.T) { + assert := assert.New(t) + table := Table{Name: "table"} + + assert.Nil(table.indexes) + + table.Index("test_idx", "id", "name") + + assert.Len(table.indexes, 1) + assert.Equal(key{name: "test_idx", columns: []string{"id", "name"}}, table.indexes[0]) + }) +} + +func TestTableForeignIndex(t *testing.T) { + assert := assert.New(t) + table := Table{Name: "table"} + + assert.Nil(table.indexes) + assert.Nil(table.foreigns) + + table.Foreign("test_id", "id", "tests", "set null", "cascade") + + assert.Len(table.indexes, 1) + assert.Equal(key{name: "table_test_id_foreign", columns: []string{"test_id"}}, table.indexes[0]) + assert.Len(table.foreigns, 1) + assert.Equal( + foreign{key: "table_test_id_foreign", column: "test_id", reference: "id", on: "tests", onUpdate: "set null", onDelete: "cascade"}, + table.foreigns[0], + ) +} + +func TestBuildUniqueIndexName(t *testing.T) { + t.Run("It builds name from one column", func(t *testing.T) { + table := Table{Name: "table"} + + assert.Equal(t, "table_test_unique", table.buildUniqueKeyName("test")) + }) + + t.Run("it builds name from multiple columns", func(t *testing.T) { + table := Table{Name: "table"} + + assert.Equal(t, "table_test_again_unique", table.buildUniqueKeyName("test", "again")) + }) +} + +func TestBuildForeignIndexName(t *testing.T) { + table := Table{Name: "table"} + + assert.Equal(t, "table_test_foreign", table.buildForeignKeyName("test")) +}