├── doc └── img │ └── logo.png ├── suite_test.go ├── sqlexec ├── suite_test.go ├── model.go ├── splitter.go ├── scanner.go ├── generator.go ├── scanner_test.go ├── runner.go ├── provider_test.go ├── splitter_test.go ├── runner_test.go ├── generator_test.go └── provider.go ├── .github ├── CONTRIBUTING.md ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── release.yaml │ └── main.yaml ├── dependabot.yml └── CODE_OF_CONDUCT.md ├── CONTRIBUTORS ├── sqlmigr ├── suite_test.go ├── util.go ├── util_test.go ├── printer.go ├── generator.go ├── generator_test.go ├── printer_test.go ├── runner.go ├── model_test.go ├── executor.go ├── provider.go ├── model.go └── runner_test.go ├── .gitignore ├── .goreleaser.yml ├── sqlmodel ├── template │ ├── model.mustache │ ├── routine.mustache │ ├── suite_test.mustache │ ├── repository.mustache │ └── repository_test.mustache ├── fixture │ └── repository.txt ├── generator.go ├── model_test.go ├── executor.go ├── builder.go ├── builder_test.go ├── generator_test.go └── executor_test.go ├── LICENSE ├── go.mod ├── storage └── storage.go ├── integration ├── suite_test.go ├── model_sync_test.go ├── model_print_test.go ├── routine_sync_test.go ├── migration_create_test.go ├── migration_setup_test.go ├── migration_status_test.go ├── migration_reset_test.go ├── routine_run_test.go ├── migration_run_test.go ├── routine_create_test.go └── migration_revert_test.go ├── cmd ├── prana │ └── main.go ├── hook.go ├── repository.go ├── model.go ├── migration.go └── routine.go ├── fake ├── model_tag_builder.go ├── tag_builder.go ├── model_generator.go ├── migration_runner.go ├── migration_generator.go ├── Querier.go └── schema_provider.go ├── common.go └── common_test.go /doc/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phogolabs/prana/HEAD/doc/img/logo.png -------------------------------------------------------------------------------- /suite_test.go: -------------------------------------------------------------------------------- 1 | package prana_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestOAK(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Prana Suite") 13 | } 14 | -------------------------------------------------------------------------------- /sqlexec/suite_test.go: -------------------------------------------------------------------------------- 1 | package sqlexec_test 2 | 3 | import ( 4 | "testing" 5 | 6 | _ "github.com/mattn/go-sqlite3" 7 | 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestScript(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "SQLExec Suite") 15 | } 16 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | # This is the list of Parcell contributors for copyright purposes. 2 | # 3 | # This does not necessarily list everyone who has contributed code, since in 4 | # some cases, their employer may be the copyright holder. To see the full list 5 | # of contributors, see the revision history in source control. 6 | 7 | Svetlin Ralchev - https://github.com/svett 8 | -------------------------------------------------------------------------------- /sqlmigr/suite_test.go: -------------------------------------------------------------------------------- 1 | package sqlmigr_test 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | 7 | _ "github.com/mattn/go-sqlite3" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func TestMigration(t *testing.T) { 14 | log.SetOutput(GinkgoWriter) 15 | RegisterFailHandler(Fail) 16 | RunSpecs(t, "Migration Suite") 17 | } 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | Please explain the changes you made here. 4 | 5 | ### Checklist 6 | 7 | - [ ] Code compiles correctly 8 | - [ ] Created tests that fail without the change (if possible) 9 | - [ ] All tests passing 10 | - [ ] Extended the README.md / documentation, if necessary 11 | - [ ] Added me / the copyright holder to the CONTRIBUTORS file 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | *.coverprofile 13 | 14 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 15 | .glide/ 16 | vendor/ 17 | 18 | # Ignore SQLite database 19 | *.db 20 | -------------------------------------------------------------------------------- /sqlmigr/util.go: -------------------------------------------------------------------------------- 1 | package sqlmigr 2 | 3 | import "github.com/jmoiron/sqlx" 4 | 5 | // RunAll runs all sqlmigrs 6 | func RunAll(db *sqlx.DB, storage FileSystem) error { 7 | executor := &Executor{ 8 | Provider: &Provider{ 9 | FileSystem: storage, 10 | DB: db, 11 | }, 12 | Runner: &Runner{ 13 | FileSystem: storage, 14 | DB: db, 15 | }, 16 | } 17 | 18 | _, err := executor.RunAll() 19 | return err 20 | } 21 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | builds: 5 | - main: ./cmd/prana/main.go 6 | env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - darwin 11 | - windows 12 | goarch: 13 | - arm 14 | - arm64 15 | - amd64 16 | changelog: 17 | sort: asc 18 | filters: 19 | exclude: 20 | - '^docs:' 21 | - '^test:' 22 | brews: 23 | - tap: 24 | owner: phogolabs 25 | name: homebrew-tap 26 | name: prana 27 | description: Golang Database Management and Code Generation 28 | homepage: https://github.com/phogolabs/prana 29 | test: | 30 | system "#{bin}/prana -v" 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "!*" 7 | tags: 8 | - "v*.*.*" 9 | 10 | jobs: 11 | pipeline: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out Code 16 | uses: actions/checkout@v1 17 | 18 | - name: Set up Golang 19 | uses: actions/setup-go@v1 20 | with: 21 | go-version: '1.16.x' 22 | 23 | - name: Release Application 24 | uses: goreleaser/goreleaser-action@v2.8.0 25 | with: 26 | version: latest 27 | args: release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GORELEASE_GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /sqlmodel/template/model.mustache: -------------------------------------------------------------------------------- 1 | {{#Schema}} 2 | {{#if Model.HasDocumentation}} 3 | // Code generated by prana; DO NOT EDIT. 4 | 5 | // Package {{Model.Package}} contains an object model of database schema '{{Name}}' 6 | // Auto-generated at {{now}} 7 | 8 | {{/if}} 9 | package {{Model.Package}} 10 | {{#tables}} 11 | 12 | {{#if Model.HasDocumentation}} 13 | // {{Model.Type}} represents a data base table '{{Name}}' 14 | {{/if}} 15 | type {{Model.Type}} struct { 16 | {{#columns}} 17 | {{#if Model.HasDocumentation}} 18 | // {{Model.Name}} represents a database column '{{Name}}' of type '{{Type}}' 19 | {{/if}} 20 | {{Model.Name}} {{Model.Type}} {{{Model.Tag}}} 21 | {{/columns}} 22 | } 23 | {{/tables}} 24 | {{/Schema}} 25 | -------------------------------------------------------------------------------- /sqlexec/model.go: -------------------------------------------------------------------------------- 1 | // Package sqlexec provides primitives and functions to work with raw SQL 2 | // statements and pre-defined SQL Scripts. 3 | package sqlexec 4 | 5 | import ( 6 | "io/fs" 7 | 8 | "github.com/jmoiron/sqlx" 9 | ) 10 | 11 | var ( 12 | format = "20060102150405" 13 | ) 14 | 15 | // Param is a command parameter for given query. 16 | type Param = interface{} 17 | 18 | // Rows is a wrapper around sql.Rows which caches costly reflect operations 19 | // during a looped StructScan. 20 | type Rows = sqlx.Rows 21 | 22 | // FileSystem provides with primitives to work with the underlying file system 23 | type FileSystem = fs.FS 24 | 25 | // WriteFileSystem represents a wriable file system 26 | type WriteFileSystem interface { 27 | FileSystem 28 | 29 | // OpenFile opens a new file 30 | OpenFile(string, int, fs.FileMode) (fs.File, error) 31 | } 32 | -------------------------------------------------------------------------------- /sqlmodel/template/routine.mustache: -------------------------------------------------------------------------------- 1 | {{#Schema}} 2 | {{#if Model.HasDocumentation}} 3 | -- Auto-generated at {{now}} 4 | 5 | {{/if}} 6 | {{#tables}} 7 | -- name: {{Model.SelectAllRoutine}} 8 | SELECT * FROM {{Name}}; 9 | 10 | -- name: {{Model.SelectByPKRoutine}} 11 | SELECT * FROM {{Name}} 12 | WHERE {{Model.PrimaryKeyCondition}}; 13 | 14 | -- name: {{Model.InsertRoutine}} 15 | INSERT INTO {{Name}} ({{Model.InsertColumns}}) 16 | VALUES ({{Model.InsertValues}}){{#equal Driver "postgresql"}} RETURNING *{{/equal}}; 17 | 18 | -- name: {{Model.UpdateByPKRoutine}} 19 | UPDATE {{Name}} 20 | SET {{Model.UpdateByPKColumns}} 21 | WHERE {{Model.PrimaryKeyCondition}}{{#equal Driver "postgresql"}} RETURNING *{{/equal}}; 22 | 23 | -- name: {{Model.DeleteByPKRoutine}} 24 | DELETE FROM {{Name}} 25 | WHERE {{Model.PrimaryKeyCondition}}; 26 | 27 | {{/tables}} 28 | {{/Schema}} 29 | -------------------------------------------------------------------------------- /sqlmigr/util_test.go: -------------------------------------------------------------------------------- 1 | package sqlmigr_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "path/filepath" 6 | "testing/fstest" 7 | 8 | "github.com/jmoiron/sqlx" 9 | "github.com/phogolabs/prana/sqlmigr" 10 | 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var _ = Describe("Util", func() { 16 | Describe("RunAll", func() { 17 | var db *sqlx.DB 18 | 19 | BeforeEach(func() { 20 | dir, err := ioutil.TempDir("", "prana_runner") 21 | Expect(err).To(BeNil()) 22 | 23 | conn := filepath.Join(dir, "prana.db") 24 | db, err = sqlx.Open("sqlite3", conn) 25 | Expect(err).To(BeNil()) 26 | 27 | }) 28 | 29 | AfterEach(func() { 30 | Expect(db.Close()).To(Succeed()) 31 | }) 32 | 33 | It("runs all sqlmigrs successfully", func() { 34 | Expect(sqlmigr.RunAll(db, fstest.MapFS{})).To(Succeed()) 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: github.com/onsi/ginkgo 10 | versions: 11 | - 1.14.2 12 | - 1.15.0 13 | - 1.15.1 14 | - 1.15.2 15 | - 1.16.0 16 | - dependency-name: github.com/lib/pq 17 | versions: 18 | - 1.10.0 19 | - 1.9.0 20 | - dependency-name: github.com/olekukonko/tablewriter 21 | versions: 22 | - 0.0.5 23 | - dependency-name: github.com/onsi/gomega 24 | versions: 25 | - 1.10.4 26 | - 1.10.5 27 | - dependency-name: github.com/jmoiron/sqlx 28 | versions: 29 | - 1.3.1 30 | - dependency-name: github.com/mattn/go-sqlite3 31 | versions: 32 | - 1.14.6 33 | - dependency-name: github.com/fatih/color 34 | versions: 35 | - 1.10.0 36 | -------------------------------------------------------------------------------- /sqlexec/splitter.go: -------------------------------------------------------------------------------- 1 | package sqlexec 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "regexp" 9 | ) 10 | 11 | var separatorRgxp = regexp.MustCompile(`^[\s]*[-]*[\s]*(?i)go[;]*\s*`) 12 | 13 | // Splitter splits a statement by GO separator 14 | type Splitter struct{} 15 | 16 | // Split splits a statement by GO separator 17 | func (s *Splitter) Split(reader io.Reader) []string { 18 | buffer := &bytes.Buffer{} 19 | queries := []string{} 20 | scanner := bufio.NewScanner(reader) 21 | 22 | for scanner.Scan() { 23 | line := scanner.Text() 24 | if s.match(line) { 25 | s.add(buffer, &queries) 26 | continue 27 | } 28 | 29 | fmt.Fprintln(buffer, line) 30 | } 31 | 32 | s.add(buffer, &queries) 33 | return queries 34 | } 35 | 36 | func (s *Splitter) match(line string) bool { 37 | return separatorRgxp.MatchString(line) 38 | } 39 | 40 | func (s *Splitter) add(buffer *bytes.Buffer, queries *[]string) { 41 | if buffer.Len() > 0 { 42 | *queries = append(*queries, buffer.String()) 43 | buffer.Reset() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /sqlmodel/template/suite_test.mustache: -------------------------------------------------------------------------------- 1 | {{#if Schema.Model.HasDocumentation}} 2 | // Code generated by prana; DO NOT EDIT. 3 | 4 | // Package {{Meta.RepositoryPackage}}_tests contains a tests for database repository 5 | // Auto-generated at {{now}} 6 | 7 | {{/if}} 8 | package {{Meta.RepositoryPackage}}_test 9 | 10 | import ( 11 | "os" 12 | "testing" 13 | 14 | "github.com/phogolabs/orm" 15 | 16 | . "github.com/onsi/ginkgo" 17 | . "github.com/onsi/gomega" 18 | ) 19 | 20 | var ( 21 | gateway *orm.Gateway 22 | ) 23 | 24 | func TestDatabase(t *testing.T) { 25 | RegisterFailHandler(Fail) 26 | RunSpecs(t, "Database Suite") 27 | } 28 | 29 | var _ = BeforeSuite(func() { 30 | var ( 31 | err error 32 | url = os.Getenv("TEST_DB_URL") 33 | ) 34 | 35 | if url == "" { 36 | Skip("TEST_DB_URL environment variable is not set") 37 | return 38 | } 39 | 40 | gateway, err = orm.Connect(url) 41 | Expect(gateway).NotTo(BeNil()) 42 | Expect(err).NotTo(HaveOccurred()) 43 | }) 44 | 45 | var _ = AfterSuite(func() { 46 | if gateway == nil { 47 | return 48 | } 49 | 50 | Expect(gateway.Close()).To(Succeed()) 51 | }) 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Phogo Labs Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/phogolabs/prana 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/aymerick/raymond v2.0.2+incompatible 7 | github.com/fatih/color v1.12.0 8 | github.com/go-openapi/inflect v0.19.0 9 | github.com/go-sql-driver/mysql v1.6.0 10 | github.com/google/go-cmp v0.5.6 // indirect 11 | github.com/gosuri/uitable v0.0.4 12 | github.com/hashicorp/hcl/v2 v2.10.1 // indirect 13 | github.com/jmoiron/sqlx v1.3.4 14 | github.com/lib/pq v1.10.2 15 | github.com/mattn/go-isatty v0.0.13 // indirect 16 | github.com/mattn/go-runewidth v0.0.13 // indirect 17 | github.com/mattn/go-sqlite3 v1.14.8 18 | github.com/olekukonko/tablewriter v0.0.5 19 | github.com/onsi/ginkgo v1.14.0 20 | github.com/onsi/gomega v1.10.1 21 | github.com/phogolabs/cli v0.0.0-20210430125239-bcee8250ce56 22 | github.com/phogolabs/flaw v0.0.0-20210430130223-f948049b189e // indirect 23 | github.com/phogolabs/log v0.0.0-20210430125128-bb23cd1dfac5 24 | github.com/zclconf/go-cty v1.9.0 // indirect 25 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect 26 | golang.org/x/tools v0.1.5 27 | google.golang.org/genproto v0.0.0-20210726200206-e7812ac95cc0 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type ( 10 | File = fs.File 11 | FileMode = fs.FileMode 12 | ) 13 | 14 | // FileSystem represents a disk file system 15 | type FileSystem struct { 16 | dir string 17 | } 18 | 19 | // NewStorage creates a new storage 20 | func New(dir string) *FileSystem { 21 | return &FileSystem{dir: dir} 22 | } 23 | 24 | // Open opens the named file for reading 25 | func (storage *FileSystem) Open(name string) (File, error) { 26 | name = filepath.Join(storage.dir, name) 27 | 28 | if err := storage.mkdir(name); err != nil { 29 | return nil, err 30 | } 31 | 32 | return os.Open(name) 33 | } 34 | 35 | // OpenFile is the generalized open call 36 | func (storage *FileSystem) OpenFile(name string, flag int, perm FileMode) (File, error) { 37 | name = filepath.Join(storage.dir, name) 38 | 39 | if err := storage.mkdir(name); err != nil { 40 | return nil, err 41 | } 42 | 43 | return os.OpenFile(name, flag, perm) 44 | } 45 | 46 | func (storage *FileSystem) mkdir(name string) error { 47 | if path := filepath.Dir(name); path != "" { 48 | return os.MkdirAll(path, 0700) 49 | } 50 | 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /sqlexec/scanner.go: -------------------------------------------------------------------------------- 1 | package sqlexec 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var nameRgxp = regexp.MustCompile("^\\s*--\\s*name:\\s*(\\S+)") 11 | 12 | // Scanner loads a SQL statements for given SQL Script 13 | type Scanner struct{} 14 | 15 | // Scan scans a reader for SQL commands that have name tag 16 | func (s *Scanner) Scan(reader io.Reader) map[string]string { 17 | queries := make(map[string]string) 18 | scanner := bufio.NewScanner(reader) 19 | name := "" 20 | 21 | for scanner.Scan() { 22 | line := scanner.Text() 23 | 24 | if tag := s.tag(line); tag != "" { 25 | name = tag 26 | } else if name != "" { 27 | s.add(name, queries, line) 28 | } 29 | } 30 | 31 | return queries 32 | } 33 | 34 | func (s *Scanner) tag(line string) string { 35 | matches := nameRgxp.FindStringSubmatch(line) 36 | if matches == nil { 37 | return "" 38 | } 39 | return matches[1] 40 | } 41 | 42 | func (s *Scanner) add(name string, queries map[string]string, line string) { 43 | current := queries[name] 44 | line = strings.Trim(line, " \t") 45 | 46 | if len(line) == 0 { 47 | return 48 | } 49 | 50 | if len(current) > 0 { 51 | current = current + "\n" 52 | } 53 | 54 | current = current + line 55 | queries[name] = current 56 | } 57 | -------------------------------------------------------------------------------- /integration/suite_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "os/exec" 5 | "testing" 6 | "time" 7 | 8 | "github.com/onsi/gomega/gexec" 9 | 10 | _ "github.com/mattn/go-sqlite3" 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var gomPath string 16 | 17 | func TestIntegration(t *testing.T) { 18 | RegisterFailHandler(Fail) 19 | RunSpecs(t, "Integration Suite") 20 | } 21 | 22 | var _ = SynchronizedBeforeSuite(func() []byte { 23 | binPath, err := gexec.Build("github.com/phogolabs/prana/cmd/prana") 24 | Expect(err).NotTo(HaveOccurred()) 25 | 26 | return []byte(binPath) 27 | }, func(data []byte) { 28 | gomPath = string(data) 29 | SetDefaultEventuallyTimeout(10 * time.Second) 30 | }) 31 | 32 | var _ = SynchronizedAfterSuite(func() { 33 | }, func() { 34 | gexec.CleanupBuildArtifacts() 35 | }) 36 | 37 | func Setup(args []string, dir string) { 38 | cmd := exec.Command(gomPath, append(args, "migration", "setup")...) 39 | cmd.Dir = dir 40 | 41 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 42 | Expect(err).NotTo(HaveOccurred()) 43 | Eventually(session).Should(gexec.Exit(0)) 44 | 45 | cmd = exec.Command(gomPath, append(args, "migration", "run")...) 46 | cmd.Dir = dir 47 | 48 | session, err = gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 49 | Expect(err).NotTo(HaveOccurred()) 50 | Eventually(session).Should(gexec.Exit(0)) 51 | } 52 | -------------------------------------------------------------------------------- /sqlexec/generator.go: -------------------------------------------------------------------------------- 1 | package sqlexec 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/go-openapi/inflect" 11 | ) 12 | 13 | // Generator generates a new command. 14 | type Generator struct { 15 | // FileSystem represents the project directory file system. 16 | FileSystem WriteFileSystem 17 | } 18 | 19 | // Create crates a new file and command for given file name and command name. 20 | func (g *Generator) Create(path, name string) (string, string, error) { 21 | path = inflect.Underscore(strings.ToLower(path)) 22 | name = inflect.Dasherize(strings.ToLower(name)) 23 | 24 | provider := &Provider{} 25 | 26 | if err := provider.ReadDir(g.FileSystem); err != nil { 27 | return "", "", err 28 | } 29 | 30 | if _, err := provider.Query(name); err == nil { 31 | return "", "", fmt.Errorf("Query '%s' already exists", name) 32 | } 33 | 34 | now := time.Now().UTC() 35 | 36 | if path == "" { 37 | path = now.Format(format) 38 | } 39 | 40 | path = fmt.Sprintf("%s.sql", path) 41 | 42 | file, err := g.FileSystem.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) 43 | if err != nil { 44 | return "", "", err 45 | } 46 | 47 | defer file.Close() 48 | 49 | if writer, ok := file.(io.Writer); ok { 50 | fmt.Fprintf(writer, "-- name: %s", name) 51 | fmt.Fprintln(writer) 52 | fmt.Fprintln(writer) 53 | } 54 | 55 | return name, path, err 56 | } 57 | -------------------------------------------------------------------------------- /sqlmigr/printer.go: -------------------------------------------------------------------------------- 1 | package sqlmigr 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "time" 8 | 9 | "github.com/fatih/color" 10 | "github.com/gosuri/uitable" 11 | "github.com/phogolabs/log" 12 | ) 13 | 14 | // Flog prints the migrations as fields 15 | func Flog(logger log.Logger, migrations []*Migration) { 16 | for _, m := range migrations { 17 | status := "pending" 18 | timestamp := "" 19 | 20 | if !m.CreatedAt.IsZero() { 21 | status = "executed" 22 | timestamp = m.CreatedAt.Format(time.UnixDate) 23 | } 24 | 25 | fields := log.Map{ 26 | "Id": m.ID, 27 | "Description": m.Description, 28 | "Status": status, 29 | "Drivers": strings.Join(m.Drivers, ", "), 30 | "CreatedAt": timestamp, 31 | } 32 | 33 | logger.WithFields(fields).Info("Migration") 34 | } 35 | } 36 | 37 | // Ftable prints the migrations as table 38 | func Ftable(w io.Writer, migrations []*Migration) { 39 | table := uitable.New() 40 | table.MaxColWidth = 50 41 | 42 | for _, m := range migrations { 43 | status := color.YellowString("pending") 44 | timestamp := "--" 45 | 46 | if !m.CreatedAt.IsZero() { 47 | status = color.GreenString("executed") 48 | timestamp = m.CreatedAt.Format(time.UnixDate) 49 | } 50 | 51 | table.AddRow("Id", m.ID) 52 | table.AddRow("Description", m.Description) 53 | table.AddRow("Status", status) 54 | table.AddRow("Drivers", strings.Join(m.Drivers, ", ")) 55 | table.AddRow("Created At", timestamp) 56 | table.AddRow("") 57 | } 58 | 59 | fmt.Fprintln(w, table) 60 | } 61 | -------------------------------------------------------------------------------- /sqlmodel/fixture/repository.txt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "context" 4 | 5 | type Table1Repository struct { 6 | // Gateway connects the repository to the underlying database 7 | Gateway *orm.Gateway 8 | } 9 | 10 | func (r *Table1Repository) SelectAll(ctx context.Context) ([]*model.Table1, error) { 11 | records := []*model.Table1{} 12 | routine := orm.Routine("select-all-table1") 13 | 14 | if err := r.Gateway.All(ctx, &records, routine); err != nil { 15 | return nil, err 16 | } 17 | 18 | return records, nil 19 | } 20 | 21 | func (r *Table1Repository) SelectByPK(ctx context.Context, id string) (*model.Table1, error) { 22 | param := orm.Map{ 23 | "id": id, 24 | } 25 | 26 | routine := orm.Routine("select-table1-by-pk", param) 27 | record := &model.Table1{} 28 | 29 | if err := r.Gateway.Only(ctx, record, routine); err != nil { 30 | return nil, err 31 | } 32 | 33 | return record, nil 34 | } 35 | 36 | func (r *Table1Repository) Insert(ctx context.Context, row *model.Table1) error { 37 | routine := orm.Routine("insert-table1", row) 38 | _, err := r.Gateway.Exec(ctx, routine) 39 | return err 40 | } 41 | 42 | func (r *Table1Repository) UpdateByPK(ctx context.Context, row *model.Table1) error { 43 | routine := orm.Routine("update-table1-by-pk", row) 44 | _, err := r.Gateway.Exec(ctx, routine) 45 | return err 46 | } 47 | 48 | func (r *Table1Repository) DeleteByPK(ctx context.Context, id string) error { 49 | param := orm.Map{ 50 | "id": id, 51 | } 52 | 53 | routine := orm.Routine("delete-table1-by-pk", param) 54 | _, err := r.Gateway.Exec(ctx, routine) 55 | return err 56 | } 57 | -------------------------------------------------------------------------------- /integration/model_sync_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/jmoiron/sqlx" 11 | "github.com/onsi/gomega/gexec" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("Model Sync", func() { 18 | var cmd *exec.Cmd 19 | 20 | BeforeEach(func() { 21 | dir, err := ioutil.TempDir("", "gom") 22 | Expect(err).To(BeNil()) 23 | 24 | query := &bytes.Buffer{} 25 | fmt.Fprintln(query, "CREATE TABLE users (") 26 | fmt.Fprintln(query, " id INT PRIMARY KEY NOT NULL,") 27 | fmt.Fprintln(query, " first_name TEXT NOT NULL,") 28 | fmt.Fprintln(query, " last_name TEXT") 29 | fmt.Fprintln(query, ");") 30 | 31 | db, err := sqlx.Open("sqlite3", filepath.Join(dir, "gom.db")) 32 | Expect(err).To(BeNil()) 33 | _, err = db.Exec(query.String()) 34 | Expect(err).To(BeNil()) 35 | Expect(db.Close()).To(Succeed()) 36 | 37 | cmd = exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "migration", "setup") 38 | cmd.Dir = dir 39 | 40 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 41 | Expect(err).NotTo(HaveOccurred()) 42 | Eventually(session).Should(gexec.Exit(0)) 43 | 44 | cmd = exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "model", "print") 45 | cmd.Dir = dir 46 | }) 47 | 48 | It("syncs the model successfully", func() { 49 | buffer := &bytes.Buffer{} 50 | session, err := gexec.Start(cmd, buffer, buffer) 51 | Expect(err).NotTo(HaveOccurred()) 52 | Eventually(session).Should(gexec.Exit(0)) 53 | Expect(buffer.String()).To(ContainSubstring("type User struct")) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | ignore-tags: 8 | - 'v*' 9 | pull_request: 10 | 11 | jobs: 12 | pipeline: 13 | name: pipeline 14 | runs-on: ubuntu-latest 15 | 16 | services: 17 | postgres: 18 | image: postgres:10.8 19 | env: 20 | POSTGRES_USER: prana 21 | POSTGRES_PASSWORD: prana 22 | POSTGRES_DB: prana 23 | ports: 24 | - 5432:5432 25 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 26 | 27 | mysql: 28 | image: mysql:5.7 29 | env: 30 | MYSQL_USER: prana 31 | MYSQL_PASSWORD: prana 32 | MYSQL_DATABASE: prana 33 | MYSQL_ROOT_PASSWORD: prana 34 | ports: 35 | - 3306 36 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 37 | 38 | steps: 39 | - name: Check out Code 40 | uses: actions/checkout@v1 41 | 42 | - name: Set up Golang 43 | uses: actions/setup-go@v1 44 | with: 45 | go-version: '1.16.x' 46 | 47 | - name: Run Tests 48 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 49 | env: 50 | TEST_PSQL_URL: "postgres://prana:prana@127.0.0.1:${{ job.services.postgres.ports[5432] }}/prana?sslmode=disable" 51 | TEST_MYSQL_URL: "prana:prana@tcp(127.0.0.1:${{ job.services.mysql.ports['3306'] }})/prana" 52 | 53 | - name: Upload tests coverage to codeconv.io 54 | uses: codecov/codecov-action@v1 55 | with: 56 | token: ${{ secrets.CODECOV_TOKEN }} 57 | -------------------------------------------------------------------------------- /sqlexec/scanner_test.go: -------------------------------------------------------------------------------- 1 | package sqlexec_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/phogolabs/prana/sqlexec" 10 | ) 11 | 12 | var _ = Describe("Scanner", func() { 13 | var scanner *sqlexec.Scanner 14 | 15 | BeforeEach(func() { 16 | scanner = &sqlexec.Scanner{} 17 | }) 18 | 19 | It("returns the tagged statements successfully", func() { 20 | buffer := &bytes.Buffer{} 21 | fmt.Fprintln(buffer, "-- name: save-user") 22 | fmt.Fprintln(buffer, "SELECT * FROM users;") 23 | 24 | queries := scanner.Scan(buffer) 25 | 26 | Expect(queries).To(HaveLen(1)) 27 | Expect(queries).To(HaveKeyWithValue("save-user", "SELECT * FROM users;")) 28 | }) 29 | 30 | Context("when the tag is followed by another comment", func() { 31 | It("returns the tagged statements successfully", func() { 32 | buffer := &bytes.Buffer{} 33 | fmt.Fprintln(buffer, "-- name: save-user") 34 | fmt.Fprintln(buffer, "-- information") 35 | fmt.Fprintln(buffer, "SELECT * FROM users;") 36 | 37 | queries := scanner.Scan(buffer) 38 | 39 | Expect(queries).To(HaveLen(1)) 40 | Expect(queries).To(HaveKeyWithValue("save-user", "-- information\nSELECT * FROM users;")) 41 | }) 42 | }) 43 | 44 | Context("when there is a tag does not have body", func() { 45 | It("returns an empty repository", func() { 46 | buffer := &bytes.Buffer{} 47 | fmt.Fprintln(buffer, "-- name: empty-query") 48 | fmt.Fprintln(buffer, "-- name: save-user") 49 | fmt.Fprintln(buffer, "SELECT * FROM users;") 50 | 51 | queries := scanner.Scan(buffer) 52 | 53 | Expect(queries).To(HaveLen(1)) 54 | Expect(queries).To(HaveKeyWithValue("save-user", "SELECT * FROM users;")) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /sqlexec/runner.go: -------------------------------------------------------------------------------- 1 | package sqlexec 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/jmoiron/sqlx" 8 | "github.com/olekukonko/tablewriter" 9 | ) 10 | 11 | // Runner runs a SQL statement for given command name and parameters. 12 | type Runner struct { 13 | // FileSystem represents the project directory file system. 14 | FileSystem FileSystem 15 | // DB is a client to underlying database. 16 | DB *sqlx.DB 17 | } 18 | 19 | // Run runs a given command with provided parameters. 20 | func (r *Runner) Run(name string, args ...Param) (*Rows, error) { 21 | provider := &Provider{ 22 | dialect: r.DB.DriverName(), 23 | } 24 | 25 | if err := provider.ReadDir(r.FileSystem); err != nil { 26 | return nil, err 27 | } 28 | 29 | query, err := provider.Query(name) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | stmt, err := r.DB.Preparex(query) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | defer func() { 40 | if stmtErr := stmt.Close(); err == nil { 41 | err = stmtErr 42 | } 43 | }() 44 | 45 | return stmt.Queryx(args...) 46 | } 47 | 48 | // Print prints the rows 49 | func (r *Runner) Print(writer io.Writer, rows *sqlx.Rows) error { 50 | table := tablewriter.NewWriter(writer) 51 | 52 | columns, err := rows.Columns() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | table.SetHeader(columns) 58 | 59 | for rows.Next() { 60 | record, err := rows.SliceScan() 61 | if err != nil { 62 | return err 63 | } 64 | 65 | row := []string{} 66 | 67 | for _, column := range record { 68 | if data, ok := column.([]byte); ok { 69 | column = string(data) 70 | } 71 | row = append(row, fmt.Sprintf("%v", column)) 72 | } 73 | 74 | table.Append(row) 75 | } 76 | 77 | table.Render() 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /cmd/prana/main.go: -------------------------------------------------------------------------------- 1 | // Command Line Interface of GOM. 2 | package main 3 | 4 | import ( 5 | "os" 6 | "sort" 7 | 8 | _ "github.com/go-sql-driver/mysql" 9 | _ "github.com/lib/pq" 10 | _ "github.com/mattn/go-sqlite3" 11 | 12 | "github.com/phogolabs/cli" 13 | "github.com/phogolabs/prana/cmd" 14 | ) 15 | 16 | // version is injected by goreleaser.com 17 | var version string = "unknown" 18 | 19 | var flags = []cli.Flag{ 20 | &cli.StringFlag{ 21 | Name: "log-level", 22 | Value: "info", 23 | Usage: "level of logging", 24 | EnvVar: "PRANA_LOG_LEVEL", 25 | }, 26 | &cli.StringFlag{ 27 | Name: "log-format", 28 | Value: "", 29 | Usage: "format of the logs", 30 | EnvVar: "PRANA_LOG_FORMAT", 31 | }, 32 | &cli.StringFlag{ 33 | Name: "database-url", 34 | Value: "sqlite3://prana.db", 35 | Usage: "Database URL", 36 | EnvVar: "PRANA_DB_URL", 37 | }, 38 | } 39 | 40 | func main() { 41 | var ( 42 | migration = &cmd.SQLMigration{} 43 | routine = &cmd.SQLRoutine{} 44 | model = &cmd.SQLModel{} 45 | repository = &cmd.SQLRepository{} 46 | ) 47 | 48 | commands := []*cli.Command{ 49 | migration.CreateCommand(), 50 | routine.CreateCommand(), 51 | model.CreateCommand(), 52 | repository.CreateCommand(), 53 | } 54 | 55 | app := &cli.App{ 56 | Name: "prana", 57 | HelpName: "prana", 58 | Usage: "Golang Database Manager", 59 | UsageText: "prana [global options]", 60 | Version: version, 61 | Writer: os.Stdout, 62 | ErrWriter: os.Stderr, 63 | Flags: flags, 64 | Before: cmd.BeforeEach, 65 | Commands: commands, 66 | } 67 | 68 | sort.Sort(cli.FlagsByName(app.Flags)) 69 | sort.Sort(cli.CommandsByName(app.Commands)) 70 | 71 | for _, command := range commands { 72 | sort.Sort(cli.FlagsByName(command.Flags)) 73 | sort.Sort(cli.CommandsByName(command.Commands)) 74 | } 75 | 76 | app.Run(os.Args) 77 | } 78 | -------------------------------------------------------------------------------- /integration/model_print_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/jmoiron/sqlx" 11 | "github.com/onsi/gomega/gexec" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("Model Sync", func() { 18 | var cmd *exec.Cmd 19 | 20 | BeforeEach(func() { 21 | dir, err := ioutil.TempDir("", "gom") 22 | Expect(err).To(BeNil()) 23 | 24 | query := &bytes.Buffer{} 25 | fmt.Fprintln(query, "CREATE TABLE users (") 26 | fmt.Fprintln(query, " id INT PRIMARY KEY NOT NULL,") 27 | fmt.Fprintln(query, " first_name TEXT NOT NULL,") 28 | fmt.Fprintln(query, " last_name TEXT") 29 | fmt.Fprintln(query, ");") 30 | 31 | db, err := sqlx.Open("sqlite3", filepath.Join(dir, "gom.db")) 32 | Expect(err).To(BeNil()) 33 | _, err = db.Exec(query.String()) 34 | Expect(err).To(BeNil()) 35 | Expect(db.Close()).To(Succeed()) 36 | 37 | cmd = exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "migration", "setup") 38 | cmd.Dir = dir 39 | 40 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 41 | Expect(err).NotTo(HaveOccurred()) 42 | Eventually(session).Should(gexec.Exit(0)) 43 | 44 | cmd = exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "model", "sync") 45 | cmd.Dir = dir 46 | }) 47 | 48 | It("syncs the model successfully", func() { 49 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 50 | Expect(err).NotTo(HaveOccurred()) 51 | Eventually(session).Should(gexec.Exit(0)) 52 | 53 | path := filepath.Join(cmd.Dir, "/database/model/schema.go") 54 | Expect(path).To(BeARegularFile()) 55 | 56 | data, err := ioutil.ReadFile(path) 57 | Expect(err).To(BeNil()) 58 | 59 | script := string(data) 60 | Expect(script).To(ContainSubstring("type User struct")) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /integration/routine_sync_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/jmoiron/sqlx" 11 | "github.com/onsi/gomega/gexec" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("Script Sync", func() { 18 | var cmd *exec.Cmd 19 | 20 | BeforeEach(func() { 21 | dir, err := ioutil.TempDir("", "gom") 22 | Expect(err).To(BeNil()) 23 | 24 | query := &bytes.Buffer{} 25 | fmt.Fprintln(query, "CREATE TABLE users (") 26 | fmt.Fprintln(query, " id INT PRIMARY KEY NOT NULL,") 27 | fmt.Fprintln(query, " first_name TEXT NOT NULL,") 28 | fmt.Fprintln(query, " last_name TEXT") 29 | fmt.Fprintln(query, ");") 30 | 31 | db, err := sqlx.Open("sqlite3", filepath.Join(dir, "gom.db")) 32 | Expect(err).To(BeNil()) 33 | _, err = db.Exec(query.String()) 34 | Expect(err).To(BeNil()) 35 | Expect(db.Close()).To(Succeed()) 36 | 37 | cmd = exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "migration", "setup") 38 | cmd.Dir = dir 39 | 40 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 41 | Expect(err).NotTo(HaveOccurred()) 42 | Eventually(session).Should(gexec.Exit(0)) 43 | 44 | cmd = exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "routine", "sync") 45 | cmd.Dir = dir 46 | }) 47 | 48 | It("syncs the script command successfully", func() { 49 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 50 | Expect(err).NotTo(HaveOccurred()) 51 | Eventually(session).Should(gexec.Exit(0)) 52 | 53 | path := filepath.Join(cmd.Dir, "/database/routine/routine.sql") 54 | Expect(path).To(BeARegularFile()) 55 | 56 | data, err := ioutil.ReadFile(path) 57 | Expect(err).To(BeNil()) 58 | 59 | script := string(data) 60 | Expect(script).To(ContainSubstring("-- name: select-all-users")) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /sqlmigr/generator.go: -------------------------------------------------------------------------------- 1 | package sqlmigr 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "time" 9 | ) 10 | 11 | var _ MigrationGenerator = &Generator{} 12 | 13 | // Generator generates a new sqlmigr file for given directory. 14 | type Generator struct { 15 | // FileSystem is the file system where all sqlmigrs are created. 16 | FileSystem WriteFileSystem 17 | } 18 | 19 | // Create creates a new sqlmigr. 20 | func (g *Generator) Create(m *Migration) error { 21 | return g.Write(m, nil) 22 | } 23 | 24 | // Write creates a new sqlmigr for given content. 25 | func (g *Generator) Write(m *Migration, content *Content) error { 26 | buffer := &bytes.Buffer{} 27 | 28 | fmt.Fprintln(buffer, "-- Auto-generated at", m.CreatedAt.Format(time.RFC1123)) 29 | fmt.Fprintln(buffer, "-- Please do not change the name attributes") 30 | fmt.Fprintln(buffer) 31 | fmt.Fprintln(buffer, "-- name: up") 32 | 33 | if content != nil { 34 | if _, err := io.Copy(buffer, content.UpCommand); err != nil { 35 | return err 36 | } 37 | } else { 38 | fmt.Fprintln(buffer) 39 | } 40 | 41 | fmt.Fprintln(buffer, "-- name: down") 42 | 43 | if content != nil { 44 | if _, err := io.Copy(buffer, content.DownCommand); err != nil { 45 | return err 46 | } 47 | } else { 48 | fmt.Fprintln(buffer) 49 | } 50 | 51 | for _, filename := range m.Filenames() { 52 | if err := g.write(filename, buffer.Bytes(), 0600); err != nil { 53 | return err 54 | } 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (g *Generator) write(filename string, data []byte, perm os.FileMode) error { 61 | f, err := g.FileSystem.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | if writer, ok := f.(io.Writer); ok { 67 | var n int 68 | 69 | if n, err = writer.Write(data); err == nil && n < len(data) { 70 | err = io.ErrShortWrite 71 | } 72 | } 73 | 74 | if xerr := f.Close(); err == nil { 75 | err = xerr 76 | } 77 | 78 | return err 79 | } 80 | -------------------------------------------------------------------------------- /integration/migration_create_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os/exec" 6 | "path/filepath" 7 | 8 | "github.com/onsi/gomega/gbytes" 9 | "github.com/onsi/gomega/gexec" 10 | 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var _ = Describe("Migration Create", func() { 16 | var cmd *exec.Cmd 17 | 18 | JustBeforeEach(func() { 19 | dir, err := ioutil.TempDir("", "gom") 20 | Expect(err).To(BeNil()) 21 | 22 | cmd = exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "migration", "create") 23 | cmd.Dir = dir 24 | }) 25 | 26 | It("generates migration successfully", func() { 27 | cmd.Args = append(cmd.Args, "schema") 28 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 29 | Expect(err).NotTo(HaveOccurred()) 30 | Eventually(session).Should(gexec.Exit(0)) 31 | 32 | path := filepath.Join(cmd.Dir, "/database/migration/*_schema.sql") 33 | matches, err := filepath.Glob(path) 34 | Expect(err).NotTo(HaveOccurred()) 35 | 36 | Expect(matches).To(HaveLen(1)) 37 | Expect(matches[0]).To(BeARegularFile()) 38 | }) 39 | 40 | Context("when the name has space in it", func() { 41 | It("generates migration successfully", func() { 42 | cmd.Args = append(cmd.Args, "my initial schema") 43 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 44 | Expect(err).NotTo(HaveOccurred()) 45 | Eventually(session).Should(gexec.Exit(0)) 46 | 47 | path := filepath.Join(cmd.Dir, "/database/migration/*_my_initial_schema.sql") 48 | matches, err := filepath.Glob(path) 49 | Expect(err).NotTo(HaveOccurred()) 50 | 51 | Expect(matches).To(HaveLen(1)) 52 | Expect(matches[0]).To(BeARegularFile()) 53 | }) 54 | }) 55 | 56 | Context("when the name is not provided", func() { 57 | It("returns an error", func() { 58 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 59 | Expect(err).NotTo(HaveOccurred()) 60 | Eventually(session).Should(gexec.Exit(103)) 61 | Expect(session.Err).To(gbytes.Say("Create command expects a single argument")) 62 | }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /sqlmodel/generator.go: -------------------------------------------------------------------------------- 1 | package sqlmodel 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "time" 9 | 10 | "github.com/aymerick/raymond" 11 | "golang.org/x/tools/imports" 12 | ) 13 | 14 | func init() { 15 | raymond.RegisterHelper("now", func() raymond.SafeString { 16 | return raymond.SafeString(time.Now().Format(time.RFC1123)) 17 | }) 18 | } 19 | 20 | var ( 21 | _ Generator = &Codegen{} 22 | ) 23 | 24 | // Codegen generates Golang structs from database schema 25 | type Codegen struct { 26 | // Meta information 27 | Meta map[string]interface{} 28 | // Format the code 29 | Format bool 30 | } 31 | 32 | // Generate generates the golang structs from database schema 33 | func (g *Codegen) Generate(ctx *GeneratorContext) error { 34 | buffer := &bytes.Buffer{} 35 | 36 | if len(ctx.Schema.Tables) == 0 { 37 | return nil 38 | } 39 | 40 | template, err := g.template(ctx.Template) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | param := map[string]interface{}{ 46 | "Schema": ctx.Schema, 47 | "Meta": g.Meta, 48 | } 49 | 50 | result, err := raymond.Render(template, param) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | _, err = buffer.WriteString(result) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | if g.Format { 61 | if err := g.format(ctx.Template, buffer); err != nil { 62 | return err 63 | } 64 | } 65 | 66 | _, err = io.Copy(ctx.Writer, buffer) 67 | return err 68 | } 69 | 70 | func (g *Codegen) template(name string) (string, error) { 71 | path := fmt.Sprintf("template/%s.mustache", name) 72 | // open path 73 | template, err := template.Open(path) 74 | if err != nil { 75 | return "", err 76 | } 77 | defer template.Close() 78 | 79 | data, err := ioutil.ReadAll(template) 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | return string(data), nil 85 | } 86 | 87 | func (g *Codegen) format(template string, buffer *bytes.Buffer) error { 88 | data, err := imports.Process(template, buffer.Bytes(), nil) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | buffer.Reset() 94 | 95 | _, err = buffer.Write(data) 96 | return err 97 | } 98 | -------------------------------------------------------------------------------- /fake/model_tag_builder.go: -------------------------------------------------------------------------------- 1 | // This file was generated by counterfeiter 2 | package fake 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/phogolabs/prana/sqlmodel" 8 | ) 9 | 10 | type ModelTagBuilder struct { 11 | BuildStub func(column *sqlmodel.Column) string 12 | buildMutex sync.RWMutex 13 | buildArgsForCall []struct { 14 | column *sqlmodel.Column 15 | } 16 | buildReturns struct { 17 | result1 string 18 | } 19 | invocations map[string][][]interface{} 20 | invocationsMutex sync.RWMutex 21 | } 22 | 23 | func (fake *ModelTagBuilder) Build(column *sqlmodel.Column) string { 24 | fake.buildMutex.Lock() 25 | fake.buildArgsForCall = append(fake.buildArgsForCall, struct { 26 | column *sqlmodel.Column 27 | }{column}) 28 | fake.recordInvocation("Build", []interface{}{column}) 29 | fake.buildMutex.Unlock() 30 | if fake.BuildStub != nil { 31 | return fake.BuildStub(column) 32 | } 33 | return fake.buildReturns.result1 34 | } 35 | 36 | func (fake *ModelTagBuilder) BuildCallCount() int { 37 | fake.buildMutex.RLock() 38 | defer fake.buildMutex.RUnlock() 39 | return len(fake.buildArgsForCall) 40 | } 41 | 42 | func (fake *ModelTagBuilder) BuildArgsForCall(i int) *sqlmodel.Column { 43 | fake.buildMutex.RLock() 44 | defer fake.buildMutex.RUnlock() 45 | return fake.buildArgsForCall[i].column 46 | } 47 | 48 | func (fake *ModelTagBuilder) BuildReturns(result1 string) { 49 | fake.BuildStub = nil 50 | fake.buildReturns = struct { 51 | result1 string 52 | }{result1} 53 | } 54 | 55 | func (fake *ModelTagBuilder) Invocations() map[string][][]interface{} { 56 | fake.invocationsMutex.RLock() 57 | defer fake.invocationsMutex.RUnlock() 58 | fake.buildMutex.RLock() 59 | defer fake.buildMutex.RUnlock() 60 | return fake.invocations 61 | } 62 | 63 | func (fake *ModelTagBuilder) recordInvocation(key string, args []interface{}) { 64 | fake.invocationsMutex.Lock() 65 | defer fake.invocationsMutex.Unlock() 66 | if fake.invocations == nil { 67 | fake.invocations = map[string][][]interface{}{} 68 | } 69 | if fake.invocations[key] == nil { 70 | fake.invocations[key] = [][]interface{}{} 71 | } 72 | fake.invocations[key] = append(fake.invocations[key], args) 73 | } 74 | 75 | var _ sqlmodel.TagBuilder = new(ModelTagBuilder) 76 | -------------------------------------------------------------------------------- /integration/migration_setup_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os/exec" 6 | "path/filepath" 7 | 8 | "github.com/onsi/gomega/gbytes" 9 | "github.com/onsi/gomega/gexec" 10 | 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | var _ = Describe("Migration Setup", func() { 16 | var cmd *exec.Cmd 17 | 18 | BeforeEach(func() { 19 | dir, err := ioutil.TempDir("", "gom") 20 | Expect(err).To(BeNil()) 21 | 22 | cmd = exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "migration", "setup") 23 | cmd.Dir = dir 24 | }) 25 | 26 | It("setups the project successfully", func() { 27 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 28 | Expect(err).NotTo(HaveOccurred()) 29 | Eventually(session).Should(gexec.Exit(0)) 30 | Eventually(session.Err).Should(gbytes.Say("Setup project directory at")) 31 | 32 | path := filepath.Join(cmd.Dir, "/database/migration/00060524000000_setup.sql") 33 | Expect(path).To(BeARegularFile()) 34 | }) 35 | 36 | Context("when the setup command is executed more than once", func() { 37 | It("returns an error", func() { 38 | setupCmd := exec.Command(gomPath, "--database-url", "sqlite3://gom.db", "migration", "setup") 39 | setupCmd.Dir = cmd.Dir 40 | 41 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 42 | Expect(err).NotTo(HaveOccurred()) 43 | Eventually(session).Should(gexec.Exit(0)) 44 | 45 | Expect(session.Err).Should(gbytes.Say("Setup project directory at")) 46 | 47 | session, err = gexec.Start(setupCmd, GinkgoWriter, GinkgoWriter) 48 | Expect(err).NotTo(HaveOccurred()) 49 | Eventually(session).Should(gexec.Exit(0)) 50 | 51 | Expect(session.Err).ShouldNot(gbytes.Say("Setup project directory at")) 52 | }) 53 | }) 54 | 55 | Context("when the database is not available", func() { 56 | BeforeEach(func() { 57 | cmd.Args = []string{gomPath, "--database-url", "wrong://database.db", "migration", "setup"} 58 | }) 59 | 60 | It("returns an error", func() { 61 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 62 | Expect(err).NotTo(HaveOccurred()) 63 | Eventually(session).Should(gexec.Exit(101)) 64 | Expect(session.Err).To(gbytes.Say(`sql: unknown driver`)) 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /integration/migration_status_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/onsi/gomega/gexec" 14 | 15 | . "github.com/onsi/ginkgo" 16 | . "github.com/onsi/gomega" 17 | ) 18 | 19 | var _ = Describe("Migration Status", func() { 20 | var ( 21 | cmd *exec.Cmd 22 | db *sql.DB 23 | ) 24 | 25 | JustBeforeEach(func() { 26 | dir, err := ioutil.TempDir("", "gom") 27 | Expect(err).To(BeNil()) 28 | 29 | args := []string{"--database-url", "sqlite3://gom.db"} 30 | 31 | Setup(args, dir) 32 | 33 | args = append(args, "migration") 34 | 35 | script := &bytes.Buffer{} 36 | fmt.Fprintln(script, "-- name: up") 37 | fmt.Fprintln(script, "SELECT * FROM migrations;") 38 | fmt.Fprintln(script, "-- name: down") 39 | fmt.Fprintln(script, "SELECT * FROM migrations;") 40 | 41 | path := filepath.Join(dir, "/database/migration/20060102150405_schema.sql") 42 | Expect(ioutil.WriteFile(path, script.Bytes(), 0700)).To(Succeed()) 43 | 44 | cmd = exec.Command(gomPath, append(args, "status")...) 45 | cmd.Dir = dir 46 | 47 | db, err = sql.Open("sqlite3", filepath.Join(dir, "gom.db")) 48 | Expect(err).NotTo(HaveOccurred()) 49 | }) 50 | 51 | AfterEach(func() { 52 | Expect(db.Close()).To(Succeed()) 53 | }) 54 | 55 | It("returns the migration status successfully", func() { 56 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 57 | Expect(err).NotTo(HaveOccurred()) 58 | Eventually(session).Should(gexec.Exit(0)) 59 | 60 | row := db.QueryRow("SELECT created_at FROM migrations WHERE id = '00060524000000'") 61 | 62 | timestamp := time.Now() 63 | Expect(row.Scan(×tamp)).To(Succeed()) 64 | 65 | Expect(string(session.Out.Contents())).To(ContainSubstring("00060524000000")) 66 | Expect(string(session.Out.Contents())).To(ContainSubstring("20060102150405")) 67 | }) 68 | 69 | Context("when the database is not available", func() { 70 | It("returns an error", func() { 71 | Expect(os.Remove(filepath.Join(cmd.Dir, "gom.db"))).To(Succeed()) 72 | 73 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 74 | Expect(err).NotTo(HaveOccurred()) 75 | Eventually(session).Should(gexec.Exit(-1)) 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /sqlmigr/generator_test.go: -------------------------------------------------------------------------------- 1 | package sqlmigr_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/phogolabs/prana/sqlmigr" 10 | "github.com/phogolabs/prana/storage" 11 | 12 | . "github.com/onsi/ginkgo" 13 | . "github.com/onsi/gomega" 14 | ) 15 | 16 | var _ = Describe("Generator", func() { 17 | var ( 18 | generator *sqlmigr.Generator 19 | item *sqlmigr.Migration 20 | dir string 21 | ) 22 | 23 | BeforeEach(func() { 24 | var err error 25 | 26 | dir, err = ioutil.TempDir("", "prana_generator") 27 | Expect(err).To(BeNil()) 28 | 29 | dir = filepath.Join(dir, "sqlmigr") 30 | Expect(os.MkdirAll(dir, 0700)).To(Succeed()) 31 | 32 | generator = &sqlmigr.Generator{ 33 | FileSystem: storage.New(dir), 34 | } 35 | 36 | item = &sqlmigr.Migration{ 37 | ID: "20160102150", 38 | Description: "schema", 39 | Drivers: []string{"sql"}, 40 | } 41 | }) 42 | 43 | Describe("Create", func() { 44 | It("creates a migration successfully", func() { 45 | err := generator.Create(item) 46 | Expect(err).To(BeNil()) 47 | 48 | path := filepath.Join(dir, item.Filenames()[0]) 49 | Expect(path).To(BeARegularFile()) 50 | Expect(dir).To(BeADirectory()) 51 | 52 | data, err := ioutil.ReadFile(path) 53 | Expect(err).To(BeNil()) 54 | 55 | script := string(data) 56 | Expect(script).To(ContainSubstring("-- name: up")) 57 | Expect(script).To(ContainSubstring("-- name: down")) 58 | }) 59 | }) 60 | 61 | Describe("Write", func() { 62 | It("writes a sqlmigr successfully", func() { 63 | content := &sqlmigr.Content{ 64 | UpCommand: bytes.NewBufferString("upgrade"), 65 | DownCommand: bytes.NewBufferString("rollback"), 66 | } 67 | 68 | Expect(generator.Write(item, content)).To(Succeed()) 69 | Expect(dir).To(BeADirectory()) 70 | 71 | path := filepath.Join(dir, item.Filenames()[0]) 72 | Expect(path).To(BeARegularFile()) 73 | 74 | data, err := ioutil.ReadFile(path) 75 | Expect(err).To(BeNil()) 76 | 77 | script := string(data) 78 | Expect(script).To(ContainSubstring("-- name: up")) 79 | Expect(script).To(ContainSubstring("upgrade")) 80 | Expect(script).To(ContainSubstring("-- name: down")) 81 | Expect(script).To(ContainSubstring("rollback")) 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /cmd/hook.go: -------------------------------------------------------------------------------- 1 | // Package cmd provides a set of commands used in CLI. 2 | package cmd 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | "github.com/jmoiron/sqlx" 11 | "github.com/phogolabs/cli" 12 | "github.com/phogolabs/log" 13 | "github.com/phogolabs/log/handler/json" 14 | "github.com/phogolabs/prana" 15 | "github.com/phogolabs/prana/sqlmodel" 16 | ) 17 | 18 | const ( 19 | // ErrCodeArg when the CLI argument is invalid. 20 | ErrCodeArg = 101 21 | // ErrCodeMigration when the migration operation fails. 22 | ErrCodeMigration = 103 23 | // ErrCodeCommand when the SQL command operation fails. 24 | ErrCodeCommand = 104 25 | // ErrCodeSchema when the SQL schema operation fails. 26 | ErrCodeSchema = 105 27 | ) 28 | 29 | type logHandler struct { 30 | Writer io.Writer 31 | } 32 | 33 | func (h *logHandler) Handle(entry *log.Entry) { 34 | fmt.Fprintln(h.Writer, entry.Message) 35 | } 36 | 37 | // BeforeEach is a function executed before each CLI operation. 38 | func BeforeEach(ctx *cli.Context) error { 39 | var handler log.Handler 40 | 41 | if strings.EqualFold("json", ctx.String("log-format")) { 42 | handler = json.New(os.Stderr) 43 | } else { 44 | handler = &logHandler{ 45 | Writer: os.Stderr, 46 | } 47 | } 48 | 49 | log.SetHandler(handler) 50 | 51 | level, err := log.ParseLevel(ctx.String("log-level")) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | log.SetLevel(level) 57 | return nil 58 | } 59 | 60 | func open(ctx *cli.Context) (*sqlx.DB, error) { 61 | driver, conn, err := prana.ParseURL(ctx.GlobalString("database-url")) 62 | if err != nil { 63 | return nil, cli.NewExitError(err.Error(), ErrCodeArg) 64 | } 65 | 66 | db, err := sqlx.Open(driver, conn) 67 | if err != nil { 68 | return nil, cli.NewExitError(err.Error(), ErrCodeArg) 69 | } 70 | 71 | return db, nil 72 | } 73 | 74 | func provider(db *sqlx.DB) (sqlmodel.SchemaProvider, error) { 75 | switch db.DriverName() { 76 | case "sqlite3": 77 | return &sqlmodel.SQLiteProvider{DB: db}, nil 78 | case "postgres": 79 | return &sqlmodel.PostgreSQLProvider{DB: db}, nil 80 | case "mysql": 81 | return &sqlmodel.MySQLProvider{DB: db}, nil 82 | default: 83 | err := fmt.Errorf("Cannot find provider for database driver '%s'", db.DriverName()) 84 | return nil, cli.NewExitError(err.Error(), ErrCodeArg) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | // Package prana facilitates the work with applications that use database for 2 | // their store 3 | package prana 4 | 5 | import ( 6 | "errors" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/go-sql-driver/mysql" 11 | "github.com/phogolabs/log" 12 | ) 13 | 14 | //go:generate counterfeiter -fake-name Logger -o ./fake/logger.go . Logger 15 | 16 | // Logger used to log any output 17 | type Logger = log.Logger 18 | 19 | const ( 20 | // DriverMySQL represents the database driver name of MySQL 21 | DriverMySQL = "mysql" 22 | // DriverSQLite represents the database driver name of SQLite3 23 | DriverSQLite = "sqlite3" 24 | // DriverPostgres represents the databse driver name of Postgres 25 | DriverPostgres = "postgres" 26 | ) 27 | 28 | // Error codes returned by failures to parse a dsn. 29 | var ( 30 | errNoDriverName = errors.New("no driver name") 31 | errEmptyConnURL = errors.New("url cannot be empty") 32 | errInvalidDSN = errors.New("invalid dsn") 33 | ) 34 | 35 | // ParseURL parses a URL and returns the database driver and connection string to the database 36 | func ParseURL(conn string) (string, string, error) { 37 | driver, source, err := parseRawURL(conn) 38 | if err != nil { 39 | return "", "", err 40 | } 41 | 42 | switch driver { 43 | case DriverMySQL: 44 | mysqlSource, err := parseMySQL(driver, source) 45 | if err != nil { 46 | return "", "", err 47 | } 48 | return driver, mysqlSource, nil 49 | case DriverSQLite: 50 | return driver, source, nil 51 | case DriverPostgres: 52 | return driver, conn, nil 53 | default: 54 | return driver, conn, nil 55 | } 56 | } 57 | 58 | // parseRawURL returns the db driver name from a URL string 59 | func parseRawURL(url string) (driverName string, path string, err error) { 60 | if url == "" { 61 | return "", "", errEmptyConnURL 62 | } 63 | 64 | // scheme must match 65 | prog := regexp.MustCompile(`^([a-zA-Z][a-zA-Z0-9+-.]*)://(.*)$`) 66 | matches := prog.FindStringSubmatch(url) 67 | 68 | if len(matches) > 2 { 69 | return strings.ToLower(matches[1]), matches[2], nil 70 | } 71 | return "", "", errInvalidDSN 72 | } 73 | 74 | func parseMySQL(driver, source string) (string, error) { 75 | cfg, err := mysql.ParseDSN(source) 76 | if err != nil { 77 | return "", err 78 | } 79 | cfg.ParseTime = true 80 | 81 | return cfg.FormatDSN(), nil 82 | } 83 | -------------------------------------------------------------------------------- /common_test.go: -------------------------------------------------------------------------------- 1 | package prana_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | "github.com/phogolabs/prana" 7 | ) 8 | 9 | var _ = Describe("ParseURL", func() { 10 | It("parses the SQLite connection string successfully", func() { 11 | driver, source, err := prana.ParseURL("sqlite3://./prana.db") 12 | Expect(err).NotTo(HaveOccurred()) 13 | Expect(driver).To(Equal("sqlite3")) 14 | Expect(source).To(Equal("./prana.db")) 15 | }) 16 | 17 | Describe("MySQL", func() { 18 | It("parses the MySQL connection string successfully", func() { 19 | driver, source, err := prana.ParseURL("mysql://root@/prana") 20 | Expect(err).NotTo(HaveOccurred()) 21 | Expect(driver).To(Equal("mysql")) 22 | Expect(source).To(Equal("root@tcp(127.0.0.1:3306)/prana?parseTime=true")) 23 | }) 24 | 25 | It("parses the MySQL connection string with custom port successfully", func() { 26 | driver, source, err := prana.ParseURL("mysql://root:password@tcp(127.0.0.1:13306)/prana") 27 | Expect(err).NotTo(HaveOccurred()) 28 | Expect(driver).To(Equal("mysql")) 29 | Expect(source).To(Equal("root:password@tcp(127.0.0.1:13306)/prana?parseTime=true")) 30 | }) 31 | 32 | Context("when the DSN is invalid", func() { 33 | It("returns the error", func() { 34 | driver, source, err := prana.ParseURL("mysql://@net(addr/") 35 | Expect(err).To(MatchError("invalid DSN: network address not terminated (missing closing brace)")) 36 | Expect(driver).To(BeEmpty()) 37 | Expect(source).To(BeEmpty()) 38 | }) 39 | }) 40 | }) 41 | 42 | It("parses the PostgreSQL connection string successfully", func() { 43 | driver, source, err := prana.ParseURL("postgres://localhost/prana?sslmode=disable") 44 | Expect(err).NotTo(HaveOccurred()) 45 | Expect(driver).To(Equal("postgres")) 46 | Expect(source).To(Equal("postgres://localhost/prana?sslmode=disable")) 47 | }) 48 | 49 | Context("when the URL is empty", func() { 50 | It("returns an error", func() { 51 | driver, source, err := prana.ParseURL("") 52 | Expect(driver).To(BeEmpty()) 53 | Expect(source).To(BeEmpty()) 54 | Expect(err).To(MatchError("url cannot be empty")) 55 | }) 56 | }) 57 | 58 | Context("when the URL is invalid", func() { 59 | It("returns an error", func() { 60 | driver, source, err := prana.ParseURL("::") 61 | Expect(driver).To(BeEmpty()) 62 | Expect(source).To(BeEmpty()) 63 | Expect(err).To(MatchError("invalid dsn")) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /integration/migration_reset_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | 12 | "github.com/onsi/gomega/gexec" 13 | 14 | . "github.com/onsi/ginkgo" 15 | . "github.com/onsi/gomega" 16 | ) 17 | 18 | var _ = Describe("Migration Reset", func() { 19 | var ( 20 | cmd *exec.Cmd 21 | db *sql.DB 22 | ) 23 | 24 | JustBeforeEach(func() { 25 | dir, err := ioutil.TempDir("", "gom") 26 | Expect(err).To(BeNil()) 27 | 28 | args := []string{"--database-url", "sqlite3://gom.db"} 29 | 30 | Setup(args, dir) 31 | 32 | args = append(args, "migration") 33 | 34 | script := &bytes.Buffer{} 35 | fmt.Fprintln(script, "-- name: up") 36 | fmt.Fprintln(script, "SELECT * FROM migrations;") 37 | fmt.Fprintln(script, "-- name: down") 38 | fmt.Fprintln(script, "SELECT * FROM migrations;") 39 | 40 | path := filepath.Join(dir, "/database/migration/20060102150405_schema.sql") 41 | Expect(ioutil.WriteFile(path, script.Bytes(), 0700)).To(Succeed()) 42 | 43 | path = filepath.Join(dir, "/database/migration/20070102150405_trigger.sql") 44 | Expect(ioutil.WriteFile(path, script.Bytes(), 0700)).To(Succeed()) 45 | 46 | cmd = exec.Command(gomPath, append(args, "run", "--count", "2")...) 47 | cmd.Dir = dir 48 | 49 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 50 | Expect(err).NotTo(HaveOccurred()) 51 | Eventually(session).Should(gexec.Exit(0)) 52 | 53 | cmd = exec.Command(gomPath, append(args, "reset")...) 54 | cmd.Dir = dir 55 | 56 | db, err = sql.Open("sqlite3", filepath.Join(dir, "gom.db")) 57 | Expect(err).NotTo(HaveOccurred()) 58 | }) 59 | 60 | AfterEach(func() { 61 | Expect(db.Close()).To(Succeed()) 62 | }) 63 | 64 | It("resets the migrations successfully", func() { 65 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 66 | Expect(err).NotTo(HaveOccurred()) 67 | Eventually(session).Should(gexec.Exit(0)) 68 | 69 | row := db.QueryRow("SELECT COUNT(*) FROM migrations") 70 | 71 | count := 0 72 | Expect(row.Scan(&count)).To(Succeed()) 73 | Expect(count).To(Equal(3)) 74 | }) 75 | 76 | Context("when the database is not available", func() { 77 | It("returns an error", func() { 78 | Expect(os.Remove(filepath.Join(cmd.Dir, "gom.db"))).To(Succeed()) 79 | 80 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 81 | Expect(err).NotTo(HaveOccurred()) 82 | Eventually(session).Should(gexec.Exit(0)) 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /integration/routine_run_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | 12 | "github.com/onsi/gomega/gbytes" 13 | "github.com/onsi/gomega/gexec" 14 | 15 | . "github.com/onsi/ginkgo" 16 | . "github.com/onsi/gomega" 17 | ) 18 | 19 | var _ = Describe("Script Run", func() { 20 | var ( 21 | cmd *exec.Cmd 22 | db *sql.DB 23 | ) 24 | 25 | JustBeforeEach(func() { 26 | dir, err := ioutil.TempDir("", "gom") 27 | Expect(err).To(BeNil()) 28 | 29 | args := []string{"--database-url", "sqlite3://gom.db"} 30 | 31 | Setup(args, dir) 32 | 33 | script := &bytes.Buffer{} 34 | fmt.Fprintln(script, "-- name: show-migrations") 35 | fmt.Fprintln(script, "SELECT * FROM migrations;") 36 | 37 | Expect(os.MkdirAll(filepath.Join(dir, "/database/routine"), 0700)).To(Succeed()) 38 | path := filepath.Join(dir, "/database/routine/20060102150405.sql") 39 | Expect(ioutil.WriteFile(path, script.Bytes(), 0700)).To(Succeed()) 40 | 41 | cmd = exec.Command(gomPath, append(args, "routine", "run")...) 42 | cmd.Dir = dir 43 | 44 | db, err = sql.Open("sqlite3", filepath.Join(dir, "gom.db")) 45 | Expect(err).NotTo(HaveOccurred()) 46 | }) 47 | 48 | AfterEach(func() { 49 | Expect(db.Close()).To(Succeed()) 50 | }) 51 | 52 | It("runs command successfully", func() { 53 | cmd.Args = append(cmd.Args, "show-migrations") 54 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 55 | Expect(err).NotTo(HaveOccurred()) 56 | Eventually(session).Should(gexec.Exit(0)) 57 | 58 | Expect(session.Err).To(gbytes.Say("Running command 'show-migrations'")) 59 | }) 60 | 61 | Context("when the database is not available", func() { 62 | It("returns an error", func() { 63 | Expect(os.Remove(filepath.Join(cmd.Dir, "gom.db"))).To(Succeed()) 64 | 65 | cmd.Args = append(cmd.Args, "show-migrations") 66 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 67 | Expect(err).NotTo(HaveOccurred()) 68 | Eventually(session).Should(gexec.Exit(-1)) 69 | Expect(session.Err).To(gbytes.Say("no such table: migrations")) 70 | }) 71 | }) 72 | 73 | Context("when the command name is missing", func() { 74 | It("returns an error", func() { 75 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 76 | Expect(err).NotTo(HaveOccurred()) 77 | Eventually(session).Should(gexec.Exit(-1)) 78 | Expect(session.Err).To(gbytes.Say("Run command expects a single argument")) 79 | }) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /sqlmigr/printer_test.go: -------------------------------------------------------------------------------- 1 | package sqlmigr_test 2 | 3 | import ( 4 | "bytes" 5 | "time" 6 | 7 | "github.com/phogolabs/prana/fake" 8 | "github.com/phogolabs/prana/sqlmigr" 9 | 10 | . "github.com/onsi/ginkgo" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | var _ = Describe("Printer", func() { 15 | var migrations []*sqlmigr.Migration 16 | 17 | BeforeEach(func() { 18 | migrations = []*sqlmigr.Migration{ 19 | { 20 | ID: "20060102150405", 21 | Description: "First", 22 | CreatedAt: time.Now(), 23 | }, 24 | } 25 | }) 26 | 27 | Context("Flog", func() { 28 | var logger *fake.Logger 29 | 30 | BeforeEach(func() { 31 | logger = &fake.Logger{} 32 | logger.WithFieldsReturns(logger) 33 | }) 34 | 35 | It("logs the migration", func() { 36 | sqlmigr.Flog(logger, migrations) 37 | Expect(logger.WithFieldsCallCount()).To(Equal(1)) 38 | 39 | fields := logger.WithFieldsArgsForCall(0) 40 | Expect(fields).To(HaveKeyWithValue("Id", migrations[0].ID)) 41 | Expect(fields).To(HaveKeyWithValue("Description", migrations[0].Description)) 42 | Expect(fields).To(HaveKeyWithValue("Status", "executed")) 43 | }) 44 | 45 | Context("when the migration is not executed", func() { 46 | BeforeEach(func() { 47 | migrations[0].CreatedAt = time.Time{} 48 | }) 49 | 50 | It("logs the migration", func() { 51 | sqlmigr.Flog(logger, migrations) 52 | Expect(logger.WithFieldsCallCount()).To(Equal(1)) 53 | 54 | fields := logger.WithFieldsArgsForCall(0) 55 | Expect(fields).To(HaveKeyWithValue("Status", "pending")) 56 | }) 57 | }) 58 | }) 59 | 60 | Context("Ftable", func() { 61 | It("logs the migrations", func() { 62 | w := &bytes.Buffer{} 63 | sqlmigr.Ftable(w, migrations) 64 | 65 | content := w.String() 66 | Expect(content).To(ContainSubstring("Id")) 67 | Expect(content).To(ContainSubstring("Description")) 68 | Expect(content).To(ContainSubstring("Status")) 69 | Expect(content).To(ContainSubstring("Created At")) 70 | Expect(content).To(ContainSubstring("executed")) 71 | Expect(content).To(ContainSubstring("20060102150405")) 72 | Expect(content).To(ContainSubstring("First")) 73 | }) 74 | 75 | Context("when the migration is not applied", func() { 76 | BeforeEach(func() { 77 | migrations[0].CreatedAt = time.Time{} 78 | }) 79 | 80 | It("logs the migrations", func() { 81 | w := &bytes.Buffer{} 82 | sqlmigr.Ftable(w, migrations) 83 | 84 | content := w.String() 85 | Expect(content).To(ContainSubstring("pending")) 86 | }) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /sqlmodel/model_test.go: -------------------------------------------------------------------------------- 1 | package sqlmodel_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | "github.com/phogolabs/prana/sqlmodel" 7 | ) 8 | 9 | var _ = Describe("Model", func() { 10 | Describe("TypeDef", func() { 11 | It("returns the type name", func() { 12 | def := &sqlmodel.TypeDef{Type: "int"} 13 | Expect(def.As(false)).To(Equal("int")) 14 | }) 15 | 16 | Context("when the type is nullable", func() { 17 | It("returns the nullable type name", func() { 18 | def := &sqlmodel.TypeDef{ 19 | Type: "int", 20 | NullableType: "null.int", 21 | } 22 | Expect(def.As(true)).To(Equal("null.int")) 23 | }) 24 | }) 25 | }) 26 | 27 | Describe("ColumnType", func() { 28 | var columnType sqlmodel.ColumnType 29 | 30 | BeforeEach(func() { 31 | columnType = sqlmodel.ColumnType{ 32 | Name: "varchar", 33 | IsPrimaryKey: true, 34 | IsNullable: true, 35 | CharMaxLength: 200, 36 | } 37 | }) 38 | 39 | Context("when the type is user-defined", func() { 40 | BeforeEach(func() { 41 | columnType.Name = "USER-DEFINED" 42 | columnType.Underlying = "under" 43 | }) 44 | 45 | It("returns the correct db type", func() { 46 | Expect(columnType.String()).To(Equal("UNDER(200) PRIMARY KEY NULL")) 47 | }) 48 | }) 49 | 50 | It("returns the column type as string correctly", func() { 51 | Expect(columnType.String()).To(Equal("VARCHAR(200) PRIMARY KEY NULL")) 52 | }) 53 | 54 | Context("when the type is not null", func() { 55 | BeforeEach(func() { 56 | columnType.IsNullable = false 57 | }) 58 | 59 | It("returns the column type as string correctly", func() { 60 | Expect(columnType.String()).To(Equal("VARCHAR(200) PRIMARY KEY NOT NULL")) 61 | }) 62 | }) 63 | 64 | Context("when the type has precision and scale", func() { 65 | BeforeEach(func() { 66 | columnType.CharMaxLength = 0 67 | columnType.Name = "numeric" 68 | columnType.Precision = 10 69 | columnType.PrecisionScale = 20 70 | }) 71 | 72 | It("returns the column type as string correctly", func() { 73 | Expect(columnType.String()).To(Equal("NUMERIC(10, 20) PRIMARY KEY NULL")) 74 | }) 75 | }) 76 | 77 | Context("when the type has precision only", func() { 78 | BeforeEach(func() { 79 | columnType.CharMaxLength = 0 80 | columnType.Name = "numeric" 81 | columnType.Precision = 10 82 | columnType.PrecisionScale = 0 83 | }) 84 | 85 | It("returns the column type as string correctly", func() { 86 | Expect(columnType.String()).To(Equal("NUMERIC(10) PRIMARY KEY NULL")) 87 | }) 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /sqlexec/provider_test.go: -------------------------------------------------------------------------------- 1 | package sqlexec_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing/fstest" 7 | 8 | "github.com/phogolabs/prana/sqlexec" 9 | 10 | . "github.com/onsi/ginkgo" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | var _ = Describe("Provider", func() { 15 | var provider *sqlexec.Provider 16 | 17 | BeforeEach(func() { 18 | provider = &sqlexec.Provider{} 19 | }) 20 | 21 | Describe("ReadFrom", func() { 22 | var buffer *bytes.Buffer 23 | 24 | BeforeEach(func() { 25 | buffer = bytes.NewBufferString("-- name: up") 26 | fmt.Fprintln(buffer) 27 | fmt.Fprintln(buffer, "SELECT * FROM users;") 28 | }) 29 | 30 | It("loads the provider successfully", func() { 31 | n, err := provider.ReadFrom(buffer) 32 | Expect(n).To(Equal(int64(1))) 33 | Expect(err).To(Succeed()) 34 | 35 | query, err := provider.Query("up") 36 | Expect(err).NotTo(HaveOccurred()) 37 | Expect(query).To(Equal("SELECT * FROM users;")) 38 | }) 39 | 40 | Context("when the statement are duplicated", func() { 41 | It("returns an error", func() { 42 | n, err := provider.ReadFrom(buffer) 43 | Expect(n).To(Equal(int64(1))) 44 | Expect(err).To(Succeed()) 45 | 46 | buffer = bytes.NewBufferString("-- name: up") 47 | fmt.Fprintln(buffer) 48 | fmt.Fprintln(buffer, "SELECT * FROM categories;") 49 | 50 | n, err = provider.ReadFrom(buffer) 51 | Expect(n).To(BeZero()) 52 | Expect(err).To(MatchError("query 'up' already exists")) 53 | }) 54 | }) 55 | }) 56 | 57 | Describe("ReadDir", func() { 58 | var storage fstest.MapFS 59 | 60 | BeforeEach(func() { 61 | storage = fstest.MapFS{} 62 | }) 63 | 64 | It("loads the provider successfully", func() { 65 | buffer := &bytes.Buffer{} 66 | fmt.Fprintln(buffer, "-- name: get-categories") 67 | fmt.Fprintln(buffer, "SELECT * FROM categories;") 68 | 69 | storage["routine.sql"] = &fstest.MapFile{ 70 | Data: buffer.Bytes(), 71 | } 72 | 73 | Expect(provider.ReadDir(storage)).To(Succeed()) 74 | 75 | query, err := provider.Query("get-categories") 76 | Expect(err).NotTo(HaveOccurred()) 77 | Expect(query).To(Equal("SELECT * FROM categories;")) 78 | }) 79 | 80 | It("skips none sql files", func() { 81 | buffer := &bytes.Buffer{} 82 | fmt.Fprintln(buffer, "-- name: get-categories") 83 | fmt.Fprintln(buffer, "SELECT * FROM categories;") 84 | 85 | storage["routine.txt"] = &fstest.MapFile{ 86 | Data: buffer.Bytes(), 87 | } 88 | 89 | Expect(provider.ReadDir(storage)).To(Succeed()) 90 | 91 | query, err := provider.Query("get-categories") 92 | Expect(err).To(MatchError("query 'get-categories' not found")) 93 | Expect(query).To(BeEmpty()) 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /integration/migration_run_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | 12 | "github.com/onsi/gomega/gexec" 13 | 14 | . "github.com/onsi/ginkgo" 15 | . "github.com/onsi/gomega" 16 | ) 17 | 18 | var _ = Describe("Migration Run", func() { 19 | var ( 20 | cmd *exec.Cmd 21 | db *sql.DB 22 | ) 23 | 24 | JustBeforeEach(func() { 25 | dir, err := ioutil.TempDir("", "gom") 26 | Expect(err).To(BeNil()) 27 | 28 | args := []string{"--database-url", "sqlite3://gom.db"} 29 | 30 | Setup(args, dir) 31 | 32 | args = append(args, "migration") 33 | 34 | script := &bytes.Buffer{} 35 | fmt.Fprintln(script, "-- name: up") 36 | fmt.Fprintln(script, "SELECT * FROM migrations;") 37 | fmt.Fprintln(script, "-- name: down") 38 | fmt.Fprintln(script, "SELECT * FROM migrations;") 39 | 40 | path := filepath.Join(dir, "/database/migration/20060102150405_schema.sql") 41 | Expect(ioutil.WriteFile(path, script.Bytes(), 0700)).To(Succeed()) 42 | 43 | path = filepath.Join(dir, "/database/migration/20070102150405_trigger.sql") 44 | Expect(ioutil.WriteFile(path, script.Bytes(), 0700)).To(Succeed()) 45 | 46 | cmd = exec.Command(gomPath, append(args, "run")...) 47 | cmd.Dir = dir 48 | 49 | db, err = sql.Open("sqlite3", filepath.Join(dir, "gom.db")) 50 | Expect(err).NotTo(HaveOccurred()) 51 | }) 52 | 53 | AfterEach(func() { 54 | Expect(db.Close()).To(Succeed()) 55 | }) 56 | 57 | It("runs migration successfully", func() { 58 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 59 | Expect(err).NotTo(HaveOccurred()) 60 | Eventually(session).Should(gexec.Exit(0)) 61 | 62 | row := db.QueryRow("SELECT COUNT(*) FROM migrations") 63 | 64 | count := 0 65 | Expect(row.Scan(&count)).To(Succeed()) 66 | Expect(count).To(Equal(3)) 67 | }) 68 | 69 | Context("when the count argument is provided", func() { 70 | It("runs migration successfully", func() { 71 | cmd.Args = append(cmd.Args, "--count", "2") 72 | 73 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 74 | Expect(err).NotTo(HaveOccurred()) 75 | Eventually(session).Should(gexec.Exit(0)) 76 | 77 | row := db.QueryRow("SELECT COUNT(*) FROM migrations") 78 | 79 | count := 0 80 | Expect(row.Scan(&count)).To(Succeed()) 81 | Expect(count).To(Equal(3)) 82 | }) 83 | }) 84 | 85 | Context("when the database is not available", func() { 86 | It("returns an error", func() { 87 | Expect(os.Remove(filepath.Join(cmd.Dir, "gom.db"))).To(Succeed()) 88 | 89 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 90 | Expect(err).NotTo(HaveOccurred()) 91 | Eventually(session).Should(gexec.Exit(0)) 92 | }) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /sqlexec/splitter_test.go: -------------------------------------------------------------------------------- 1 | package sqlexec_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/phogolabs/prana/sqlexec" 10 | ) 11 | 12 | var _ = Describe("Splitter", func() { 13 | var ( 14 | splitter *sqlexec.Splitter 15 | query *bytes.Buffer 16 | ) 17 | 18 | BeforeEach(func() { 19 | query = &bytes.Buffer{} 20 | fmt.Fprintln(query, "SELECT * FROM users;") 21 | fmt.Fprintln(query, "GO") 22 | fmt.Fprintln(query, "SELECT * FROM documents;") 23 | 24 | splitter = &sqlexec.Splitter{} 25 | }) 26 | 27 | ItSplitsTheQuery := func() { 28 | It("splits query by the separator", func() { 29 | queries := splitter.Split(query) 30 | Expect(queries).To(HaveLen(2)) 31 | Expect(queries[0]).To(Equal("SELECT * FROM users;\n")) 32 | Expect(queries[1]).To(Equal("SELECT * FROM documents;\n")) 33 | }) 34 | } 35 | 36 | ItSplitsTheQuery() 37 | 38 | Context("when the separator is GO;", func() { 39 | BeforeEach(func() { 40 | query.Reset() 41 | fmt.Fprintln(query, "SELECT * FROM users;") 42 | fmt.Fprintln(query, "GO;") 43 | fmt.Fprintln(query, "SELECT * FROM documents;") 44 | }) 45 | 46 | ItSplitsTheQuery() 47 | }) 48 | 49 | Context("when the separator has space", func() { 50 | BeforeEach(func() { 51 | query.Reset() 52 | fmt.Fprintln(query, "SELECT * FROM users;") 53 | fmt.Fprintln(query, "GO; ") 54 | fmt.Fprintln(query, "SELECT * FROM documents;") 55 | }) 56 | 57 | ItSplitsTheQuery() 58 | }) 59 | 60 | Context("when the separator has comment", func() { 61 | BeforeEach(func() { 62 | query.Reset() 63 | fmt.Fprintln(query, "SELECT * FROM users;") 64 | fmt.Fprintln(query, "GO; -- split") 65 | fmt.Fprintln(query, "SELECT * FROM documents;") 66 | }) 67 | 68 | ItSplitsTheQuery() 69 | }) 70 | 71 | Context("when the separator is in the end", func() { 72 | BeforeEach(func() { 73 | query.Reset() 74 | fmt.Fprintln(query, "SELECT * FROM users;") 75 | fmt.Fprintln(query, "GO") 76 | }) 77 | 78 | It("remove the GO separator", func() { 79 | queries := splitter.Split(query) 80 | Expect(queries).To(HaveLen(1)) 81 | Expect(queries[0]).To(Equal("SELECT * FROM users;\n")) 82 | }) 83 | }) 84 | 85 | Context("when the separator is missing", func() { 86 | BeforeEach(func() { 87 | query.Reset() 88 | fmt.Fprintln(query, "SELECT * FROM users;") 89 | fmt.Fprintln(query, "SELECT * FROM documents;") 90 | }) 91 | 92 | It("does not split the query", func() { 93 | stmt := query.String() 94 | queries := splitter.Split(query) 95 | Expect(queries).To(HaveLen(1)) 96 | Expect(queries[0]).To(Equal(stmt)) 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /sqlmigr/runner.go: -------------------------------------------------------------------------------- 1 | package sqlmigr 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/jmoiron/sqlx" 8 | "github.com/phogolabs/log" 9 | "github.com/phogolabs/prana/sqlexec" 10 | ) 11 | 12 | var _ MigrationRunner = &Runner{} 13 | 14 | // Runner runs or reverts a given migration item. 15 | type Runner struct { 16 | // FileSystem represents the project directory file system. 17 | FileSystem FileSystem 18 | // DB is a client to underlying database. 19 | DB *sqlx.DB 20 | } 21 | 22 | // Run runs a given migration item. 23 | func (r *Runner) Run(m *Migration) error { 24 | return r.exec("up", m) 25 | } 26 | 27 | // Revert reverts a given migration item. 28 | func (r *Runner) Revert(m *Migration) error { 29 | return r.exec("down", m) 30 | } 31 | 32 | func (r *Runner) exec(step string, m *Migration) error { 33 | statements, err := r.routine(step, m) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | tx, err := r.DB.Begin() 39 | if err != nil { 40 | return err 41 | } 42 | 43 | for _, query := range statements { 44 | if _, err := tx.Exec(query); err != nil { 45 | if xerr := tx.Rollback(); xerr != nil { 46 | log.WithError(xerr).Error("rollback failure") 47 | } 48 | 49 | return &RunnerError{ 50 | Err: err, 51 | Statement: query, 52 | } 53 | } 54 | } 55 | 56 | return tx.Commit() 57 | } 58 | 59 | func (r *Runner) routine(name string, m *Migration) ([]string, error) { 60 | statements := make(map[string][]string, 2) 61 | filenames := m.Filenames() 62 | 63 | if name == "down" { 64 | reverse(filenames) 65 | } 66 | 67 | for _, file := range filenames { 68 | routines, err := r.scan(file) 69 | if err != nil { 70 | return []string{}, err 71 | } 72 | 73 | for key, value := range routines { 74 | statements[key] = append(statements[key], value) 75 | } 76 | } 77 | 78 | routine, ok := statements[name] 79 | if !ok { 80 | return []string{}, fmt.Errorf("routine '%s' not found for migration '%v'", name, m) 81 | } 82 | 83 | queries := []string{} 84 | splitter := &sqlexec.Splitter{} 85 | 86 | for _, body := range routine { 87 | stmt := splitter.Split(bytes.NewBufferString(body)) 88 | queries = append(queries, stmt...) 89 | } 90 | 91 | return queries, nil 92 | } 93 | 94 | func (r *Runner) scan(filename string) (map[string]string, error) { 95 | file, err := r.FileSystem.Open(filename) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | defer func() { 101 | if ioErr := file.Close(); err == nil { 102 | err = ioErr 103 | } 104 | }() 105 | 106 | scanner := &sqlexec.Scanner{} 107 | return scanner.Scan(file), nil 108 | } 109 | 110 | func reverse(s []string) { 111 | for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { 112 | s[i], s[j] = s[j], s[i] 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /integration/routine_create_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/onsi/gomega/gbytes" 10 | "github.com/onsi/gomega/gexec" 11 | 12 | . "github.com/onsi/ginkgo" 13 | . "github.com/onsi/gomega" 14 | ) 15 | 16 | var _ = Describe("Script Create", func() { 17 | var cmd *exec.Cmd 18 | 19 | JustBeforeEach(func() { 20 | dir, err := ioutil.TempDir("", "gom") 21 | Expect(err).To(BeNil()) 22 | 23 | cmd = exec.Command(gomPath, "routine", "create") 24 | cmd.Dir = dir 25 | 26 | Expect(os.MkdirAll(filepath.Join(dir, "database", "routine"), 0700)).To(Succeed()) 27 | }) 28 | 29 | It("generates command successfully", func() { 30 | cmd.Args = append(cmd.Args, "-n", "commands", "update-user") 31 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 32 | Expect(err).NotTo(HaveOccurred()) 33 | Eventually(session).Should(gexec.Exit(0)) 34 | 35 | path := filepath.Join(cmd.Dir, "/database/routine/commands.sql") 36 | Expect(path).To(BeARegularFile()) 37 | 38 | data, err := ioutil.ReadFile(path) 39 | Expect(err).To(BeNil()) 40 | 41 | script := string(data) 42 | Expect(script).To(ContainSubstring("-- name: update-user")) 43 | }) 44 | 45 | Context("when the command name has space in it", func() { 46 | It("generates command successfully", func() { 47 | cmd.Args = append(cmd.Args, "-n", "commands", "update user") 48 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 49 | Expect(err).NotTo(HaveOccurred()) 50 | Eventually(session).Should(gexec.Exit(0)) 51 | 52 | path := filepath.Join(cmd.Dir, "/database/routine/commands.sql") 53 | Expect(path).To(BeARegularFile()) 54 | 55 | data, err := ioutil.ReadFile(path) 56 | Expect(err).To(BeNil()) 57 | 58 | script := string(data) 59 | Expect(script).To(ContainSubstring("-- name: update-user")) 60 | }) 61 | }) 62 | 63 | Context("when the container name has space in it", func() { 64 | It("generates command successfully", func() { 65 | cmd.Args = append(cmd.Args, "-n", "my commands", "update-user") 66 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 67 | Expect(err).NotTo(HaveOccurred()) 68 | Eventually(session).Should(gexec.Exit(0)) 69 | 70 | path := filepath.Join(cmd.Dir, "/database/routine/my_commands.sql") 71 | Expect(path).To(BeARegularFile()) 72 | 73 | data, err := ioutil.ReadFile(path) 74 | Expect(err).To(BeNil()) 75 | 76 | script := string(data) 77 | Expect(script).To(ContainSubstring("-- name: update-user")) 78 | }) 79 | }) 80 | 81 | Context("when the name is not provided", func() { 82 | It("returns an error", func() { 83 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 84 | Expect(err).NotTo(HaveOccurred()) 85 | Eventually(session).Should(gexec.Exit(104)) 86 | Expect(session.Err).To(gbytes.Say("Create command expects a single argument")) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /sqlexec/runner_test.go: -------------------------------------------------------------------------------- 1 | package sqlexec_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | "testing/fstest" 9 | 10 | "github.com/jmoiron/sqlx" 11 | "github.com/phogolabs/prana/sqlexec" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("Runner", func() { 18 | var ( 19 | runner *sqlexec.Runner 20 | storage fstest.MapFS 21 | ) 22 | 23 | BeforeEach(func() { 24 | storage = fstest.MapFS{} 25 | 26 | dir, err := ioutil.TempDir("", "prana_runner") 27 | Expect(err).To(BeNil()) 28 | 29 | db := filepath.Join(dir, "prana.db") 30 | gateway, err := sqlx.Open("sqlite3", db) 31 | Expect(err).To(BeNil()) 32 | 33 | runner = &sqlexec.Runner{ 34 | FileSystem: storage, 35 | DB: gateway, 36 | } 37 | }) 38 | 39 | JustBeforeEach(func() { 40 | command := &bytes.Buffer{} 41 | fmt.Fprintln(command, "-- name: system-tables") 42 | fmt.Fprintln(command, "SELECT * FROM sqlite_master") 43 | 44 | storage["commands.sql"] = &fstest.MapFile{ 45 | Data: command.Bytes(), 46 | } 47 | }) 48 | 49 | AfterEach(func() { 50 | runner.DB.Close() 51 | }) 52 | 53 | It("runs the command successfully", func() { 54 | rows, err := runner.Run("system-tables") 55 | Expect(err).To(Succeed()) 56 | 57 | columns, err := rows.Columns() 58 | Expect(err).To(Succeed()) 59 | Expect(columns).To(ContainElement("type")) 60 | Expect(columns).To(ContainElement("name")) 61 | Expect(columns).To(ContainElement("tbl_name")) 62 | Expect(columns).To(ContainElement("rootpage")) 63 | Expect(columns).To(ContainElement("sql")) 64 | }) 65 | 66 | Context("when the command requires parameters", func() { 67 | JustBeforeEach(func() { 68 | command := &bytes.Buffer{} 69 | fmt.Fprintln(command, "-- name: system-tables") 70 | fmt.Fprintln(command, "SELECT ? AS Param FROM sqlite_master") 71 | 72 | storage["commands.sql"] = &fstest.MapFile{ 73 | Data: command.Bytes(), 74 | } 75 | }) 76 | 77 | It("runs the command successfully", func() { 78 | rows, err := runner.Run("system-tables", "hello") 79 | Expect(err).To(Succeed()) 80 | 81 | columns, err := rows.Columns() 82 | Expect(err).To(Succeed()) 83 | Expect(columns).To(ContainElement("Param")) 84 | }) 85 | }) 86 | 87 | Context("when the command does not exist", func() { 88 | JustBeforeEach(func() { 89 | delete(storage, "commands.sql") 90 | }) 91 | 92 | It("returns an error", func() { 93 | _, err := runner.Run("system-tables") 94 | Expect(err).To(MatchError("query 'system-tables' not found")) 95 | }) 96 | }) 97 | 98 | Context("when the database is not available", func() { 99 | JustBeforeEach(func() { 100 | Expect(runner.DB.Close()).To(Succeed()) 101 | }) 102 | 103 | It("return an error", func() { 104 | _, err := runner.Run("system-tables") 105 | Expect(err).To(MatchError("sql: database is closed")) 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /sqlmodel/template/repository.mustache: -------------------------------------------------------------------------------- 1 | {{#if Schema.Model.HasDocumentation}} 2 | // Package {{Meta.RepositoryPackage}} contains an repository of database schema '{{Name}}' 3 | // Auto-generated at {{now}} 4 | {{/if}} 5 | package {{Meta.RepositoryPackage}} 6 | 7 | {{#Schema}} 8 | {{#tables}} 9 | 10 | {{#if Model.HasDocumentation}} 11 | // {{Model.Type}}Repository represents a repository for '{{Name}}' 12 | {{/if}} 13 | type {{Model.Type}}Repository struct { 14 | // Gateway connects the repository to the underlying database 15 | Gateway *orm.Gateway 16 | } 17 | 18 | {{#if Model.HasDocumentation}} 19 | // SelectAll returns all {{Model.Type}} from the database 20 | {{/if}} 21 | func (r *{{Model.Type}}Repository) SelectAll(ctx context.Context) ([]*model.{{Model.Type}}, error) { 22 | records := []*model.{{Model.Type}}{} 23 | routine := orm.Routine("{{Model.SelectAllRoutine}}") 24 | 25 | if err := r.Gateway.All(ctx, &records, routine); err != nil { 26 | return nil, err 27 | } 28 | 29 | return records, nil 30 | } 31 | 32 | {{#if Model.HasDocumentation}} 33 | // SelectByPK returns a record of {{Model.Type}} for given primary key 34 | {{/if}} 35 | func (r *{{Model.Type}}Repository) SelectByPK(ctx context.Context, {{Model.PrimaryKeyArgs}}) (*model.{{Model.Type}}, error) { 36 | param := orm.Map{ 37 | {{#each Model.PrimaryKey}} 38 | "{{@key}}": {{this}}, 39 | {{/each}} 40 | } 41 | 42 | routine := orm.Routine("{{Model.SelectByPKRoutine}}", param) 43 | record := &model.{{Model.Type}}{} 44 | 45 | if err := r.Gateway.Only(ctx, record, routine); err != nil { 46 | return nil, err 47 | } 48 | 49 | return record, nil 50 | } 51 | 52 | {{#if Model.HasDocumentation}} 53 | // Insert inserts a record of type {{model.type}} into the database 54 | {{/if}} 55 | func (r *{{Model.Type}}Repository) Insert(ctx context.Context, row *model.{{Model.Type}}) error { 56 | routine := orm.Routine("{{Model.InsertRoutine}}", row) 57 | {{#if (equal Driver "postgresql")}} 58 | err := r.Gateway.Only(ctx, row, routine) 59 | {{else}} 60 | _, err := r.Gateway.Exec(ctx, routine) 61 | {{/if}} 62 | return err 63 | } 64 | 65 | {{#if Model.HasDocumentation}} 66 | // UpdateByPKContext updates a record of type {{model.type}} for given primary key 67 | {{/if}} 68 | func (r *{{Model.Type}}Repository) UpdateByPK(ctx context.Context, row *model.{{Model.Type}}) error { 69 | routine := orm.Routine("{{Model.UpdateByPKRoutine}}", row) 70 | {{#if (equal Driver "postgresql")}} 71 | err := r.Gateway.Only(ctx, row, routine) 72 | {{else}} 73 | _, err := r.Gateway.Exec(ctx, routine) 74 | {{/if}} 75 | return err 76 | } 77 | 78 | {{#if Model.HasDocumentation}} 79 | // DeleteByPK deletes a record of {{Model.Type}} for given primary key 80 | {{/if}} 81 | func (r *{{Model.Type}}Repository) DeleteByPK(ctx context.Context, {{Model.PrimaryKeyArgs}}) error { 82 | param := orm.Map{ 83 | {{#each Model.PrimaryKey}} 84 | "{{@key}}": {{this}}, 85 | {{/each}} 86 | } 87 | 88 | routine := orm.Routine("{{Model.DeleteByPKRoutine}}", param) 89 | _, err := r.Gateway.Exec(ctx, routine) 90 | return err 91 | } 92 | {{/tables}} 93 | {{/Schema}} 94 | -------------------------------------------------------------------------------- /sqlmodel/executor.go: -------------------------------------------------------------------------------- 1 | package sqlmodel 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | ) 12 | 13 | // Executor executes the schema generation 14 | type Executor struct { 15 | // Generator is the generator 16 | Generator Generator 17 | // Provider provides information the database schema 18 | Provider SchemaProvider 19 | } 20 | 21 | // Write writes the generated schema sqlmodels to a writer 22 | func (e *Executor) Write(w io.Writer, spec *Spec) error { 23 | _, err := e.write(w, spec) 24 | return err 25 | } 26 | 27 | // Create creates a package with the generated schema sqlmodels 28 | func (e *Executor) Create(spec *Spec) (string, error) { 29 | reader := &bytes.Buffer{} 30 | 31 | schema, err := e.write(reader, spec) 32 | if err != nil { 33 | return "", err 34 | } 35 | 36 | body, _ := ioutil.ReadAll(reader) 37 | if len(body) == 0 { 38 | return "", nil 39 | } 40 | 41 | filepath := e.fileOf(e.nameOf(schema), spec.Filename) 42 | 43 | file, err := spec.FileSystem.OpenFile(filepath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | defer func() { 49 | if ioErr := file.Close(); err == nil { 50 | err = ioErr 51 | } 52 | }() 53 | 54 | if writer, ok := file.(io.Writer); ok { 55 | if _, err = writer.Write(body); err != nil { 56 | return "", err 57 | } 58 | } 59 | 60 | return filepath, nil 61 | } 62 | 63 | func (e *Executor) write(writer io.Writer, spec *Spec) (*Schema, error) { 64 | schema, err := e.schemaOf(spec) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | ctx := &GeneratorContext{ 70 | Writer: writer, 71 | Template: spec.Template, 72 | Schema: schema, 73 | } 74 | 75 | if err = e.Generator.Generate(ctx); err != nil { 76 | return nil, err 77 | } 78 | 79 | return schema, nil 80 | } 81 | 82 | func (e *Executor) schemaOf(spec *Spec) (*Schema, error) { 83 | if len(spec.Tables) == 0 { 84 | tables, err := e.Provider.Tables(spec.Schema) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | spec.Tables = tables 90 | } 91 | 92 | spec.Tables = filter(spec.IgnoreTables, spec.Tables) 93 | 94 | schema, err := e.Provider.Schema(spec.Schema, spec.Tables...) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | return schema, nil 100 | } 101 | 102 | func (e *Executor) fileOf(schema, filename string) string { 103 | if schema != "" { 104 | filename = fmt.Sprintf("%s%s", schema, filepath.Ext(filename)) 105 | } 106 | 107 | return filename 108 | } 109 | 110 | func (e *Executor) nameOf(schema *Schema) string { 111 | if !schema.IsDefault { 112 | return schema.Name 113 | } 114 | return "" 115 | } 116 | 117 | func filter(ignore, tables []string) []string { 118 | var result []string 119 | 120 | if !sort.StringsAreSorted(ignore) { 121 | sort.Strings(ignore) 122 | } 123 | 124 | for _, table := range tables { 125 | if contains(ignore, table) { 126 | continue 127 | } 128 | 129 | result = append(result, table) 130 | } 131 | 132 | return result 133 | } 134 | -------------------------------------------------------------------------------- /fake/tag_builder.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fake 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/phogolabs/prana/sqlmodel" 8 | ) 9 | 10 | type TagBuilder struct { 11 | BuildStub func(*sqlmodel.Column) string 12 | buildMutex sync.RWMutex 13 | buildArgsForCall []struct { 14 | arg1 *sqlmodel.Column 15 | } 16 | buildReturns struct { 17 | result1 string 18 | } 19 | buildReturnsOnCall map[int]struct { 20 | result1 string 21 | } 22 | invocations map[string][][]interface{} 23 | invocationsMutex sync.RWMutex 24 | } 25 | 26 | func (fake *TagBuilder) Build(arg1 *sqlmodel.Column) string { 27 | fake.buildMutex.Lock() 28 | ret, specificReturn := fake.buildReturnsOnCall[len(fake.buildArgsForCall)] 29 | fake.buildArgsForCall = append(fake.buildArgsForCall, struct { 30 | arg1 *sqlmodel.Column 31 | }{arg1}) 32 | fake.recordInvocation("Build", []interface{}{arg1}) 33 | fake.buildMutex.Unlock() 34 | if fake.BuildStub != nil { 35 | return fake.BuildStub(arg1) 36 | } 37 | if specificReturn { 38 | return ret.result1 39 | } 40 | fakeReturns := fake.buildReturns 41 | return fakeReturns.result1 42 | } 43 | 44 | func (fake *TagBuilder) BuildCallCount() int { 45 | fake.buildMutex.RLock() 46 | defer fake.buildMutex.RUnlock() 47 | return len(fake.buildArgsForCall) 48 | } 49 | 50 | func (fake *TagBuilder) BuildCalls(stub func(*sqlmodel.Column) string) { 51 | fake.buildMutex.Lock() 52 | defer fake.buildMutex.Unlock() 53 | fake.BuildStub = stub 54 | } 55 | 56 | func (fake *TagBuilder) BuildArgsForCall(i int) *sqlmodel.Column { 57 | fake.buildMutex.RLock() 58 | defer fake.buildMutex.RUnlock() 59 | argsForCall := fake.buildArgsForCall[i] 60 | return argsForCall.arg1 61 | } 62 | 63 | func (fake *TagBuilder) BuildReturns(result1 string) { 64 | fake.buildMutex.Lock() 65 | defer fake.buildMutex.Unlock() 66 | fake.BuildStub = nil 67 | fake.buildReturns = struct { 68 | result1 string 69 | }{result1} 70 | } 71 | 72 | func (fake *TagBuilder) BuildReturnsOnCall(i int, result1 string) { 73 | fake.buildMutex.Lock() 74 | defer fake.buildMutex.Unlock() 75 | fake.BuildStub = nil 76 | if fake.buildReturnsOnCall == nil { 77 | fake.buildReturnsOnCall = make(map[int]struct { 78 | result1 string 79 | }) 80 | } 81 | fake.buildReturnsOnCall[i] = struct { 82 | result1 string 83 | }{result1} 84 | } 85 | 86 | func (fake *TagBuilder) Invocations() map[string][][]interface{} { 87 | fake.invocationsMutex.RLock() 88 | defer fake.invocationsMutex.RUnlock() 89 | fake.buildMutex.RLock() 90 | defer fake.buildMutex.RUnlock() 91 | copiedInvocations := map[string][][]interface{}{} 92 | for key, value := range fake.invocations { 93 | copiedInvocations[key] = value 94 | } 95 | return copiedInvocations 96 | } 97 | 98 | func (fake *TagBuilder) recordInvocation(key string, args []interface{}) { 99 | fake.invocationsMutex.Lock() 100 | defer fake.invocationsMutex.Unlock() 101 | if fake.invocations == nil { 102 | fake.invocations = map[string][][]interface{}{} 103 | } 104 | if fake.invocations[key] == nil { 105 | fake.invocations[key] = [][]interface{}{} 106 | } 107 | fake.invocations[key] = append(fake.invocations[key], args) 108 | } 109 | 110 | var _ sqlmodel.TagBuilder = new(TagBuilder) 111 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@phogolabs.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /sqlexec/generator_test.go: -------------------------------------------------------------------------------- 1 | package sqlexec_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/phogolabs/prana/sqlexec" 12 | "github.com/phogolabs/prana/storage" 13 | 14 | . "github.com/onsi/ginkgo" 15 | . "github.com/onsi/gomega" 16 | ) 17 | 18 | var _ = Describe("Generator", func() { 19 | var ( 20 | generator *sqlexec.Generator 21 | dir string 22 | ) 23 | 24 | BeforeEach(func() { 25 | var err error 26 | dir, err = ioutil.TempDir("", "prana_generator") 27 | Expect(err).To(BeNil()) 28 | 29 | generator = &sqlexec.Generator{ 30 | FileSystem: storage.New(dir), 31 | } 32 | }) 33 | 34 | Describe("Create", func() { 35 | It("creates a command file successfully", func() { 36 | name, path, err := generator.Create("commands", "update") 37 | Expect(err).To(BeNil()) 38 | 39 | path = filepath.Join(dir, path) 40 | Expect(path).To(BeARegularFile()) 41 | Expect(name).To(Equal("update")) 42 | 43 | data, err := ioutil.ReadFile(path) 44 | Expect(err).To(BeNil()) 45 | 46 | sqlexec := string(data) 47 | Expect(sqlexec).To(ContainSubstring("-- name: update")) 48 | }) 49 | 50 | Context("when the file is not provided", func() { 51 | It("creates a command file successfully", func() { 52 | name, path, err := generator.Create("", "update") 53 | Expect(err).To(BeNil()) 54 | 55 | path = filepath.Join(dir, path) 56 | Expect(path).To(BeARegularFile()) 57 | Expect(name).To(Equal("update")) 58 | 59 | filename := filepath.Base(path) 60 | ext := filepath.Ext(path) 61 | filename = strings.Replace(filename, ext, "", -1) 62 | filename = strings.Replace(filename, "-routine", "", -1) 63 | 64 | _, err = time.Parse("20060102150405", filename) 65 | Expect(err).To(Succeed()) 66 | 67 | data, err := ioutil.ReadFile(path) 68 | Expect(err).To(BeNil()) 69 | 70 | sqlexec := string(data) 71 | Expect(sqlexec).To(ContainSubstring("-- name: update")) 72 | }) 73 | }) 74 | 75 | Context("when the file already exists", func() { 76 | It("adds the command to the file successfully", func() { 77 | name, path, err := generator.Create("commands", "update") 78 | Expect(err).To(BeNil()) 79 | Expect(name).To(Equal("update")) 80 | 81 | path = filepath.Join(dir, path) 82 | Expect(path).To(BeARegularFile()) 83 | 84 | name, path, err = generator.Create("commands", "delete") 85 | Expect(err).To(BeNil()) 86 | Expect(name).To(Equal("delete")) 87 | 88 | path = filepath.Join(dir, path) 89 | Expect(path).To(BeARegularFile()) 90 | 91 | data, err := ioutil.ReadFile(path) 92 | Expect(err).To(BeNil()) 93 | 94 | sqlexec := string(data) 95 | Expect(sqlexec).To(ContainSubstring("-- name: update")) 96 | Expect(sqlexec).To(ContainSubstring("-- name: delete")) 97 | }) 98 | }) 99 | 100 | Context("when the command already exists", func() { 101 | It("returns an error", func() { 102 | buffer := &bytes.Buffer{} 103 | fmt.Fprintln(buffer, "-- name: update") 104 | fmt.Fprintln(buffer, "SELECT * FROM migrations;") 105 | 106 | path := filepath.Join(dir, "commands.sql") 107 | Expect(ioutil.WriteFile(path, buffer.Bytes(), 0700)).To(Succeed()) 108 | 109 | _, _, err := generator.Create("commands", "update") 110 | Expect(err).To(MatchError("Query 'update' already exists")) 111 | }) 112 | }) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /fake/model_generator.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fake 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/phogolabs/prana/sqlmodel" 8 | ) 9 | 10 | type ModelGenerator struct { 11 | GenerateStub func(*sqlmodel.GeneratorContext) error 12 | generateMutex sync.RWMutex 13 | generateArgsForCall []struct { 14 | arg1 *sqlmodel.GeneratorContext 15 | } 16 | generateReturns struct { 17 | result1 error 18 | } 19 | generateReturnsOnCall map[int]struct { 20 | result1 error 21 | } 22 | invocations map[string][][]interface{} 23 | invocationsMutex sync.RWMutex 24 | } 25 | 26 | func (fake *ModelGenerator) Generate(arg1 *sqlmodel.GeneratorContext) error { 27 | fake.generateMutex.Lock() 28 | ret, specificReturn := fake.generateReturnsOnCall[len(fake.generateArgsForCall)] 29 | fake.generateArgsForCall = append(fake.generateArgsForCall, struct { 30 | arg1 *sqlmodel.GeneratorContext 31 | }{arg1}) 32 | fake.recordInvocation("Generate", []interface{}{arg1}) 33 | fake.generateMutex.Unlock() 34 | if fake.GenerateStub != nil { 35 | return fake.GenerateStub(arg1) 36 | } 37 | if specificReturn { 38 | return ret.result1 39 | } 40 | fakeReturns := fake.generateReturns 41 | return fakeReturns.result1 42 | } 43 | 44 | func (fake *ModelGenerator) GenerateCallCount() int { 45 | fake.generateMutex.RLock() 46 | defer fake.generateMutex.RUnlock() 47 | return len(fake.generateArgsForCall) 48 | } 49 | 50 | func (fake *ModelGenerator) GenerateCalls(stub func(*sqlmodel.GeneratorContext) error) { 51 | fake.generateMutex.Lock() 52 | defer fake.generateMutex.Unlock() 53 | fake.GenerateStub = stub 54 | } 55 | 56 | func (fake *ModelGenerator) GenerateArgsForCall(i int) *sqlmodel.GeneratorContext { 57 | fake.generateMutex.RLock() 58 | defer fake.generateMutex.RUnlock() 59 | argsForCall := fake.generateArgsForCall[i] 60 | return argsForCall.arg1 61 | } 62 | 63 | func (fake *ModelGenerator) GenerateReturns(result1 error) { 64 | fake.generateMutex.Lock() 65 | defer fake.generateMutex.Unlock() 66 | fake.GenerateStub = nil 67 | fake.generateReturns = struct { 68 | result1 error 69 | }{result1} 70 | } 71 | 72 | func (fake *ModelGenerator) GenerateReturnsOnCall(i int, result1 error) { 73 | fake.generateMutex.Lock() 74 | defer fake.generateMutex.Unlock() 75 | fake.GenerateStub = nil 76 | if fake.generateReturnsOnCall == nil { 77 | fake.generateReturnsOnCall = make(map[int]struct { 78 | result1 error 79 | }) 80 | } 81 | fake.generateReturnsOnCall[i] = struct { 82 | result1 error 83 | }{result1} 84 | } 85 | 86 | func (fake *ModelGenerator) Invocations() map[string][][]interface{} { 87 | fake.invocationsMutex.RLock() 88 | defer fake.invocationsMutex.RUnlock() 89 | fake.generateMutex.RLock() 90 | defer fake.generateMutex.RUnlock() 91 | copiedInvocations := map[string][][]interface{}{} 92 | for key, value := range fake.invocations { 93 | copiedInvocations[key] = value 94 | } 95 | return copiedInvocations 96 | } 97 | 98 | func (fake *ModelGenerator) recordInvocation(key string, args []interface{}) { 99 | fake.invocationsMutex.Lock() 100 | defer fake.invocationsMutex.Unlock() 101 | if fake.invocations == nil { 102 | fake.invocations = map[string][][]interface{}{} 103 | } 104 | if fake.invocations[key] == nil { 105 | fake.invocations[key] = [][]interface{}{} 106 | } 107 | fake.invocations[key] = append(fake.invocations[key], args) 108 | } 109 | 110 | var _ sqlmodel.Generator = new(ModelGenerator) 111 | -------------------------------------------------------------------------------- /sqlexec/provider.go: -------------------------------------------------------------------------------- 1 | package sqlexec 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/jmoiron/sqlx" 13 | ) 14 | 15 | const every = "sql" 16 | 17 | // Provider loads SQL sqlexecs and provides all SQL statements as commands. 18 | type Provider struct { 19 | dialect string 20 | mu sync.RWMutex 21 | repository map[string]string 22 | } 23 | 24 | // Dialect returns the dialect 25 | func (p *Provider) Dialect() string { 26 | return p.dialect 27 | } 28 | 29 | // SetDialect sets the dialect 30 | func (p *Provider) SetDialect(value string) { 31 | p.dialect = value 32 | } 33 | 34 | // ReadDir loads all sqlexec commands from a given directory. Note that all 35 | // sqlexecs should have .sql extension. 36 | func (p *Provider) ReadDir(storage FileSystem) error { 37 | return fs.WalkDir(storage, ".", func(path string, info fs.DirEntry, err error) error { 38 | if info == nil { 39 | return os.ErrNotExist 40 | } 41 | 42 | if info.IsDir() { 43 | return nil 44 | } 45 | 46 | return p.ReadFile(path, storage) 47 | }) 48 | } 49 | 50 | // ReadFile reads a given file 51 | func (p *Provider) ReadFile(path string, storage FileSystem) error { 52 | if !p.filter(path) { 53 | return nil 54 | } 55 | 56 | file, err := storage.Open(path) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | defer func() { 62 | if ioErr := file.Close(); err == nil { 63 | err = ioErr 64 | } 65 | }() 66 | 67 | if _, err = p.ReadFrom(file); err != nil { 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // ReadFrom reads the sqlexec from a reader 75 | func (p *Provider) ReadFrom(r io.Reader) (int64, error) { 76 | p.mu.Lock() 77 | defer p.mu.Unlock() 78 | 79 | if p.repository == nil { 80 | p.repository = make(map[string]string) 81 | } 82 | 83 | scanner := &Scanner{} 84 | stmts := scanner.Scan(r) 85 | 86 | for name, stmt := range stmts { 87 | if _, ok := p.repository[name]; ok { 88 | return 0, fmt.Errorf("query '%s' already exists", name) 89 | } 90 | 91 | p.repository[name] = stmt 92 | } 93 | 94 | return int64(len(stmts)), nil 95 | } 96 | 97 | // Query returns a query statement for given name and parameters. The operation can 98 | // err if the command cannot be found. 99 | func (p *Provider) Query(name string) (string, error) { 100 | p.mu.RLock() 101 | defer p.mu.RUnlock() 102 | 103 | if query, ok := p.repository[name]; ok { 104 | return sqlx.Rebind(sqlx.BindType(p.dialect), query), nil 105 | } 106 | 107 | return "", nonExistQueryErr(name) 108 | } 109 | 110 | // Filter returns true if the file can be processed for the current driver 111 | func (p *Provider) filter(path string) bool { 112 | ext := filepath.Ext(path) 113 | 114 | if ext != ".sql" { 115 | return false 116 | } 117 | 118 | driver := PathDriver(path) 119 | return driver == every || driver == p.dialect 120 | } 121 | 122 | // PathDriver returns the driver name from a given path 123 | func PathDriver(path string) string { 124 | ext := filepath.Ext(path) 125 | _, path = filepath.Split(path) 126 | path = strings.Replace(path, ext, "", -1) 127 | parts := strings.Split(path, "_") 128 | driver := strings.ToLower(parts[len(parts)-1]) 129 | 130 | switch driver { 131 | case "sqlite3", "postgres", "mysql": 132 | return driver 133 | default: 134 | return every 135 | } 136 | } 137 | 138 | func nonExistQueryErr(name string) error { 139 | return fmt.Errorf("query '%s' not found", name) 140 | } 141 | -------------------------------------------------------------------------------- /integration/migration_revert_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | 12 | "github.com/onsi/gomega/gbytes" 13 | "github.com/onsi/gomega/gexec" 14 | 15 | . "github.com/onsi/ginkgo" 16 | . "github.com/onsi/gomega" 17 | ) 18 | 19 | var _ = Describe("Migration Revert", func() { 20 | var ( 21 | cmd *exec.Cmd 22 | db *sql.DB 23 | ) 24 | 25 | JustBeforeEach(func() { 26 | dir, err := ioutil.TempDir("", "gom") 27 | Expect(err).To(BeNil()) 28 | 29 | args := []string{"--database-url", "sqlite3://gom.db"} 30 | 31 | Setup(args, dir) 32 | 33 | args = append(args, "migration") 34 | 35 | script := &bytes.Buffer{} 36 | fmt.Fprintln(script, "-- name: up") 37 | fmt.Fprintln(script, "SELECT * FROM migrations;") 38 | fmt.Fprintln(script, "-- name: down") 39 | fmt.Fprintln(script, "SELECT * FROM migrations;") 40 | 41 | path := filepath.Join(dir, "/database/migration/20060102150405_schema.sql") 42 | Expect(ioutil.WriteFile(path, script.Bytes(), 0700)).To(Succeed()) 43 | 44 | path = filepath.Join(dir, "/database/migration/20070102150405_trigger.sql") 45 | Expect(ioutil.WriteFile(path, script.Bytes(), 0700)).To(Succeed()) 46 | 47 | cmd = exec.Command(gomPath, append(args, "run", "--count", "2")...) 48 | cmd.Dir = dir 49 | 50 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 51 | Expect(err).NotTo(HaveOccurred()) 52 | Eventually(session).Should(gexec.Exit(0)) 53 | 54 | cmd = exec.Command(gomPath, append(args, "revert")...) 55 | cmd.Dir = dir 56 | 57 | db, err = sql.Open("sqlite3", filepath.Join(dir, "gom.db")) 58 | Expect(err).NotTo(HaveOccurred()) 59 | }) 60 | 61 | AfterEach(func() { 62 | Expect(db.Close()).To(Succeed()) 63 | }) 64 | 65 | It("reverts migration successfully", func() { 66 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 67 | Expect(err).NotTo(HaveOccurred()) 68 | Eventually(session).Should(gexec.Exit(0)) 69 | 70 | row := db.QueryRow("SELECT COUNT(*) FROM migrations") 71 | 72 | count := 0 73 | Expect(row.Scan(&count)).To(MatchError("no such table: migrations")) 74 | }) 75 | 76 | Context("when the count argument is provided", func() { 77 | It("runs migration successfully", func() { 78 | cmd.Args = append(cmd.Args, "--count", "2") 79 | 80 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 81 | Expect(err).NotTo(HaveOccurred()) 82 | Eventually(session).Should(gexec.Exit(0)) 83 | 84 | row := db.QueryRow("SELECT COUNT(*) FROM migrations") 85 | 86 | count := 0 87 | Expect(row.Scan(&count)).To(Succeed()) 88 | Expect(count).To(Equal(1)) 89 | }) 90 | }) 91 | 92 | Context("when the database is not available", func() { 93 | It("returns an error", func() { 94 | Expect(os.Remove(filepath.Join(cmd.Dir, "gom.db"))).To(Succeed()) 95 | 96 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 97 | Expect(err).NotTo(HaveOccurred()) 98 | Eventually(session).Should(gexec.Exit(0)) 99 | }) 100 | }) 101 | 102 | Context("when the count argument is wrong", func() { 103 | It("returns an error", func() { 104 | cmd.Args = append(cmd.Args, "--count", "wrong") 105 | 106 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 107 | Expect(err).NotTo(HaveOccurred()) 108 | Eventually(session).Should(gexec.Exit(-1)) 109 | Expect(session.Out).To(gbytes.Say(`invalid value "wrong" for flag -count: strconv.ParseInt: parsing "wrong": invalid syntax`)) 110 | }) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /sqlmodel/template/repository_test.mustache: -------------------------------------------------------------------------------- 1 | {{#if Schema.Model.HasDocumentation}} 2 | // Code generated by prana; DO NOT EDIT. 3 | 4 | // Package {{Meta.RepositoryPackage}}_tests contains a tests for database repository 5 | // Auto-generated at {{now}} 6 | 7 | {{/if}} 8 | package {{Meta.RepositoryPackage}}_test 9 | 10 | import ( 11 | "context" 12 | 13 | "github.com/phogolabs/orm" 14 | "github.com/phogolabs/schema" 15 | 16 | . "github.com/onsi/ginkgo" 17 | . "github.com/onsi/gomega" 18 | ) 19 | {{#Schema}} 20 | {{#tables}} 21 | 22 | var _ = Describe("{{Model.Type}}Repository", func() { 23 | var ( 24 | repository *database.{{Model.Type}}Repository 25 | entity *{{Model.Package}}.{{Model.Type}} 26 | ) 27 | 28 | BeforeEach(func() { 29 | repository = &database.{{Model.Type}}Repository{ 30 | Gateway: gateway, 31 | } 32 | 33 | entity = &{{Model.Package}}.{{Model.Type}}{ 34 | //TODO: set the required fields 35 | } 36 | }) 37 | 38 | AfterEach(func() { 39 | _, err := gateway.Exec(orm.Query("DELETE FROM {{Name}}")) 40 | Expect(err).NotTo(HaveOccurred()) 41 | }) 42 | 43 | Describe("SelectAll", func() { 44 | It("returns no records", func() { 45 | records, err := repository.SelectAll(ctx) 46 | Expect(err).NotTo(HaveOccurred()) 47 | Expect(records).To(BeEmpty()) 48 | }) 49 | 50 | Context("when there is a record", func() { 51 | BeforeEach(func() { 52 | Expect(repository.Insert(ctx, entity)).To(Succeed()) 53 | }) 54 | 55 | It("returns all records", func() { 56 | records, err := repository.SelectAll(ctx) 57 | Expect(err).NotTo(HaveOccurred()) 58 | Expect(records).To(HaveLen(1)) 59 | Expect(records[0]).To(Equal(entity)) 60 | }) 61 | }) 62 | }) 63 | 64 | Describe("SelectByPK", func() { 65 | It("return a record by primary key", func() { 66 | Expect(repository.Insert(entity)).To(Succeed()) 67 | 68 | record, err := repository.SelectByPK(ctx, {{Model.PrimaryKeyEntityParams}}) 69 | Expect(err).NotTo(HaveOccurred()) 70 | Expect(record).To(Equal(entity)) 71 | }) 72 | 73 | Context("when the record does not exist", func() { 74 | It("returns an error", func() { 75 | record, err := repository.SelectByPK(ctx, {{Model.PrimaryKeyEntityParams}}) 76 | Expect(err).To(HaveOccurred()) 77 | Expect(record).To(BeNil()) 78 | }) 79 | }) 80 | }) 81 | 82 | Describe("Insert", func() { 83 | It("inserts a new member successfully", func() { 84 | Expect(repository.Insert(ctx, entity)).To(Succeed()) 85 | 86 | record, err := repository.SelectByPK(ctx, {{Model.PrimaryKeyEntityParams}}) 87 | Expect(err).NotTo(HaveOccurred()) 88 | Expect(record).To(Equal(entity)) 89 | }) 90 | }) 91 | 92 | Describe("UpdateByPK", func() { 93 | BeforeEach(func() { 94 | Expect(repository.Insert(ctx, entity)).To(Succeed()) 95 | }) 96 | 97 | It("updates a record by primary key", func() { 98 | Expect(repository.UpdateByPK(ctx, entity)).To(Succeed()) 99 | 100 | record, err := repository.SelectByPK(ctx, {{Model.PrimaryKeyEntityParams}}) 101 | Expect(err).NotTo(HaveOccurred()) 102 | Expect(record).To(Equal(entity)) 103 | }) 104 | }) 105 | 106 | Describe("DeleteByPK", func() { 107 | BeforeEach(func() { 108 | Expect(repository.Insert(ctx, entity)).To(Succeed()) 109 | }) 110 | 111 | It("deletes a record by primary key", func() { 112 | Expect(repository.DeleteByPK(ctx, {{Model.PrimaryKeyEntityParams}})).To(Succeed()) 113 | 114 | record, err := repository.SelectByPK(ctx, {{Model.PrimaryKeyEntityParams}}) 115 | Expect(err).To(HaveOccurred()) 116 | Expect(record).To(BeNil()) 117 | }) 118 | }) 119 | }) 120 | {{/tables}} 121 | {{/Schema}} 122 | -------------------------------------------------------------------------------- /sqlmigr/model_test.go: -------------------------------------------------------------------------------- 1 | package sqlmigr_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/phogolabs/prana/sqlmigr" 7 | 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("Migration", func() { 13 | Describe("Parse", func() { 14 | It("parses the item successfully", func() { 15 | filename := "20060102150405_schema.sql" 16 | item, err := sqlmigr.Parse(filename) 17 | Expect(err).NotTo(HaveOccurred()) 18 | Expect(item.ID).To(Equal("20060102150405")) 19 | Expect(item.Description).To(Equal("schema")) 20 | Expect(item.Filenames()).To(ContainElement(filename)) 21 | }) 22 | 23 | Context("when the filename is has longer description", func() { 24 | It("parses the item successfully", func() { 25 | filename := "20060102150405_my_schema_for_this_db.sql" 26 | item, err := sqlmigr.Parse(filename) 27 | Expect(err).NotTo(HaveOccurred()) 28 | Expect(item.ID).To(Equal("20060102150405")) 29 | Expect(item.Description).To(Equal("my_schema_for_this_db")) 30 | Expect(item.Drivers).To(ContainElement("sql")) 31 | Expect(item.Filenames()).To(ContainElement(filename)) 32 | }) 33 | 34 | Context("when the filename has driver name as suffix", func() { 35 | It("parses the item successfully", func() { 36 | filename := "20060102150405_my_schema_for_this_db_sqlite3.sql" 37 | item, err := sqlmigr.Parse(filename) 38 | Expect(err).NotTo(HaveOccurred()) 39 | Expect(item.ID).To(Equal("20060102150405")) 40 | Expect(item.Description).To(Equal("my_schema_for_this_db")) 41 | Expect(item.Drivers).To(ContainElement("sqlite3")) 42 | Expect(item.Filenames()).To(ContainElement(filename)) 43 | }) 44 | }) 45 | }) 46 | 47 | Context("when the filename does not contain two parts", func() { 48 | It("returns an error", func() { 49 | filename := "schema.sql" 50 | item, err := sqlmigr.Parse(filename) 51 | Expect(err).To(MatchError("migration 'schema.sql' has an invalid file name")) 52 | Expect(item).To(BeNil()) 53 | }) 54 | }) 55 | 56 | Context("when the filename does not have timestamp in its name", func() { 57 | It("returns an error", func() { 58 | filename := "id_schema.sql" 59 | item, err := sqlmigr.Parse(filename) 60 | Expect(err).To(MatchError("migration 'id_schema.sql' has an invalid file name")) 61 | Expect(item).To(BeNil()) 62 | }) 63 | }) 64 | }) 65 | }) 66 | 67 | var _ = Describe("RunnerErr", func() { 68 | It("returns the error message", func() { 69 | err := &sqlmigr.RunnerError{ 70 | Err: fmt.Errorf("oh no!"), 71 | Statement: "statement", 72 | } 73 | 74 | Expect(err).To(MatchError("oh no!: statement")) 75 | }) 76 | 77 | Context("when it has a new line", func() { 78 | It("returns the error message", func() { 79 | err := &sqlmigr.RunnerError{ 80 | Err: fmt.Errorf("oh no!"), 81 | Statement: "statement\nhello", 82 | } 83 | 84 | Expect(err).To(MatchError("oh no!: statement")) 85 | }) 86 | }) 87 | }) 88 | 89 | var _ = Describe("IsNotExist", func() { 90 | Context("when the error is SQLite error", func() { 91 | It("returns true", func() { 92 | err := fmt.Errorf("no such table: migrations") 93 | Expect(sqlmigr.IsNotExist(err)).To(BeTrue()) 94 | }) 95 | }) 96 | 97 | Context("when the error is PostgreSQL error", func() { 98 | It("returns true", func() { 99 | err := fmt.Errorf(`pq: relation "migrations" does not exist`) 100 | Expect(sqlmigr.IsNotExist(err)).To(BeTrue()) 101 | }) 102 | }) 103 | 104 | Context("when the error is MySQL error", func() { 105 | It("returns true", func() { 106 | err := fmt.Errorf("migrations' doesn't exist") 107 | Expect(sqlmigr.IsNotExist(err)).To(BeTrue()) 108 | }) 109 | }) 110 | 111 | Context("when the error is not supported", func() { 112 | It("returns false", func() { 113 | err := fmt.Errorf("oh no") 114 | Expect(sqlmigr.IsNotExist(err)).To(BeFalse()) 115 | }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /sqlmodel/builder.go: -------------------------------------------------------------------------------- 1 | package sqlmodel 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | _ TagBuilder = &CompositeTagBuilder{} 10 | _ TagBuilder = &SQLXTagBuilder{} 11 | _ TagBuilder = &GORMTagBuilder{} 12 | _ TagBuilder = &JSONTagBuilder{} 13 | _ TagBuilder = &XMLTagBuilder{} 14 | _ TagBuilder = &ValidateTagBuilder{} 15 | _ TagBuilder = &NoopTagBuilder{} 16 | ) 17 | 18 | // CompositeTagBuilder composes multiple builders 19 | type CompositeTagBuilder []TagBuilder 20 | 21 | // Build builds tags for given column 22 | func (composition CompositeTagBuilder) Build(column *Column) string { 23 | tags := []string{} 24 | 25 | for _, builder := range composition { 26 | tag := strings.TrimSpace(builder.Build(column)) 27 | if tag == "" { 28 | continue 29 | } 30 | tags = append(tags, tag) 31 | } 32 | 33 | return fmt.Sprintf("`%s`", strings.Join(tags, " ")) 34 | } 35 | 36 | // SQLXTagBuilder builds tags for SQLX mapper 37 | type SQLXTagBuilder struct{} 38 | 39 | // Build builds tags for given column 40 | func (builder SQLXTagBuilder) Build(column *Column) string { 41 | options := []string{} 42 | options = append(options, column.Name) 43 | 44 | if column.Type.IsPrimaryKey { 45 | options = append(options, "primary_key") 46 | } 47 | 48 | if column.Type.IsNullable { 49 | options = append(options, "null") 50 | } else { 51 | options = append(options, "not_null") 52 | } 53 | 54 | if size := column.Type.CharMaxLength; size > 0 { 55 | options = append(options, fmt.Sprintf("size=%d", size)) 56 | } 57 | 58 | return fmt.Sprintf("db:\"%s\"", strings.Join(options, ",")) 59 | } 60 | 61 | // GORMTagBuilder builds tags for GORM mapper 62 | type GORMTagBuilder struct{} 63 | 64 | // Build builds tags for given column 65 | func (builder GORMTagBuilder) Build(column *Column) string { 66 | options := []string{} 67 | options = append(options, fmt.Sprintf("column:%s", column.Name)) 68 | options = append(options, fmt.Sprintf("type:%s", strings.ToLower(column.Type.DBType()))) 69 | 70 | if column.Type.IsPrimaryKey { 71 | options = append(options, "primary_key") 72 | } 73 | 74 | if column.Type.IsNullable { 75 | options = append(options, "null") 76 | } else { 77 | options = append(options, "not null") 78 | } 79 | 80 | if size := column.Type.CharMaxLength; size > 0 { 81 | options = append(options, fmt.Sprintf("size:%d", size)) 82 | } 83 | 84 | if precision := column.Type.Precision; precision > 0 { 85 | options = append(options, fmt.Sprintf("precision:%d", precision)) 86 | } 87 | 88 | return fmt.Sprintf("gorm:\"%s\"", strings.Join(options, ";")) 89 | } 90 | 91 | // JSONTagBuilder builds JSON tags 92 | type JSONTagBuilder struct{} 93 | 94 | // Build builds tags for given column 95 | func (builder JSONTagBuilder) Build(column *Column) string { 96 | return fmt.Sprintf("json:\"%s\"", column.Name) 97 | } 98 | 99 | // XMLTagBuilder builds XML tags 100 | type XMLTagBuilder struct{} 101 | 102 | // Build builds tags for given column 103 | func (builder XMLTagBuilder) Build(column *Column) string { 104 | return fmt.Sprintf("xml:\"%s\"", column.Name) 105 | } 106 | 107 | // ValidateTagBuilder builds JSON tags 108 | type ValidateTagBuilder struct{} 109 | 110 | // Build builds tags for given column 111 | func (builder ValidateTagBuilder) Build(column *Column) string { 112 | options := []string{} 113 | 114 | if !column.Type.IsNullable { 115 | options = append(options, "required") 116 | 117 | if strings.EqualFold(column.ScanType, "string") { 118 | options = append(options, "gt=0") 119 | } 120 | } 121 | 122 | if size := column.Type.CharMaxLength; size > 0 { 123 | options = append(options, fmt.Sprintf("max=%d", size)) 124 | } 125 | 126 | if len(options) == 0 { 127 | options = append(options, "-") 128 | } 129 | 130 | return fmt.Sprintf("validate:\"%s\"", strings.Join(options, ",")) 131 | } 132 | 133 | // NoopTagBuilder composes multiple builders 134 | type NoopTagBuilder struct{} 135 | 136 | // Build builds tags for given column 137 | func (composition NoopTagBuilder) Build(column *Column) string { 138 | return "" 139 | } 140 | -------------------------------------------------------------------------------- /sqlmigr/executor.go: -------------------------------------------------------------------------------- 1 | package sqlmigr 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | "github.com/go-openapi/inflect" 11 | "github.com/phogolabs/log" 12 | ) 13 | 14 | var migrationRgxp = regexp.MustCompile(`CREATE TABLE IF NOT EXISTS\s*([a-zA-Z0-9\.]+)\s*`) 15 | 16 | // Executor provides a group of operations that works with migrations. 17 | type Executor struct { 18 | // Logger logs each execution step 19 | Logger log.Logger 20 | // Provider provides all migrations for the current project. 21 | Provider MigrationProvider 22 | // Runner runs or reverts migrations for the current project. 23 | Runner MigrationRunner 24 | // Generator generates a migration file. 25 | Generator MigrationGenerator 26 | } 27 | 28 | // Setup setups the current project for database migrations by creating 29 | // migration directory and related database. 30 | func (m *Executor) Setup() error { 31 | up := &bytes.Buffer{} 32 | 33 | fmt.Fprintln(up, "CREATE TABLE IF NOT EXISTS migrations (") 34 | fmt.Fprintln(up, " id VARCHAR(14) NOT NULL PRIMARY KEY,") 35 | fmt.Fprintln(up, " description TEXT NOT NULL,") 36 | fmt.Fprintln(up, " created_at TIMESTAMP NOT NULL") 37 | fmt.Fprintln(up, ");") 38 | fmt.Fprintln(up) 39 | 40 | down := bytes.NewBufferString("DROP TABLE IF EXISTS migrations;") 41 | fmt.Fprintln(down) 42 | 43 | content := &Content{ 44 | UpCommand: up, 45 | DownCommand: down, 46 | } 47 | 48 | return m.Generator.Write(setup, content) 49 | } 50 | 51 | // Create creates a migration script successfully if the project has already 52 | // been setup, otherwise returns an error. 53 | func (m *Executor) Create(name string) (*Migration, error) { 54 | now := time.Now().UTC() 55 | id := now.Format(format) 56 | name = inflect.Underscore(strings.ToLower(name)) 57 | name = fmt.Sprintf("%s_%s.sql", id, name) 58 | 59 | migration, _ := Parse(name) 60 | 61 | if migration != nil { 62 | migration.CreatedAt = now 63 | } 64 | 65 | if err := m.Generator.Create(migration); err != nil { 66 | return nil, err 67 | } 68 | 69 | return migration, nil 70 | } 71 | 72 | // Run runs a pending migration for given count. If the count is negative number, it 73 | // will execute all pending migrations. 74 | func (m *Executor) Run(step int) (int, error) { 75 | run := 0 76 | migrations, err := m.Migrations() 77 | if err != nil { 78 | return run, err 79 | } 80 | 81 | for index, migration := range migrations { 82 | if step == 0 { 83 | return run, nil 84 | } 85 | 86 | if !migration.CreatedAt.IsZero() { 87 | continue 88 | } 89 | 90 | m.logf("Running migration '%v'", migration) 91 | 92 | if err := m.Runner.Run(migrations[index]); err != nil { 93 | return run, err 94 | } 95 | 96 | if err := m.Provider.Insert(migrations[index]); err != nil { 97 | return run, err 98 | } 99 | 100 | step = step - 1 101 | run = run + 1 102 | } 103 | 104 | return run, nil 105 | } 106 | 107 | // RunAll runs all pending migrations. 108 | func (m *Executor) RunAll() (int, error) { 109 | return m.Run(-1) 110 | } 111 | 112 | // Revert reverts an applied migration for given count. If the count is 113 | // negative number, it will revert all applied migrations. 114 | func (m *Executor) Revert(step int) (int, error) { 115 | reverted := 0 116 | migrations, err := m.Migrations() 117 | 118 | if err != nil { 119 | return reverted, err 120 | } 121 | 122 | for index := len(migrations) - 1; index >= 0; index-- { 123 | migration := migrations[index] 124 | 125 | if step == 0 { 126 | return reverted, nil 127 | } 128 | 129 | if migration.CreatedAt.IsZero() { 130 | continue 131 | } 132 | 133 | m.logf("Reverting migration '%v'", migration) 134 | 135 | if err := m.Runner.Revert(migrations[index]); err != nil { 136 | return reverted, err 137 | } 138 | 139 | if err := m.Provider.Delete(migrations[index]); err != nil { 140 | if IsNotExist(err) { 141 | err = nil 142 | } 143 | return reverted, err 144 | } 145 | 146 | step = step - 1 147 | reverted = reverted + 1 148 | } 149 | 150 | return reverted, nil 151 | } 152 | 153 | // RevertAll reverts all applied migrations. 154 | func (m *Executor) RevertAll() (int, error) { 155 | return m.Revert(-1) 156 | } 157 | 158 | // Migrations returns all migrations. 159 | func (m *Executor) Migrations() ([]*Migration, error) { 160 | return m.Provider.Migrations() 161 | } 162 | 163 | func (m *Executor) logf(text string, args ...interface{}) { 164 | if m.Logger != nil { 165 | m.Logger.Infof(text, args...) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /sqlmigr/provider.go: -------------------------------------------------------------------------------- 1 | package sqlmigr 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | 12 | "github.com/jmoiron/sqlx" 13 | ) 14 | 15 | var _ MigrationProvider = &Provider{} 16 | 17 | // Provider provides all migration for given project. 18 | type Provider struct { 19 | // FileSystem represents the project directory file system. 20 | FileSystem FileSystem 21 | // DB is a client to underlying database. 22 | DB *sqlx.DB 23 | } 24 | 25 | // Migrations returns the project migrations. 26 | func (m *Provider) Migrations() ([]*Migration, error) { 27 | local, err := m.files() 28 | if err != nil { 29 | return local, err 30 | } 31 | 32 | remote, err := m.query() 33 | if err != nil { 34 | return remote, err 35 | } 36 | 37 | return m.merge(remote, local) 38 | } 39 | 40 | func (m *Provider) files() ([]*Migration, error) { 41 | local := []*Migration{} 42 | 43 | err := fs.WalkDir(m.FileSystem, ".", func(path string, info os.DirEntry, xerr error) error { 44 | if ferr := m.filter(info); ferr != nil { 45 | if ferr.Error() == "skip" { 46 | ferr = nil 47 | } 48 | 49 | return ferr 50 | } 51 | 52 | migration, err := Parse(path) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if !m.supported(migration.Drivers) { 58 | return nil 59 | } 60 | 61 | if index := len(local) - 1; index >= 0 { 62 | if prev := local[index]; migration.Equal(prev) { 63 | prev.Drivers = append(prev.Drivers, migration.Drivers...) 64 | local[index] = prev 65 | return nil 66 | } 67 | } 68 | 69 | local = append(local, migration) 70 | return nil 71 | }) 72 | 73 | if err != nil { 74 | return []*Migration{}, err 75 | } 76 | 77 | return local, nil 78 | } 79 | 80 | func (m *Provider) filter(info fs.DirEntry) error { 81 | skip := fmt.Errorf("skip") 82 | 83 | if info == nil { 84 | return os.ErrNotExist 85 | } 86 | 87 | if info.IsDir() { 88 | return skip 89 | } 90 | 91 | matched, _ := filepath.Match("*.sql", info.Name()) 92 | 93 | if !matched { 94 | return skip 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func (m *Provider) supported(drivers []string) bool { 101 | for _, driver := range drivers { 102 | if driver == every || driver == m.DB.DriverName() { 103 | return true 104 | } 105 | } 106 | 107 | return false 108 | } 109 | 110 | func (m *Provider) query() ([]*Migration, error) { 111 | query := &bytes.Buffer{} 112 | query.WriteString("SELECT id, description, created_at ") 113 | query.WriteString("FROM " + m.table() + " ") 114 | query.WriteString("ORDER BY id ASC") 115 | 116 | remote := []*Migration{} 117 | 118 | if err := m.DB.Select(&remote, query.String()); err != nil && !IsNotExist(err) { 119 | return []*Migration{}, err 120 | } 121 | 122 | return remote, nil 123 | } 124 | 125 | // Insert inserts executed sqlmigr item in the sqlmigrs table. 126 | func (m *Provider) Insert(item *Migration) error { 127 | item.CreatedAt = time.Now() 128 | 129 | builder := &bytes.Buffer{} 130 | builder.WriteString("INSERT INTO " + m.table() + "(id, description, created_at) ") 131 | builder.WriteString("VALUES (?, ?, ?)") 132 | 133 | query := m.DB.Rebind(builder.String()) 134 | if _, err := m.DB.Exec(query, item.ID, item.Description, item.CreatedAt); err != nil { 135 | return err 136 | } 137 | 138 | return nil 139 | } 140 | 141 | // Delete deletes applied sqlmigr item from sqlmigrs table. 142 | func (m *Provider) Delete(item *Migration) error { 143 | builder := &bytes.Buffer{} 144 | builder.WriteString("DELETE FROM " + m.table() + " ") 145 | builder.WriteString("WHERE id = ?") 146 | 147 | query := m.DB.Rebind(builder.String()) 148 | if _, err := m.DB.Exec(query, item.ID); err != nil { 149 | return err 150 | } 151 | 152 | return nil 153 | } 154 | 155 | // Exists returns true if the sqlmigr exists 156 | func (m *Provider) Exists(item *Migration) bool { 157 | count := 0 158 | 159 | if err := m.DB.Get(&count, "SELECT count(id) FROM "+m.table()+" WHERE id = ?", item.ID); err != nil { 160 | return false 161 | } 162 | 163 | return count == 1 164 | } 165 | 166 | func (m *Provider) merge(remote, local []*Migration) ([]*Migration, error) { 167 | result := local 168 | 169 | for index, r := range remote { 170 | l := local[index] 171 | 172 | if r.ID != l.ID { 173 | return []*Migration{}, fmt.Errorf("mismatched migration id. Expected: '%s' but has '%s'", r.ID, l.ID) 174 | } 175 | 176 | if r.Description != l.Description { 177 | return []*Migration{}, fmt.Errorf("mismatched migration description. Expected: '%s' but has '%s'", r.Description, l.Description) 178 | } 179 | 180 | // Merge creation time 181 | l.CreatedAt = r.CreatedAt 182 | result[index] = l 183 | } 184 | 185 | return result, nil 186 | } 187 | 188 | func (m *Provider) table() string { 189 | for _, path := range setup.Filenames() { 190 | file, err := m.FileSystem.Open(path) 191 | if err != nil { 192 | continue 193 | } 194 | // close the file 195 | defer file.Close() 196 | 197 | if data, err := io.ReadAll(file); err == nil { 198 | if match := migrationRgxp.FindSubmatch(data); len(match) == 2 { 199 | return string(match[1]) 200 | } 201 | } 202 | } 203 | 204 | return "migrations" 205 | } 206 | -------------------------------------------------------------------------------- /fake/migration_runner.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fake 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/phogolabs/prana/sqlmigr" 8 | ) 9 | 10 | type MigrationRunner struct { 11 | RevertStub func(*sqlmigr.Migration) error 12 | revertMutex sync.RWMutex 13 | revertArgsForCall []struct { 14 | arg1 *sqlmigr.Migration 15 | } 16 | revertReturns struct { 17 | result1 error 18 | } 19 | revertReturnsOnCall map[int]struct { 20 | result1 error 21 | } 22 | RunStub func(*sqlmigr.Migration) error 23 | runMutex sync.RWMutex 24 | runArgsForCall []struct { 25 | arg1 *sqlmigr.Migration 26 | } 27 | runReturns struct { 28 | result1 error 29 | } 30 | runReturnsOnCall map[int]struct { 31 | result1 error 32 | } 33 | invocations map[string][][]interface{} 34 | invocationsMutex sync.RWMutex 35 | } 36 | 37 | func (fake *MigrationRunner) Revert(arg1 *sqlmigr.Migration) error { 38 | fake.revertMutex.Lock() 39 | ret, specificReturn := fake.revertReturnsOnCall[len(fake.revertArgsForCall)] 40 | fake.revertArgsForCall = append(fake.revertArgsForCall, struct { 41 | arg1 *sqlmigr.Migration 42 | }{arg1}) 43 | fake.recordInvocation("Revert", []interface{}{arg1}) 44 | fake.revertMutex.Unlock() 45 | if fake.RevertStub != nil { 46 | return fake.RevertStub(arg1) 47 | } 48 | if specificReturn { 49 | return ret.result1 50 | } 51 | fakeReturns := fake.revertReturns 52 | return fakeReturns.result1 53 | } 54 | 55 | func (fake *MigrationRunner) RevertCallCount() int { 56 | fake.revertMutex.RLock() 57 | defer fake.revertMutex.RUnlock() 58 | return len(fake.revertArgsForCall) 59 | } 60 | 61 | func (fake *MigrationRunner) RevertCalls(stub func(*sqlmigr.Migration) error) { 62 | fake.revertMutex.Lock() 63 | defer fake.revertMutex.Unlock() 64 | fake.RevertStub = stub 65 | } 66 | 67 | func (fake *MigrationRunner) RevertArgsForCall(i int) *sqlmigr.Migration { 68 | fake.revertMutex.RLock() 69 | defer fake.revertMutex.RUnlock() 70 | argsForCall := fake.revertArgsForCall[i] 71 | return argsForCall.arg1 72 | } 73 | 74 | func (fake *MigrationRunner) RevertReturns(result1 error) { 75 | fake.revertMutex.Lock() 76 | defer fake.revertMutex.Unlock() 77 | fake.RevertStub = nil 78 | fake.revertReturns = struct { 79 | result1 error 80 | }{result1} 81 | } 82 | 83 | func (fake *MigrationRunner) RevertReturnsOnCall(i int, result1 error) { 84 | fake.revertMutex.Lock() 85 | defer fake.revertMutex.Unlock() 86 | fake.RevertStub = nil 87 | if fake.revertReturnsOnCall == nil { 88 | fake.revertReturnsOnCall = make(map[int]struct { 89 | result1 error 90 | }) 91 | } 92 | fake.revertReturnsOnCall[i] = struct { 93 | result1 error 94 | }{result1} 95 | } 96 | 97 | func (fake *MigrationRunner) Run(arg1 *sqlmigr.Migration) error { 98 | fake.runMutex.Lock() 99 | ret, specificReturn := fake.runReturnsOnCall[len(fake.runArgsForCall)] 100 | fake.runArgsForCall = append(fake.runArgsForCall, struct { 101 | arg1 *sqlmigr.Migration 102 | }{arg1}) 103 | fake.recordInvocation("Run", []interface{}{arg1}) 104 | fake.runMutex.Unlock() 105 | if fake.RunStub != nil { 106 | return fake.RunStub(arg1) 107 | } 108 | if specificReturn { 109 | return ret.result1 110 | } 111 | fakeReturns := fake.runReturns 112 | return fakeReturns.result1 113 | } 114 | 115 | func (fake *MigrationRunner) RunCallCount() int { 116 | fake.runMutex.RLock() 117 | defer fake.runMutex.RUnlock() 118 | return len(fake.runArgsForCall) 119 | } 120 | 121 | func (fake *MigrationRunner) RunCalls(stub func(*sqlmigr.Migration) error) { 122 | fake.runMutex.Lock() 123 | defer fake.runMutex.Unlock() 124 | fake.RunStub = stub 125 | } 126 | 127 | func (fake *MigrationRunner) RunArgsForCall(i int) *sqlmigr.Migration { 128 | fake.runMutex.RLock() 129 | defer fake.runMutex.RUnlock() 130 | argsForCall := fake.runArgsForCall[i] 131 | return argsForCall.arg1 132 | } 133 | 134 | func (fake *MigrationRunner) RunReturns(result1 error) { 135 | fake.runMutex.Lock() 136 | defer fake.runMutex.Unlock() 137 | fake.RunStub = nil 138 | fake.runReturns = struct { 139 | result1 error 140 | }{result1} 141 | } 142 | 143 | func (fake *MigrationRunner) RunReturnsOnCall(i int, result1 error) { 144 | fake.runMutex.Lock() 145 | defer fake.runMutex.Unlock() 146 | fake.RunStub = nil 147 | if fake.runReturnsOnCall == nil { 148 | fake.runReturnsOnCall = make(map[int]struct { 149 | result1 error 150 | }) 151 | } 152 | fake.runReturnsOnCall[i] = struct { 153 | result1 error 154 | }{result1} 155 | } 156 | 157 | func (fake *MigrationRunner) Invocations() map[string][][]interface{} { 158 | fake.invocationsMutex.RLock() 159 | defer fake.invocationsMutex.RUnlock() 160 | fake.revertMutex.RLock() 161 | defer fake.revertMutex.RUnlock() 162 | fake.runMutex.RLock() 163 | defer fake.runMutex.RUnlock() 164 | copiedInvocations := map[string][][]interface{}{} 165 | for key, value := range fake.invocations { 166 | copiedInvocations[key] = value 167 | } 168 | return copiedInvocations 169 | } 170 | 171 | func (fake *MigrationRunner) recordInvocation(key string, args []interface{}) { 172 | fake.invocationsMutex.Lock() 173 | defer fake.invocationsMutex.Unlock() 174 | if fake.invocations == nil { 175 | fake.invocations = map[string][][]interface{}{} 176 | } 177 | if fake.invocations[key] == nil { 178 | fake.invocations[key] = [][]interface{}{} 179 | } 180 | fake.invocations[key] = append(fake.invocations[key], args) 181 | } 182 | 183 | var _ sqlmigr.MigrationRunner = new(MigrationRunner) 184 | -------------------------------------------------------------------------------- /sqlmigr/model.go: -------------------------------------------------------------------------------- 1 | // Package sqlmigr provides primitives and functions to work with SQL 2 | // sqlmigrs. 3 | package sqlmigr 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | 13 | "github.com/phogolabs/prana/sqlexec" 14 | ) 15 | 16 | //go:generate counterfeiter -fake-name MigrationRunner -o ../fake/migration_runner.go . MigrationRunner 17 | //go:generate counterfeiter -fake-name MigrationProvider -o ../fake/migration_provider.go . MigrationProvider 18 | //go:generate counterfeiter -fake-name MigrationGenerator -o ../fake/migration_generator.go . MigrationGenerator 19 | 20 | var ( 21 | format = "20060102150405" 22 | min = time.Date(1, time.January, 1970, 0, 0, 0, 0, time.UTC) 23 | every = "sql" 24 | ) 25 | 26 | var ( 27 | // setup migration 28 | setup = &Migration{ 29 | ID: min.Format(format), 30 | Description: "setup", 31 | Drivers: []string{every}, 32 | CreatedAt: time.Now(), 33 | } 34 | ) 35 | 36 | // FileSystem provides with primitives to work with the underlying file system 37 | type FileSystem = fs.FS 38 | 39 | // WriteFileSystem represents a wriable file system 40 | type WriteFileSystem interface { 41 | FileSystem 42 | 43 | // OpenFile opens a new file 44 | OpenFile(string, int, fs.FileMode) (fs.File, error) 45 | } 46 | 47 | // MigrationRunner runs or reverts a given sqlmigr item. 48 | type MigrationRunner interface { 49 | // Run runs a given sqlmigr item. 50 | Run(item *Migration) error 51 | // Revert reverts a given sqlmigr item. 52 | Revert(item *Migration) error 53 | } 54 | 55 | // MigrationProvider provides all items. 56 | type MigrationProvider interface { 57 | // Migrations returns all sqlmigr items. 58 | Migrations() ([]*Migration, error) 59 | // Insert inserts executed sqlmigr item in the sqlmigrs table. 60 | Insert(item *Migration) error 61 | // Delete deletes applied sqlmigr item from sqlmigrs table. 62 | Delete(item *Migration) error 63 | // Exists returns true if the sqlmigr exists 64 | Exists(item *Migration) bool 65 | } 66 | 67 | // MigrationGenerator generates a migration item file. 68 | type MigrationGenerator interface { 69 | // Create creates a new sqlmigr. 70 | Create(m *Migration) error 71 | // Write creates a new sqlmigr for given content. 72 | Write(m *Migration, content *Content) error 73 | } 74 | 75 | // Content represents a migration content. 76 | type Content struct { 77 | // UpCommand is the content for upgrade operation. 78 | UpCommand io.Reader 79 | // DownCommand is the content for rollback operation. 80 | DownCommand io.Reader 81 | } 82 | 83 | // RunnerError represents a runner error 84 | type RunnerError struct { 85 | // Err the actual error 86 | Err error 87 | // Statement that cause the issue 88 | Statement string 89 | } 90 | 91 | // Error returns the error as string 92 | func (e *RunnerError) Error() string { 93 | lines := strings.Split(e.Statement, "\n") 94 | return fmt.Sprintf("%s: %s", e.Err.Error(), lines[0]) 95 | } 96 | 97 | // Migration represents a single migration record. 98 | type Migration struct { 99 | // Id is the primary key for this sqlmigr 100 | ID string `db:"id"` 101 | // Description is the short description of this sqlmigr. 102 | Description string `db:"description"` 103 | // CreatedAt returns the time of sqlmigr execution. 104 | CreatedAt time.Time `db:"created_at"` 105 | // Drivers return all supported drivers 106 | Drivers []string `db:"-"` 107 | } 108 | 109 | // Filenames return the migration filenames 110 | func (m *Migration) Filenames() []string { 111 | var ( 112 | files []string 113 | parts []string 114 | ) 115 | 116 | for _, driver := range m.Drivers { 117 | switch driver { 118 | case every: 119 | parts = []string{m.ID, m.Description} 120 | default: 121 | parts = []string{m.ID, m.Description, driver} 122 | } 123 | 124 | files = append(files, fmt.Sprintf("%s.sql", strings.Join(parts, "_"))) 125 | } 126 | 127 | return files 128 | } 129 | 130 | // String returns the migration as string 131 | func (m *Migration) String() string { 132 | return fmt.Sprintf("%s_%s", m.ID, m.Description) 133 | } 134 | 135 | // Equal returns true if the migrations are equal 136 | func (m *Migration) Equal(migration *Migration) bool { 137 | return m.ID == migration.ID && m.Description == migration.Description 138 | } 139 | 140 | // Parse parses a given file path to a sqlmigr item. 141 | func Parse(path string) (*Migration, error) { 142 | name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) 143 | parts := strings.SplitN(name, "_", 2) 144 | parseErr := fmt.Errorf("migration '%s' has an invalid file name", path) 145 | 146 | if len(parts) != 2 { 147 | return nil, parseErr 148 | } 149 | 150 | if _, err := time.Parse(format, parts[0]); err != nil { 151 | return nil, parseErr 152 | } 153 | 154 | id := parts[0] 155 | description := parts[1] 156 | driver := sqlexec.PathDriver(path) 157 | 158 | if driver != every { 159 | pattern := fmt.Sprintf("_%s", driver) 160 | description = strings.Replace(description, pattern, "", -1) 161 | } 162 | 163 | return &Migration{ 164 | ID: id, 165 | Description: description, 166 | Drivers: []string{driver}, 167 | }, nil 168 | } 169 | 170 | // IsNotExist reports if the error is because of migration table not exists 171 | func IsNotExist(err error) bool { 172 | msg := err.Error() 173 | 174 | switch { 175 | // SQLite 176 | case strings.HasPrefix(msg, "no such table"): 177 | return true 178 | // PostgreSQL 179 | case strings.HasSuffix(msg, "does not exist"): 180 | return true 181 | // MySQL 182 | case strings.HasSuffix(msg, "doesn't exist"): 183 | return true 184 | default: 185 | return false 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /fake/migration_generator.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fake 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/phogolabs/prana/sqlmigr" 8 | ) 9 | 10 | type MigrationGenerator struct { 11 | CreateStub func(*sqlmigr.Migration) error 12 | createMutex sync.RWMutex 13 | createArgsForCall []struct { 14 | arg1 *sqlmigr.Migration 15 | } 16 | createReturns struct { 17 | result1 error 18 | } 19 | createReturnsOnCall map[int]struct { 20 | result1 error 21 | } 22 | WriteStub func(*sqlmigr.Migration, *sqlmigr.Content) error 23 | writeMutex sync.RWMutex 24 | writeArgsForCall []struct { 25 | arg1 *sqlmigr.Migration 26 | arg2 *sqlmigr.Content 27 | } 28 | writeReturns struct { 29 | result1 error 30 | } 31 | writeReturnsOnCall map[int]struct { 32 | result1 error 33 | } 34 | invocations map[string][][]interface{} 35 | invocationsMutex sync.RWMutex 36 | } 37 | 38 | func (fake *MigrationGenerator) Create(arg1 *sqlmigr.Migration) error { 39 | fake.createMutex.Lock() 40 | ret, specificReturn := fake.createReturnsOnCall[len(fake.createArgsForCall)] 41 | fake.createArgsForCall = append(fake.createArgsForCall, struct { 42 | arg1 *sqlmigr.Migration 43 | }{arg1}) 44 | fake.recordInvocation("Create", []interface{}{arg1}) 45 | fake.createMutex.Unlock() 46 | if fake.CreateStub != nil { 47 | return fake.CreateStub(arg1) 48 | } 49 | if specificReturn { 50 | return ret.result1 51 | } 52 | fakeReturns := fake.createReturns 53 | return fakeReturns.result1 54 | } 55 | 56 | func (fake *MigrationGenerator) CreateCallCount() int { 57 | fake.createMutex.RLock() 58 | defer fake.createMutex.RUnlock() 59 | return len(fake.createArgsForCall) 60 | } 61 | 62 | func (fake *MigrationGenerator) CreateCalls(stub func(*sqlmigr.Migration) error) { 63 | fake.createMutex.Lock() 64 | defer fake.createMutex.Unlock() 65 | fake.CreateStub = stub 66 | } 67 | 68 | func (fake *MigrationGenerator) CreateArgsForCall(i int) *sqlmigr.Migration { 69 | fake.createMutex.RLock() 70 | defer fake.createMutex.RUnlock() 71 | argsForCall := fake.createArgsForCall[i] 72 | return argsForCall.arg1 73 | } 74 | 75 | func (fake *MigrationGenerator) CreateReturns(result1 error) { 76 | fake.createMutex.Lock() 77 | defer fake.createMutex.Unlock() 78 | fake.CreateStub = nil 79 | fake.createReturns = struct { 80 | result1 error 81 | }{result1} 82 | } 83 | 84 | func (fake *MigrationGenerator) CreateReturnsOnCall(i int, result1 error) { 85 | fake.createMutex.Lock() 86 | defer fake.createMutex.Unlock() 87 | fake.CreateStub = nil 88 | if fake.createReturnsOnCall == nil { 89 | fake.createReturnsOnCall = make(map[int]struct { 90 | result1 error 91 | }) 92 | } 93 | fake.createReturnsOnCall[i] = struct { 94 | result1 error 95 | }{result1} 96 | } 97 | 98 | func (fake *MigrationGenerator) Write(arg1 *sqlmigr.Migration, arg2 *sqlmigr.Content) error { 99 | fake.writeMutex.Lock() 100 | ret, specificReturn := fake.writeReturnsOnCall[len(fake.writeArgsForCall)] 101 | fake.writeArgsForCall = append(fake.writeArgsForCall, struct { 102 | arg1 *sqlmigr.Migration 103 | arg2 *sqlmigr.Content 104 | }{arg1, arg2}) 105 | fake.recordInvocation("Write", []interface{}{arg1, arg2}) 106 | fake.writeMutex.Unlock() 107 | if fake.WriteStub != nil { 108 | return fake.WriteStub(arg1, arg2) 109 | } 110 | if specificReturn { 111 | return ret.result1 112 | } 113 | fakeReturns := fake.writeReturns 114 | return fakeReturns.result1 115 | } 116 | 117 | func (fake *MigrationGenerator) WriteCallCount() int { 118 | fake.writeMutex.RLock() 119 | defer fake.writeMutex.RUnlock() 120 | return len(fake.writeArgsForCall) 121 | } 122 | 123 | func (fake *MigrationGenerator) WriteCalls(stub func(*sqlmigr.Migration, *sqlmigr.Content) error) { 124 | fake.writeMutex.Lock() 125 | defer fake.writeMutex.Unlock() 126 | fake.WriteStub = stub 127 | } 128 | 129 | func (fake *MigrationGenerator) WriteArgsForCall(i int) (*sqlmigr.Migration, *sqlmigr.Content) { 130 | fake.writeMutex.RLock() 131 | defer fake.writeMutex.RUnlock() 132 | argsForCall := fake.writeArgsForCall[i] 133 | return argsForCall.arg1, argsForCall.arg2 134 | } 135 | 136 | func (fake *MigrationGenerator) WriteReturns(result1 error) { 137 | fake.writeMutex.Lock() 138 | defer fake.writeMutex.Unlock() 139 | fake.WriteStub = nil 140 | fake.writeReturns = struct { 141 | result1 error 142 | }{result1} 143 | } 144 | 145 | func (fake *MigrationGenerator) WriteReturnsOnCall(i int, result1 error) { 146 | fake.writeMutex.Lock() 147 | defer fake.writeMutex.Unlock() 148 | fake.WriteStub = nil 149 | if fake.writeReturnsOnCall == nil { 150 | fake.writeReturnsOnCall = make(map[int]struct { 151 | result1 error 152 | }) 153 | } 154 | fake.writeReturnsOnCall[i] = struct { 155 | result1 error 156 | }{result1} 157 | } 158 | 159 | func (fake *MigrationGenerator) Invocations() map[string][][]interface{} { 160 | fake.invocationsMutex.RLock() 161 | defer fake.invocationsMutex.RUnlock() 162 | fake.createMutex.RLock() 163 | defer fake.createMutex.RUnlock() 164 | fake.writeMutex.RLock() 165 | defer fake.writeMutex.RUnlock() 166 | copiedInvocations := map[string][][]interface{}{} 167 | for key, value := range fake.invocations { 168 | copiedInvocations[key] = value 169 | } 170 | return copiedInvocations 171 | } 172 | 173 | func (fake *MigrationGenerator) recordInvocation(key string, args []interface{}) { 174 | fake.invocationsMutex.Lock() 175 | defer fake.invocationsMutex.Unlock() 176 | if fake.invocations == nil { 177 | fake.invocations = map[string][][]interface{}{} 178 | } 179 | if fake.invocations[key] == nil { 180 | fake.invocations[key] = [][]interface{}{} 181 | } 182 | fake.invocations[key] = append(fake.invocations[key], args) 183 | } 184 | 185 | var _ sqlmigr.MigrationGenerator = new(MigrationGenerator) 186 | -------------------------------------------------------------------------------- /sqlmigr/runner_test.go: -------------------------------------------------------------------------------- 1 | package sqlmigr_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/jmoiron/sqlx" 11 | "github.com/phogolabs/prana/sqlmigr" 12 | "github.com/phogolabs/prana/storage" 13 | 14 | . "github.com/onsi/ginkgo" 15 | . "github.com/onsi/gomega" 16 | ) 17 | 18 | var _ = Describe("Runner", func() { 19 | var ( 20 | runner *sqlmigr.Runner 21 | item *sqlmigr.Migration 22 | dir string 23 | ) 24 | 25 | BeforeEach(func() { 26 | var err error 27 | 28 | dir, err = ioutil.TempDir("", "prana_runner") 29 | Expect(err).To(BeNil()) 30 | 31 | conn := filepath.Join(dir, "prana.db") 32 | db, err := sqlx.Open("sqlite3", conn) 33 | Expect(err).To(BeNil()) 34 | 35 | runner = &sqlmigr.Runner{ 36 | FileSystem: storage.New(dir), 37 | DB: db, 38 | } 39 | 40 | item = &sqlmigr.Migration{ 41 | ID: "20160102150", 42 | Description: "schema", 43 | Drivers: []string{"sql"}, 44 | } 45 | }) 46 | 47 | JustBeforeEach(func() { 48 | query := &bytes.Buffer{} 49 | fmt.Fprintln(query, "CREATE TABLE migrations (") 50 | fmt.Fprintln(query, " id TEXT NOT NULL PRIMARY KEY,") 51 | fmt.Fprintln(query, " description TEXT NOT NULL,") 52 | fmt.Fprintln(query, " created_at TIMESTAMP NOT NULL") 53 | fmt.Fprintln(query, ");") 54 | 55 | _, err := runner.DB.Exec(query.String()) 56 | Expect(err).To(BeNil()) 57 | 58 | sqlmigr := &bytes.Buffer{} 59 | fmt.Fprintln(sqlmigr, "-- name: up") 60 | fmt.Fprintln(sqlmigr, "CREATE TABLE IF NOT EXISTS test(id TEXT);") 61 | fmt.Fprintln(sqlmigr, "CREATE TABLE IF NOT EXISTS test2(id TEXT);") 62 | fmt.Fprintln(sqlmigr, "-- name: down") 63 | fmt.Fprintln(sqlmigr, "DROP TABLE IF EXISTS test;") 64 | fmt.Fprintln(sqlmigr, "DROP TABLE IF EXISTS test2;") 65 | 66 | for _, filename := range item.Filenames() { 67 | path := filepath.Join(dir, filename) 68 | Expect(ioutil.WriteFile(path, sqlmigr.Bytes(), 0700)).To(Succeed()) 69 | } 70 | }) 71 | 72 | AfterEach(func() { 73 | runner.DB.Close() 74 | }) 75 | 76 | Describe("Run", func() { 77 | It("runs the sqlmigr successfully", func() { 78 | Expect(runner.Run(item)).To(Succeed()) 79 | _, err := runner.DB.Exec("SELECT id FROM test") 80 | Expect(err).NotTo(HaveOccurred()) 81 | }) 82 | 83 | Context("when the sqlmigr does not exist", func() { 84 | JustBeforeEach(func() { 85 | for _, filename := range item.Filenames() { 86 | path := filepath.Join(dir, filename) 87 | Expect(os.Remove(path)).To(Succeed()) 88 | } 89 | }) 90 | 91 | It("returns an error", func() { 92 | path := filepath.Join(dir, item.Filenames()[0]) 93 | msg := fmt.Sprintf("open %s: no such file or directory", path) 94 | Expect(runner.Run(item)).To(MatchError(msg)) 95 | }) 96 | }) 97 | 98 | Context("when the database is not available", func() { 99 | JustBeforeEach(func() { 100 | Expect(runner.DB.Close()).To(Succeed()) 101 | }) 102 | 103 | It("return an error", func() { 104 | err := runner.Run(item) 105 | Expect(err).To(HaveOccurred()) 106 | Expect(err.Error()).To(ContainSubstring("sql: database is closed")) 107 | }) 108 | }) 109 | 110 | Context("when the sqlmigr step does not exist", func() { 111 | JustBeforeEach(func() { 112 | sqlmigr := &bytes.Buffer{} 113 | fmt.Fprintln(sqlmigr, "-- name: down") 114 | fmt.Fprintln(sqlmigr, "DROP TABLE IF EXISTS test") 115 | 116 | path := filepath.Join(dir, item.Filenames()[0]) 117 | Expect(ioutil.WriteFile(path, sqlmigr.Bytes(), 0700)).To(Succeed()) 118 | }) 119 | 120 | It("return an error", func() { 121 | Expect(runner.Run(item)).To(HaveOccurred()) 122 | }) 123 | }) 124 | }) 125 | 126 | Describe("Revert", func() { 127 | It("reverts the migration successfully", func() { 128 | Expect(runner.Revert(item)).To(Succeed()) 129 | _, err := runner.DB.Exec("SELECT id FROM test") 130 | Expect(err).To(MatchError("no such table: test")) 131 | }) 132 | 133 | Context("when the migration has multiple files", func() { 134 | BeforeEach(func() { 135 | item.Drivers = []string{"sqlite3", "sql"} 136 | }) 137 | 138 | It("reverts the migration successfully", func() { 139 | Expect(runner.Revert(item)).To(Succeed()) 140 | _, err := runner.DB.Exec("SELECT id FROM test") 141 | Expect(err).To(MatchError("no such table: test")) 142 | }) 143 | }) 144 | 145 | Context("when the migration does not exist", func() { 146 | JustBeforeEach(func() { 147 | path := filepath.Join(dir, item.Filenames()[0]) 148 | Expect(os.Remove(path)).To(Succeed()) 149 | }) 150 | 151 | It("returns an error", func() { 152 | path := filepath.Join(dir, item.Filenames()[0]) 153 | msg := fmt.Sprintf("open %s: no such file or directory", path) 154 | Expect(runner.Revert(item)).To(MatchError(msg)) 155 | }) 156 | }) 157 | 158 | Context("when the database is not available", func() { 159 | JustBeforeEach(func() { 160 | Expect(runner.DB.Close()).To(Succeed()) 161 | }) 162 | 163 | It("return an error", func() { 164 | err := runner.Revert(item) 165 | Expect(err).To(HaveOccurred()) 166 | Expect(err.Error()).To(ContainSubstring("sql: database is closed")) 167 | }) 168 | }) 169 | 170 | Context("when the sqlmigr step does not exist", func() { 171 | JustBeforeEach(func() { 172 | sqlmigr := &bytes.Buffer{} 173 | fmt.Fprintln(sqlmigr, "-- name: up") 174 | fmt.Fprintln(sqlmigr, "CREATE TABLE test(id TEXT)") 175 | 176 | path := filepath.Join(dir, item.Filenames()[0]) 177 | Expect(ioutil.WriteFile(path, sqlmigr.Bytes(), 0700)).To(Succeed()) 178 | }) 179 | 180 | It("return an error", func() { 181 | Expect(runner.Revert(item)).To(MatchError("routine 'down' not found for migration '20160102150_schema'")) 182 | }) 183 | }) 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /cmd/repository.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/phogolabs/cli" 8 | "github.com/phogolabs/log" 9 | "github.com/phogolabs/prana/sqlmodel" 10 | "github.com/phogolabs/prana/storage" 11 | ) 12 | 13 | // SQLRepository provides a subcommands to work generate repository from existing schema 14 | type SQLRepository struct { 15 | executor *sqlmodel.Executor 16 | } 17 | 18 | // CreateCommand creates a cli.Command that can be used by cli.App. 19 | func (m *SQLRepository) CreateCommand() *cli.Command { 20 | return &cli.Command{ 21 | Name: "repository", 22 | Usage: "A group of commands for generating database repository from schema", 23 | Description: "A group of commands for generating database repository from schema", 24 | Flags: []cli.Flag{ 25 | &cli.StringFlag{ 26 | Name: "package-dir, p", 27 | Usage: "path to the package, where the source code will be generated", 28 | Value: "./database", 29 | }, 30 | &cli.StringFlag{ 31 | Name: "model-package-dir", 32 | Usage: "path to the model's package", 33 | Value: "./database/model", 34 | }, 35 | }, 36 | Commands: []*cli.Command{ 37 | &cli.Command{ 38 | Name: "print", 39 | Usage: "Print the database repositories for given database schema or tables", 40 | Description: "Print the database repositories for given database schema or tables", 41 | Action: m.print, 42 | Before: m.before, 43 | After: m.after, 44 | Flags: m.flags(false), 45 | }, 46 | &cli.Command{ 47 | Name: "sync", 48 | Usage: "Generate a package of repositories for given database schema", 49 | Description: "Generate a package of repositories for given database schema", 50 | Action: m.sync, 51 | Before: m.before, 52 | After: m.after, 53 | Flags: m.flags(true), 54 | }, 55 | }, 56 | } 57 | } 58 | 59 | func (m *SQLRepository) flags(include bool) []cli.Flag { 60 | flags := []cli.Flag{ 61 | &cli.StringFlag{ 62 | Name: "schema-name, s", 63 | Usage: "name of the database schema", 64 | Value: "", 65 | }, 66 | &cli.StringSliceFlag{ 67 | Name: "table-name, t", 68 | Usage: "name of the table in the database", 69 | }, 70 | &cli.StringSliceFlag{ 71 | Name: "ignore-table-name, i", 72 | Usage: "name of the table in the database that should be skipped", 73 | Value: []string{"migrations"}, 74 | }, 75 | &cli.BoolFlag{ 76 | Name: "include-docs, d", 77 | Usage: "include API documentation in generated source code", 78 | Value: true, 79 | }, 80 | } 81 | 82 | if include { 83 | flag := &cli.BoolFlag{ 84 | Name: "include-tests", 85 | Usage: "include repository tests", 86 | Value: true, 87 | } 88 | 89 | flags = append(flags, flag) 90 | } 91 | 92 | return flags 93 | } 94 | 95 | func (m *SQLRepository) before(ctx *cli.Context) error { 96 | db, err := open(ctx) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | provider, err := provider(db) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | m.executor = &sqlmodel.Executor{ 107 | Provider: &sqlmodel.ModelProvider{ 108 | Config: &sqlmodel.ModelProviderConfig{ 109 | Package: filepath.Base(ctx.GlobalString("model-package-dir")), 110 | UseNamedParams: ctx.Bool("use-named-params"), 111 | InlcudeDoc: ctx.Bool("include-docs"), 112 | }, 113 | TagBuilder: &sqlmodel.NoopTagBuilder{}, 114 | Provider: provider, 115 | }, 116 | Generator: &sqlmodel.Codegen{ 117 | Meta: map[string]interface{}{ 118 | "RepositoryPackage": filepath.Base(ctx.GlobalString("package-dir")), 119 | }, 120 | Format: true, 121 | }, 122 | } 123 | 124 | return nil 125 | } 126 | 127 | func (m *SQLRepository) after(ctx *cli.Context) error { 128 | if m.executor != nil { 129 | if err := m.executor.Provider.Close(); err != nil { 130 | return cli.NewExitError(err.Error(), ErrCodeSchema) 131 | } 132 | } 133 | 134 | return nil 135 | } 136 | 137 | func (m *SQLRepository) print(ctx *cli.Context) error { 138 | for _, spec := range m.specs(ctx) { 139 | if err := m.executor.Write(os.Stdout, spec); err != nil { 140 | return cli.NewExitError(err.Error(), ErrCodeSchema) 141 | } 142 | } 143 | 144 | return nil 145 | } 146 | 147 | func (m *SQLRepository) sync(ctx *cli.Context) error { 148 | for _, spec := range m.specs(ctx) { 149 | path, err := m.executor.Create(spec) 150 | if err != nil { 151 | return cli.NewExitError(err.Error(), ErrCodeSchema) 152 | } 153 | 154 | if path != "" { 155 | log.Infof("Generated a database repository at: '%s'", path) 156 | } 157 | } 158 | 159 | return nil 160 | } 161 | 162 | func (m *SQLRepository) specs(ctx *cli.Context) []*sqlmodel.Spec { 163 | var ( 164 | specs = []*sqlmodel.Spec{} 165 | spec *sqlmodel.Spec 166 | ) 167 | 168 | spec = &sqlmodel.Spec{ 169 | Filename: "repository.go", 170 | Template: "repository", 171 | FileSystem: storage.New(ctx.GlobalString("package-dir")), 172 | Schema: ctx.String("schema-name"), 173 | Tables: ctx.StringSlice("table-name"), 174 | IgnoreTables: ctx.StringSlice("ignore-table-name"), 175 | } 176 | 177 | specs = append(specs, spec) 178 | 179 | if ctx.Bool("include-tests") { 180 | spec = &sqlmodel.Spec{ 181 | Filename: "repository_test.go", 182 | Template: "repository_test", 183 | FileSystem: spec.FileSystem, 184 | Schema: spec.Schema, 185 | Tables: spec.Tables, 186 | IgnoreTables: spec.IgnoreTables, 187 | } 188 | 189 | specs = append(specs, spec) 190 | 191 | spec = &sqlmodel.Spec{ 192 | Filename: "suite_test.go", 193 | Template: "suite_test", 194 | FileSystem: spec.FileSystem, 195 | Schema: spec.Schema, 196 | Tables: spec.Tables, 197 | IgnoreTables: spec.IgnoreTables, 198 | } 199 | 200 | specs = append(specs, spec) 201 | } 202 | 203 | return specs 204 | } 205 | -------------------------------------------------------------------------------- /cmd/model.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/phogolabs/cli" 10 | "github.com/phogolabs/log" 11 | "github.com/phogolabs/prana/sqlmodel" 12 | "github.com/phogolabs/prana/storage" 13 | ) 14 | 15 | // SQLModel provides a subcommands to work generate structs from existing schema 16 | type SQLModel struct { 17 | executor *sqlmodel.Executor 18 | } 19 | 20 | // CreateCommand creates a cli.Command that can be used by cli.App. 21 | func (m *SQLModel) CreateCommand() *cli.Command { 22 | return &cli.Command{ 23 | Name: "model", 24 | Usage: "A group of commands for generating object model from database schema", 25 | Description: "A group of commands for generating object model from database schema", 26 | Flags: []cli.Flag{ 27 | &cli.StringFlag{ 28 | Name: "package-dir, p", 29 | Usage: "path to the package, where the source code will be generated", 30 | Value: "./database/model", 31 | }, 32 | }, 33 | Commands: []*cli.Command{ 34 | &cli.Command{ 35 | Name: "print", 36 | Usage: "Print the object model for given database schema or tables", 37 | Description: "Print the object model for given database schema or tables", 38 | Action: m.print, 39 | Before: m.before, 40 | After: m.after, 41 | Flags: m.flags(), 42 | }, 43 | &cli.Command{ 44 | Name: "sync", 45 | Usage: "Generate a package of models for given database schema", 46 | Description: "Generate a package of models for given database schema", 47 | Action: m.sync, 48 | Before: m.before, 49 | After: m.after, 50 | Flags: m.flags(), 51 | }, 52 | }, 53 | } 54 | } 55 | 56 | func (m *SQLModel) flags() []cli.Flag { 57 | return []cli.Flag{ 58 | &cli.StringFlag{ 59 | Name: "schema-name, s", 60 | Usage: "name of the database schema", 61 | Value: "", 62 | }, 63 | &cli.StringSliceFlag{ 64 | Name: "table-name, t", 65 | Usage: "name of the table in the database", 66 | }, 67 | &cli.StringSliceFlag{ 68 | Name: "ignore-table-name, i", 69 | Usage: "name of the table in the database that should be skipped", 70 | Value: []string{"migrations"}, 71 | }, 72 | &cli.StringFlag{ 73 | Name: "orm-tag, m", 74 | Usage: "tag tag that is wellknow for some ORM packages. supported: (sqlx, gorm)", 75 | Value: "sqlx", 76 | }, 77 | &cli.StringSliceFlag{ 78 | Name: "extra-tag, e", 79 | Usage: "extra tags that should be included in model fields. supported: (json, xml, validate)", 80 | }, 81 | &cli.BoolFlag{ 82 | Name: "include-docs, d", 83 | Usage: "include API documentation in generated source code", 84 | Value: true, 85 | }, 86 | } 87 | } 88 | 89 | func (m *SQLModel) before(ctx *cli.Context) error { 90 | db, err := open(ctx) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | provider, err := provider(db) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | builder, err := m.builder(ctx) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | m.executor = &sqlmodel.Executor{ 106 | Provider: &sqlmodel.ModelProvider{ 107 | Config: &sqlmodel.ModelProviderConfig{ 108 | Package: filepath.Base(ctx.GlobalString("package-dir")), 109 | UseNamedParams: ctx.Bool("use-named-params"), 110 | InlcudeDoc: ctx.Bool("include-docs"), 111 | }, 112 | TagBuilder: builder, 113 | Provider: provider, 114 | }, 115 | Generator: &sqlmodel.Codegen{ 116 | Format: true, 117 | }, 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (m *SQLModel) builder(ctx *cli.Context) (sqlmodel.TagBuilder, error) { 124 | registered := make(map[string]struct{}) 125 | builder := sqlmodel.CompositeTagBuilder{} 126 | 127 | tags := []string{} 128 | tags = append(tags, ctx.String("orm-tag")) 129 | tags = append(tags, ctx.StringSlice("extra-tag")...) 130 | 131 | for _, tag := range tags { 132 | if _, ok := registered[tag]; ok { 133 | continue 134 | } 135 | 136 | registered[tag] = struct{}{} 137 | 138 | switch strings.ToLower(tag) { 139 | case "sqlx": 140 | builder = append(builder, sqlmodel.SQLXTagBuilder{}) 141 | case "gorm": 142 | builder = append(builder, sqlmodel.GORMTagBuilder{}) 143 | case "json": 144 | builder = append(builder, sqlmodel.JSONTagBuilder{}) 145 | case "xml": 146 | builder = append(builder, sqlmodel.XMLTagBuilder{}) 147 | case "validate": 148 | builder = append(builder, sqlmodel.ValidateTagBuilder{}) 149 | default: 150 | err := fmt.Errorf("Cannot find tag builder for '%s'", tag) 151 | return nil, cli.NewExitError(err.Error(), ErrCodeArg) 152 | } 153 | } 154 | 155 | return builder, nil 156 | } 157 | 158 | func (m *SQLModel) after(ctx *cli.Context) error { 159 | if m.executor != nil { 160 | if err := m.executor.Provider.Close(); err != nil { 161 | return cli.NewExitError(err.Error(), ErrCodeSchema) 162 | } 163 | } 164 | return nil 165 | } 166 | 167 | func (m *SQLModel) print(ctx *cli.Context) error { 168 | if err := m.executor.Write(os.Stdout, m.spec(ctx)); err != nil { 169 | return cli.NewExitError(err.Error(), ErrCodeSchema) 170 | } 171 | 172 | return nil 173 | } 174 | 175 | func (m *SQLModel) sync(ctx *cli.Context) error { 176 | path, err := m.executor.Create(m.spec(ctx)) 177 | if err != nil { 178 | return cli.NewExitError(err.Error(), ErrCodeSchema) 179 | } 180 | 181 | if path != "" { 182 | log.Infof("Generated a database model at: '%s'", path) 183 | } 184 | 185 | return nil 186 | } 187 | 188 | func (m *SQLModel) spec(ctx *cli.Context) *sqlmodel.Spec { 189 | spec := &sqlmodel.Spec{ 190 | Filename: "schema.go", 191 | Template: "model", 192 | FileSystem: storage.New(ctx.GlobalString("package-dir")), 193 | Schema: ctx.String("schema-name"), 194 | Tables: ctx.StringSlice("table-name"), 195 | IgnoreTables: ctx.StringSlice("ignore-table-name"), 196 | } 197 | 198 | return spec 199 | } 200 | -------------------------------------------------------------------------------- /cmd/migration.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/jmoiron/sqlx" 10 | "github.com/phogolabs/cli" 11 | "github.com/phogolabs/log" 12 | "github.com/phogolabs/prana/sqlmigr" 13 | "github.com/phogolabs/prana/storage" 14 | ) 15 | 16 | // SQLMigration provides a subcommands to work with SQL migrations. 17 | type SQLMigration struct { 18 | executor *sqlmigr.Executor 19 | db *sqlx.DB 20 | dir string 21 | } 22 | 23 | // CreateCommand creates a cli.Command that can be used by cli.App. 24 | func (m *SQLMigration) CreateCommand() *cli.Command { 25 | return &cli.Command{ 26 | Name: "migration", 27 | Usage: "A group of commands for generating, running, and reverting migrations", 28 | Description: "A group of commands for generating, running, and reverting migrations", 29 | Before: m.before, 30 | After: m.after, 31 | Flags: []cli.Flag{ 32 | &cli.StringFlag{ 33 | Name: "migration-dir, d", 34 | Usage: "path to the directory that contain the migrations", 35 | EnvVar: "PRANA_MIGRATION_DIR", 36 | Value: "./database/migration", 37 | Required: true, 38 | }, 39 | }, 40 | Commands: []*cli.Command{ 41 | { 42 | Name: "setup", 43 | Usage: "Setup the migration for the current project", 44 | Description: "Configure the current project by creating database directory hierarchy and initial migration", 45 | Action: m.setup, 46 | }, 47 | { 48 | Name: "create", 49 | Usage: "Generate a new migration with the given name, and the current timestamp as the version", 50 | Description: "Create a new migration file for the given name, and the current timestamp as the version in database/migration directory", 51 | ArgsUsage: "[name]", 52 | Action: m.create, 53 | }, 54 | { 55 | Name: "run", 56 | Usage: "Run the pending migrations", 57 | Action: m.run, 58 | Flags: []cli.Flag{ 59 | &cli.IntFlag{ 60 | Name: "count, c", 61 | Usage: "Number of migrations to be executed. Negative number will run all", 62 | Value: -1, 63 | }, 64 | }, 65 | }, 66 | { 67 | Name: "revert", 68 | Usage: "Revert the latest applied migrations", 69 | Action: m.revert, 70 | Flags: []cli.Flag{ 71 | &cli.IntFlag{ 72 | Name: "count, c", 73 | Usage: "Number of migrations to be reverted. Negative number will revert all", 74 | Value: -1, 75 | }, 76 | }, 77 | }, 78 | { 79 | Name: "reset", 80 | Usage: "Revert and re-run all migrations", 81 | Action: m.reset, 82 | }, 83 | { 84 | Name: "status", 85 | Usage: "Show all migrations, marking those that have been applied", 86 | Action: m.status, 87 | }, 88 | }, 89 | } 90 | } 91 | 92 | func (m *SQLMigration) before(ctx *cli.Context) (err error) { 93 | m.db, err = open(ctx) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | m.dir, err = filepath.Abs(ctx.String("migration-dir")) 99 | if err != nil { 100 | return cli.WrapError(err).WithCode(ErrCodeArg) 101 | } 102 | 103 | storage := storage.New(m.dir) 104 | // executer setup 105 | m.executor = &sqlmigr.Executor{ 106 | Logger: log.WithField("command", ctx.Command.Name), 107 | Provider: &sqlmigr.Provider{ 108 | FileSystem: storage, 109 | DB: m.db, 110 | }, 111 | Runner: &sqlmigr.Runner{ 112 | FileSystem: storage, 113 | DB: m.db, 114 | }, 115 | Generator: &sqlmigr.Generator{ 116 | FileSystem: storage, 117 | }, 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (m *SQLMigration) after(ctx *cli.Context) error { 124 | if m.db != nil { 125 | if err := m.db.Close(); err != nil { 126 | return cli.NewExitError(err.Error(), ErrCodeMigration) 127 | } 128 | } 129 | 130 | return nil 131 | } 132 | 133 | func (m *SQLMigration) setup(ctx *cli.Context) error { 134 | if err := m.executor.Setup(); err != nil { 135 | if os.IsExist(err) { 136 | return nil 137 | } 138 | 139 | return cli.NewExitError(err.Error(), ErrCodeMigration) 140 | } 141 | 142 | log.Infof("Setup project directory at: '%s'", m.dir) 143 | return nil 144 | } 145 | 146 | func (m *SQLMigration) create(ctx *cli.Context) error { 147 | args := ctx.Args 148 | 149 | if len(args) != 1 { 150 | return cli.NewExitError("Create command expects a single argument", ErrCodeMigration) 151 | } 152 | 153 | item, err := m.executor.Create(args[0]) 154 | if err != nil { 155 | return cli.NewExitError(err.Error(), ErrCodeMigration) 156 | } 157 | 158 | log.Infof("Created migration at: '%s'", filepath.Join(m.dir, item.Filenames()[0])) 159 | return nil 160 | } 161 | 162 | func (m *SQLMigration) run(ctx *cli.Context) error { 163 | count := ctx.Int("count") 164 | 165 | _, err := m.executor.Run(count) 166 | if err != nil { 167 | err = m.errf(err) 168 | return cli.NewExitError(err.Error(), ErrCodeMigration) 169 | } 170 | 171 | return nil 172 | } 173 | 174 | func (m *SQLMigration) revert(ctx *cli.Context) error { 175 | count := ctx.Int("count") 176 | 177 | _, err := m.executor.Revert(count) 178 | if err != nil { 179 | err = m.errf(err) 180 | return cli.NewExitError(err.Error(), ErrCodeMigration) 181 | } 182 | 183 | return nil 184 | } 185 | 186 | func (m *SQLMigration) reset(ctx *cli.Context) error { 187 | _, err := m.executor.RevertAll() 188 | if err != nil { 189 | err = m.errf(err) 190 | return cli.NewExitError(err.Error(), ErrCodeMigration) 191 | } 192 | 193 | _, err = m.executor.RunAll() 194 | if err != nil { 195 | err = m.errf(err) 196 | return cli.NewExitError(err.Error(), ErrCodeMigration) 197 | } 198 | 199 | return nil 200 | } 201 | 202 | func (m *SQLMigration) status(ctx *cli.Context) error { 203 | migrations, err := m.executor.Migrations() 204 | if err != nil { 205 | return err 206 | } 207 | 208 | if strings.EqualFold("json", ctx.GlobalString("log-format")) { 209 | logger := log.WithField("command", ctx.Command.Name) 210 | sqlmigr.Flog(logger, migrations) 211 | return nil 212 | } 213 | 214 | sqlmigr.Ftable(os.Stdout, migrations) 215 | return nil 216 | } 217 | 218 | func (m *SQLMigration) errf(err error) error { 219 | if os.IsNotExist(err) { 220 | err = fmt.Errorf("Directory '%s' does not exist", m.dir) 221 | } 222 | return err 223 | } 224 | -------------------------------------------------------------------------------- /cmd/routine.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/phogolabs/cli" 8 | "github.com/phogolabs/log" 9 | "github.com/phogolabs/prana/sqlexec" 10 | "github.com/phogolabs/prana/sqlmodel" 11 | "github.com/phogolabs/prana/storage" 12 | ) 13 | 14 | // SQLRoutine provides a subcommands to work with SQL scripts and their 15 | // statements. 16 | type SQLRoutine struct { 17 | runner *sqlexec.Runner 18 | executor *sqlmodel.Executor 19 | } 20 | 21 | // CreateCommand creates a cli.Command that can be used by cli.App. 22 | func (m *SQLRoutine) CreateCommand() *cli.Command { 23 | return &cli.Command{ 24 | Name: "routine", 25 | Usage: "A group of commands for generating, running, and removing SQL commands", 26 | Description: "A group of commands for generating, running, and removing SQL commands", 27 | Flags: []cli.Flag{ 28 | &cli.StringFlag{ 29 | Name: "routine-dir, d", 30 | Usage: "path to the directory that contain the SQL routines", 31 | EnvVar: "PRANA_ROUTINE_DIR", 32 | Value: "./database/routine", 33 | }, 34 | }, 35 | Commands: []*cli.Command{ 36 | &cli.Command{ 37 | Name: "sync", 38 | Usage: "Generate a SQL script of CRUD operations for given database schema", 39 | Description: "Generate a SQL script of CRUD operations for given database schema", 40 | Action: m.sync, 41 | Before: m.before, 42 | After: m.after, 43 | Flags: m.flags(), 44 | }, 45 | &cli.Command{ 46 | Name: "print", 47 | Usage: "Print a SQL script of CRUD operations for given database schema", 48 | Description: "Print a SQL script of CRUD operations for given database schema", 49 | Action: m.print, 50 | Before: m.before, 51 | After: m.after, 52 | Flags: m.flags(), 53 | }, 54 | &cli.Command{ 55 | Name: "create", 56 | Usage: "Create a new SQL command for given container filename", 57 | Description: "Create a new SQL command for given container filename", 58 | ArgsUsage: "[name]", 59 | Action: m.create, 60 | Before: m.before, 61 | After: m.after, 62 | Flags: []cli.Flag{ 63 | &cli.StringFlag{ 64 | Name: "filename, n", 65 | Usage: "Name of the file that contains the command", 66 | Value: "", 67 | }, 68 | }, 69 | }, 70 | &cli.Command{ 71 | Name: "run", 72 | Usage: "Run a SQL command for given arguments", 73 | Description: "Run a SQL command for given arguments", 74 | ArgsUsage: "[name]", 75 | Action: m.run, 76 | Before: m.before, 77 | After: m.after, 78 | Flags: []cli.Flag{ 79 | &cli.StringSliceFlag{ 80 | Name: "param, p", 81 | Usage: "Parameters for the command", 82 | }, 83 | }, 84 | }, 85 | }, 86 | } 87 | } 88 | 89 | func (m *SQLRoutine) flags() []cli.Flag { 90 | return []cli.Flag{ 91 | &cli.StringFlag{ 92 | Name: "schema-name, s", 93 | Usage: "name of the database schema", 94 | }, 95 | &cli.StringSliceFlag{ 96 | Name: "table-name, t", 97 | Usage: "name of the table in the database", 98 | }, 99 | &cli.StringSliceFlag{ 100 | Name: "ignore-table-name, i", 101 | Usage: "name of the table in the database that should be skipped", 102 | Value: []string{"migrations"}, 103 | }, 104 | &cli.BoolFlag{ 105 | Name: "use-named-params, n", 106 | Usage: "use named parameter instead of questionmark", 107 | Value: true, 108 | }, 109 | &cli.BoolFlag{ 110 | Name: "include-docs, d", 111 | Usage: "include API documentation in generated source code", 112 | Value: true, 113 | }, 114 | } 115 | } 116 | 117 | func (m *SQLRoutine) before(ctx *cli.Context) error { 118 | dir, err := filepath.Abs(ctx.GlobalString("routine-dir")) 119 | if err != nil { 120 | return cli.NewExitError(err.Error(), ErrCodeArg) 121 | } 122 | 123 | db, err := open(ctx) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | provider, err := provider(db) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | m.runner = &sqlexec.Runner{ 134 | FileSystem: storage.New(dir), 135 | DB: db, 136 | } 137 | 138 | m.executor = &sqlmodel.Executor{ 139 | Provider: &sqlmodel.ModelProvider{ 140 | Config: &sqlmodel.ModelProviderConfig{ 141 | Package: filepath.Base(ctx.GlobalString("routine-dir")), 142 | UseNamedParams: ctx.Bool("use-named-params"), 143 | InlcudeDoc: ctx.Bool("include-docs"), 144 | }, 145 | TagBuilder: &sqlmodel.NoopTagBuilder{}, 146 | Provider: provider, 147 | }, 148 | Generator: &sqlmodel.Codegen{ 149 | Format: false, 150 | }, 151 | } 152 | 153 | return nil 154 | } 155 | 156 | func (m *SQLRoutine) create(ctx *cli.Context) error { 157 | args := ctx.Args 158 | 159 | if len(args) != 1 { 160 | return cli.NewExitError("Create command expects a single argument", ErrCodeCommand) 161 | } 162 | 163 | generator := &sqlexec.Generator{ 164 | FileSystem: m.runner.FileSystem.(*storage.FileSystem), 165 | } 166 | 167 | name, path, err := generator.Create(ctx.String("filename"), args[0]) 168 | if err != nil { 169 | return cli.NewExitError(err.Error(), ErrCodeCommand) 170 | } 171 | 172 | dir, err := filepath.Abs(ctx.GlobalString("routine-dir")) 173 | if err != nil { 174 | return cli.NewExitError(err.Error(), ErrCodeArg) 175 | } 176 | 177 | log.Infof("Created command '%s' at '%s'", name, filepath.Join(dir, path)) 178 | return nil 179 | } 180 | 181 | func (m *SQLRoutine) run(ctx *cli.Context) error { 182 | args := ctx.Args 183 | params := params(ctx.StringSlice("param")) 184 | 185 | if len(args) != 1 { 186 | return cli.NewExitError("Run command expects a single argument", ErrCodeCommand) 187 | } 188 | 189 | name := args[0] 190 | log.Infof("Running command '%s' from '%v'", name, m.runner.FileSystem) 191 | 192 | rows, err := m.runner.Run(name, params...) 193 | if err != nil { 194 | return cli.NewExitError(err.Error(), ErrCodeCommand) 195 | } 196 | 197 | if err := m.runner.Print(os.Stdout, rows); err != nil { 198 | return cli.NewExitError(err.Error(), ErrCodeCommand) 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func (m *SQLRoutine) after(ctx *cli.Context) error { 205 | if m.executor != nil { 206 | if err := m.executor.Provider.Close(); err != nil { 207 | return cli.NewExitError(err.Error(), ErrCodeSchema) 208 | } 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func (m *SQLRoutine) print(ctx *cli.Context) error { 215 | if err := m.executor.Write(os.Stdout, m.spec(ctx)); err != nil { 216 | return cli.NewExitError(err.Error(), ErrCodeSchema) 217 | } 218 | 219 | return nil 220 | } 221 | 222 | func (m *SQLRoutine) sync(ctx *cli.Context) error { 223 | path, err := m.executor.Create(m.spec(ctx)) 224 | if err != nil { 225 | return cli.NewExitError(err.Error(), ErrCodeSchema) 226 | } 227 | 228 | if path != "" { 229 | log.Infof("Generated a database model at: '%s'", path) 230 | } 231 | 232 | return nil 233 | } 234 | 235 | func (m *SQLRoutine) spec(ctx *cli.Context) *sqlmodel.Spec { 236 | spec := &sqlmodel.Spec{ 237 | Filename: "routine.sql", 238 | Template: "routine", 239 | FileSystem: storage.New(ctx.GlobalString("routine-dir")), 240 | Schema: ctx.String("schema-name"), 241 | Tables: ctx.StringSlice("table-name"), 242 | IgnoreTables: ctx.StringSlice("ignore-table-name"), 243 | } 244 | 245 | return spec 246 | } 247 | 248 | func params(args []string) []interface{} { 249 | result := []interface{}{} 250 | for _, arg := range args { 251 | result = append(result, arg) 252 | } 253 | return result 254 | } 255 | -------------------------------------------------------------------------------- /fake/Querier.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fake 3 | 4 | import ( 5 | "database/sql" 6 | "sync" 7 | 8 | "github.com/phogolabs/prana/sqlmodel" 9 | ) 10 | 11 | type Querier struct { 12 | CloseStub func() error 13 | closeMutex sync.RWMutex 14 | closeArgsForCall []struct { 15 | } 16 | closeReturns struct { 17 | result1 error 18 | } 19 | closeReturnsOnCall map[int]struct { 20 | result1 error 21 | } 22 | QueryStub func(string, ...interface{}) (*sql.Rows, error) 23 | queryMutex sync.RWMutex 24 | queryArgsForCall []struct { 25 | arg1 string 26 | arg2 []interface{} 27 | } 28 | queryReturns struct { 29 | result1 *sql.Rows 30 | result2 error 31 | } 32 | queryReturnsOnCall map[int]struct { 33 | result1 *sql.Rows 34 | result2 error 35 | } 36 | QueryRowStub func(string, ...interface{}) *sql.Row 37 | queryRowMutex sync.RWMutex 38 | queryRowArgsForCall []struct { 39 | arg1 string 40 | arg2 []interface{} 41 | } 42 | queryRowReturns struct { 43 | result1 *sql.Row 44 | } 45 | queryRowReturnsOnCall map[int]struct { 46 | result1 *sql.Row 47 | } 48 | invocations map[string][][]interface{} 49 | invocationsMutex sync.RWMutex 50 | } 51 | 52 | func (fake *Querier) Close() error { 53 | fake.closeMutex.Lock() 54 | ret, specificReturn := fake.closeReturnsOnCall[len(fake.closeArgsForCall)] 55 | fake.closeArgsForCall = append(fake.closeArgsForCall, struct { 56 | }{}) 57 | fake.recordInvocation("Close", []interface{}{}) 58 | fake.closeMutex.Unlock() 59 | if fake.CloseStub != nil { 60 | return fake.CloseStub() 61 | } 62 | if specificReturn { 63 | return ret.result1 64 | } 65 | fakeReturns := fake.closeReturns 66 | return fakeReturns.result1 67 | } 68 | 69 | func (fake *Querier) CloseCallCount() int { 70 | fake.closeMutex.RLock() 71 | defer fake.closeMutex.RUnlock() 72 | return len(fake.closeArgsForCall) 73 | } 74 | 75 | func (fake *Querier) CloseCalls(stub func() error) { 76 | fake.closeMutex.Lock() 77 | defer fake.closeMutex.Unlock() 78 | fake.CloseStub = stub 79 | } 80 | 81 | func (fake *Querier) CloseReturns(result1 error) { 82 | fake.closeMutex.Lock() 83 | defer fake.closeMutex.Unlock() 84 | fake.CloseStub = nil 85 | fake.closeReturns = struct { 86 | result1 error 87 | }{result1} 88 | } 89 | 90 | func (fake *Querier) CloseReturnsOnCall(i int, result1 error) { 91 | fake.closeMutex.Lock() 92 | defer fake.closeMutex.Unlock() 93 | fake.CloseStub = nil 94 | if fake.closeReturnsOnCall == nil { 95 | fake.closeReturnsOnCall = make(map[int]struct { 96 | result1 error 97 | }) 98 | } 99 | fake.closeReturnsOnCall[i] = struct { 100 | result1 error 101 | }{result1} 102 | } 103 | 104 | func (fake *Querier) Query(arg1 string, arg2 ...interface{}) (*sql.Rows, error) { 105 | fake.queryMutex.Lock() 106 | ret, specificReturn := fake.queryReturnsOnCall[len(fake.queryArgsForCall)] 107 | fake.queryArgsForCall = append(fake.queryArgsForCall, struct { 108 | arg1 string 109 | arg2 []interface{} 110 | }{arg1, arg2}) 111 | fake.recordInvocation("Query", []interface{}{arg1, arg2}) 112 | fake.queryMutex.Unlock() 113 | if fake.QueryStub != nil { 114 | return fake.QueryStub(arg1, arg2...) 115 | } 116 | if specificReturn { 117 | return ret.result1, ret.result2 118 | } 119 | fakeReturns := fake.queryReturns 120 | return fakeReturns.result1, fakeReturns.result2 121 | } 122 | 123 | func (fake *Querier) QueryCallCount() int { 124 | fake.queryMutex.RLock() 125 | defer fake.queryMutex.RUnlock() 126 | return len(fake.queryArgsForCall) 127 | } 128 | 129 | func (fake *Querier) QueryCalls(stub func(string, ...interface{}) (*sql.Rows, error)) { 130 | fake.queryMutex.Lock() 131 | defer fake.queryMutex.Unlock() 132 | fake.QueryStub = stub 133 | } 134 | 135 | func (fake *Querier) QueryArgsForCall(i int) (string, []interface{}) { 136 | fake.queryMutex.RLock() 137 | defer fake.queryMutex.RUnlock() 138 | argsForCall := fake.queryArgsForCall[i] 139 | return argsForCall.arg1, argsForCall.arg2 140 | } 141 | 142 | func (fake *Querier) QueryReturns(result1 *sql.Rows, result2 error) { 143 | fake.queryMutex.Lock() 144 | defer fake.queryMutex.Unlock() 145 | fake.QueryStub = nil 146 | fake.queryReturns = struct { 147 | result1 *sql.Rows 148 | result2 error 149 | }{result1, result2} 150 | } 151 | 152 | func (fake *Querier) QueryReturnsOnCall(i int, result1 *sql.Rows, result2 error) { 153 | fake.queryMutex.Lock() 154 | defer fake.queryMutex.Unlock() 155 | fake.QueryStub = nil 156 | if fake.queryReturnsOnCall == nil { 157 | fake.queryReturnsOnCall = make(map[int]struct { 158 | result1 *sql.Rows 159 | result2 error 160 | }) 161 | } 162 | fake.queryReturnsOnCall[i] = struct { 163 | result1 *sql.Rows 164 | result2 error 165 | }{result1, result2} 166 | } 167 | 168 | func (fake *Querier) QueryRow(arg1 string, arg2 ...interface{}) *sql.Row { 169 | fake.queryRowMutex.Lock() 170 | ret, specificReturn := fake.queryRowReturnsOnCall[len(fake.queryRowArgsForCall)] 171 | fake.queryRowArgsForCall = append(fake.queryRowArgsForCall, struct { 172 | arg1 string 173 | arg2 []interface{} 174 | }{arg1, arg2}) 175 | fake.recordInvocation("QueryRow", []interface{}{arg1, arg2}) 176 | fake.queryRowMutex.Unlock() 177 | if fake.QueryRowStub != nil { 178 | return fake.QueryRowStub(arg1, arg2...) 179 | } 180 | if specificReturn { 181 | return ret.result1 182 | } 183 | fakeReturns := fake.queryRowReturns 184 | return fakeReturns.result1 185 | } 186 | 187 | func (fake *Querier) QueryRowCallCount() int { 188 | fake.queryRowMutex.RLock() 189 | defer fake.queryRowMutex.RUnlock() 190 | return len(fake.queryRowArgsForCall) 191 | } 192 | 193 | func (fake *Querier) QueryRowCalls(stub func(string, ...interface{}) *sql.Row) { 194 | fake.queryRowMutex.Lock() 195 | defer fake.queryRowMutex.Unlock() 196 | fake.QueryRowStub = stub 197 | } 198 | 199 | func (fake *Querier) QueryRowArgsForCall(i int) (string, []interface{}) { 200 | fake.queryRowMutex.RLock() 201 | defer fake.queryRowMutex.RUnlock() 202 | argsForCall := fake.queryRowArgsForCall[i] 203 | return argsForCall.arg1, argsForCall.arg2 204 | } 205 | 206 | func (fake *Querier) QueryRowReturns(result1 *sql.Row) { 207 | fake.queryRowMutex.Lock() 208 | defer fake.queryRowMutex.Unlock() 209 | fake.QueryRowStub = nil 210 | fake.queryRowReturns = struct { 211 | result1 *sql.Row 212 | }{result1} 213 | } 214 | 215 | func (fake *Querier) QueryRowReturnsOnCall(i int, result1 *sql.Row) { 216 | fake.queryRowMutex.Lock() 217 | defer fake.queryRowMutex.Unlock() 218 | fake.QueryRowStub = nil 219 | if fake.queryRowReturnsOnCall == nil { 220 | fake.queryRowReturnsOnCall = make(map[int]struct { 221 | result1 *sql.Row 222 | }) 223 | } 224 | fake.queryRowReturnsOnCall[i] = struct { 225 | result1 *sql.Row 226 | }{result1} 227 | } 228 | 229 | func (fake *Querier) Invocations() map[string][][]interface{} { 230 | fake.invocationsMutex.RLock() 231 | defer fake.invocationsMutex.RUnlock() 232 | fake.closeMutex.RLock() 233 | defer fake.closeMutex.RUnlock() 234 | fake.queryMutex.RLock() 235 | defer fake.queryMutex.RUnlock() 236 | fake.queryRowMutex.RLock() 237 | defer fake.queryRowMutex.RUnlock() 238 | copiedInvocations := map[string][][]interface{}{} 239 | for key, value := range fake.invocations { 240 | copiedInvocations[key] = value 241 | } 242 | return copiedInvocations 243 | } 244 | 245 | func (fake *Querier) recordInvocation(key string, args []interface{}) { 246 | fake.invocationsMutex.Lock() 247 | defer fake.invocationsMutex.Unlock() 248 | if fake.invocations == nil { 249 | fake.invocations = map[string][][]interface{}{} 250 | } 251 | if fake.invocations[key] == nil { 252 | fake.invocations[key] = [][]interface{}{} 253 | } 254 | fake.invocations[key] = append(fake.invocations[key], args) 255 | } 256 | 257 | var _ sqlmodel.Querier = new(Querier) 258 | -------------------------------------------------------------------------------- /sqlmodel/builder_test.go: -------------------------------------------------------------------------------- 1 | package sqlmodel_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "github.com/phogolabs/prana/fake" 8 | "github.com/phogolabs/prana/sqlmodel" 9 | ) 10 | 11 | var _ = Describe("CompositeTagBuilder", func() { 12 | It("delegate the build operation to underlying builders", func() { 13 | builder := sqlmodel.CompositeTagBuilder{} 14 | 15 | builder1 := &fake.ModelTagBuilder{} 16 | builder1.BuildReturns("tag1") 17 | builder = append(builder, builder1) 18 | 19 | builder2 := &fake.ModelTagBuilder{} 20 | builder2.BuildReturns("tag2") 21 | builder = append(builder, builder2) 22 | 23 | column := &sqlmodel.Column{} 24 | Expect(builder.Build(column)).To(Equal("`tag1 tag2`")) 25 | 26 | Expect(builder1.BuildCallCount()).To(Equal(1)) 27 | Expect(builder1.BuildArgsForCall(0)).To(Equal(column)) 28 | 29 | Expect(builder2.BuildCallCount()).To(Equal(1)) 30 | Expect(builder2.BuildArgsForCall(0)).To(Equal(column)) 31 | }) 32 | 33 | Context("when some of the builders return an space string", func() { 34 | It("skips the result", func() { 35 | builder := sqlmodel.CompositeTagBuilder{} 36 | 37 | builder1 := &fake.ModelTagBuilder{} 38 | builder1.BuildReturns(" ") 39 | builder = append(builder, builder1) 40 | 41 | builder2 := &fake.ModelTagBuilder{} 42 | builder2.BuildReturns(" tag2") 43 | builder = append(builder, builder2) 44 | 45 | column := &sqlmodel.Column{} 46 | Expect(builder.Build(column)).To(Equal("`tag2`")) 47 | 48 | Expect(builder1.BuildCallCount()).To(Equal(1)) 49 | Expect(builder1.BuildArgsForCall(0)).To(Equal(column)) 50 | 51 | Expect(builder2.BuildCallCount()).To(Equal(1)) 52 | Expect(builder2.BuildArgsForCall(0)).To(Equal(column)) 53 | }) 54 | }) 55 | }) 56 | 57 | var _ = Describe("SQLXTagBuilder", func() { 58 | var ( 59 | column *sqlmodel.Column 60 | builder *sqlmodel.SQLXTagBuilder 61 | ) 62 | 63 | BeforeEach(func() { 64 | builder = &sqlmodel.SQLXTagBuilder{} 65 | column = &sqlmodel.Column{ 66 | Name: "id", 67 | Type: sqlmodel.ColumnType{}, 68 | } 69 | }) 70 | 71 | It("builds the tag correctly", func() { 72 | Expect(builder.Build(column)).To(Equal("db:\"id,not_null\"")) 73 | }) 74 | 75 | Context("when the column is primary key", func() { 76 | BeforeEach(func() { 77 | column.Type.IsPrimaryKey = true 78 | }) 79 | 80 | It("builds the tag correctly", func() { 81 | Expect(builder.Build(column)).To(Equal("db:\"id,primary_key,not_null\"")) 82 | }) 83 | }) 84 | 85 | Context("when the column allow null", func() { 86 | BeforeEach(func() { 87 | column.Type.IsNullable = true 88 | }) 89 | 90 | It("builds the tag correctly", func() { 91 | Expect(builder.Build(column)).To(Equal("db:\"id,null\"")) 92 | }) 93 | }) 94 | 95 | Context("when the column has char size", func() { 96 | BeforeEach(func() { 97 | column.Type.CharMaxLength = 200 98 | }) 99 | 100 | It("builds the tag correctly", func() { 101 | Expect(builder.Build(column)).To(Equal("db:\"id,not_null,size=200\"")) 102 | }) 103 | }) 104 | 105 | Context("when all options are presented", func() { 106 | BeforeEach(func() { 107 | column.Type.IsPrimaryKey = true 108 | column.Type.CharMaxLength = 200 109 | }) 110 | It("builds the tag correctly", func() { 111 | Expect(builder.Build(column)).To(Equal("db:\"id,primary_key,not_null,size=200\"")) 112 | }) 113 | }) 114 | }) 115 | 116 | var _ = Describe("GORMTagBuilder", func() { 117 | var ( 118 | column *sqlmodel.Column 119 | builder *sqlmodel.GORMTagBuilder 120 | ) 121 | 122 | BeforeEach(func() { 123 | builder = &sqlmodel.GORMTagBuilder{} 124 | column = &sqlmodel.Column{ 125 | Name: "id", 126 | Type: sqlmodel.ColumnType{ 127 | Name: "db_type", 128 | }, 129 | } 130 | }) 131 | 132 | It("builds the tag correctly", func() { 133 | Expect(builder.Build(column)).To(Equal("gorm:\"column:id;type:db_type;not null\"")) 134 | }) 135 | 136 | Context("when the column is primary key", func() { 137 | BeforeEach(func() { 138 | column.Type.IsPrimaryKey = true 139 | }) 140 | 141 | It("builds the tag correctly", func() { 142 | Expect(builder.Build(column)).To(Equal("gorm:\"column:id;type:db_type;primary_key;not null\"")) 143 | }) 144 | }) 145 | 146 | Context("when the column allow null", func() { 147 | BeforeEach(func() { 148 | column.Type.IsNullable = true 149 | }) 150 | 151 | It("builds the tag correctly", func() { 152 | Expect(builder.Build(column)).To(Equal("gorm:\"column:id;type:db_type;null\"")) 153 | }) 154 | }) 155 | 156 | Context("when the column has char size", func() { 157 | BeforeEach(func() { 158 | column.Type.CharMaxLength = 200 159 | }) 160 | 161 | It("builds the tag correctly", func() { 162 | Expect(builder.Build(column)).To(Equal("gorm:\"column:id;type:db_type(200);not null;size:200\"")) 163 | }) 164 | }) 165 | 166 | Context("when the column has precision", func() { 167 | BeforeEach(func() { 168 | column.Type.Precision = 10 169 | column.Type.PrecisionScale = 20 170 | }) 171 | 172 | It("builds the tag correctly", func() { 173 | Expect(builder.Build(column)).To(Equal("gorm:\"column:id;type:db_type(10, 20);not null;precision:10\"")) 174 | }) 175 | }) 176 | 177 | Context("when all options are presented", func() { 178 | BeforeEach(func() { 179 | column.Type.IsPrimaryKey = true 180 | column.Type.CharMaxLength = 200 181 | }) 182 | It("builds the tag correctly", func() { 183 | Expect(builder.Build(column)).To(Equal("gorm:\"column:id;type:db_type(200);primary_key;not null;size:200\"")) 184 | }) 185 | }) 186 | }) 187 | 188 | var _ = Describe("JSONTagBuilder", func() { 189 | var ( 190 | column *sqlmodel.Column 191 | builder *sqlmodel.JSONTagBuilder 192 | ) 193 | 194 | BeforeEach(func() { 195 | builder = &sqlmodel.JSONTagBuilder{} 196 | column = &sqlmodel.Column{ 197 | Name: "id", 198 | } 199 | }) 200 | 201 | It("creates a json tag", func() { 202 | Expect(builder.Build(column)).To(Equal("json:\"id\"")) 203 | }) 204 | }) 205 | 206 | var _ = Describe("XMLTagBuilder", func() { 207 | var ( 208 | column *sqlmodel.Column 209 | builder *sqlmodel.XMLTagBuilder 210 | ) 211 | 212 | BeforeEach(func() { 213 | builder = &sqlmodel.XMLTagBuilder{} 214 | column = &sqlmodel.Column{ 215 | Name: "id", 216 | } 217 | }) 218 | 219 | It("creates a xml tag", func() { 220 | Expect(builder.Build(column)).To(Equal("xml:\"id\"")) 221 | }) 222 | }) 223 | 224 | var _ = Describe("ValidateTagBuilder", func() { 225 | var ( 226 | column *sqlmodel.Column 227 | builder *sqlmodel.ValidateTagBuilder 228 | ) 229 | 230 | BeforeEach(func() { 231 | builder = &sqlmodel.ValidateTagBuilder{} 232 | column = &sqlmodel.Column{ 233 | Name: "id", 234 | ScanType: "string", 235 | } 236 | }) 237 | 238 | It("creates a validation tag", func() { 239 | Expect(builder.Build(column)).To(Equal("validate:\"required,gt=0\"")) 240 | }) 241 | 242 | Context("when the value has length", func() { 243 | BeforeEach(func() { 244 | column.ScanType = "" 245 | column.Type.CharMaxLength = 200 246 | }) 247 | 248 | It("creates a validation tag", func() { 249 | Expect(builder.Build(column)).To(Equal("validate:\"required,max=200\"")) 250 | }) 251 | }) 252 | 253 | Context("when the value is not nullable", func() { 254 | BeforeEach(func() { 255 | column.Type.IsNullable = true 256 | }) 257 | 258 | Context("when the value has length", func() { 259 | BeforeEach(func() { 260 | column.Type.CharMaxLength = 200 261 | }) 262 | 263 | It("creates a validation tag", func() { 264 | Expect(builder.Build(column)).To(Equal("validate:\"max=200\"")) 265 | }) 266 | }) 267 | 268 | It("returns an empty tag", func() { 269 | Expect(builder.Build(column)).To(Equal("validate:\"-\"")) 270 | }) 271 | }) 272 | }) 273 | -------------------------------------------------------------------------------- /fake/schema_provider.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fake 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/phogolabs/prana/sqlmodel" 8 | ) 9 | 10 | type SchemaProvider struct { 11 | CloseStub func() error 12 | closeMutex sync.RWMutex 13 | closeArgsForCall []struct { 14 | } 15 | closeReturns struct { 16 | result1 error 17 | } 18 | closeReturnsOnCall map[int]struct { 19 | result1 error 20 | } 21 | SchemaStub func(string, ...string) (*sqlmodel.Schema, error) 22 | schemaMutex sync.RWMutex 23 | schemaArgsForCall []struct { 24 | arg1 string 25 | arg2 []string 26 | } 27 | schemaReturns struct { 28 | result1 *sqlmodel.Schema 29 | result2 error 30 | } 31 | schemaReturnsOnCall map[int]struct { 32 | result1 *sqlmodel.Schema 33 | result2 error 34 | } 35 | TablesStub func(string) ([]string, error) 36 | tablesMutex sync.RWMutex 37 | tablesArgsForCall []struct { 38 | arg1 string 39 | } 40 | tablesReturns struct { 41 | result1 []string 42 | result2 error 43 | } 44 | tablesReturnsOnCall map[int]struct { 45 | result1 []string 46 | result2 error 47 | } 48 | invocations map[string][][]interface{} 49 | invocationsMutex sync.RWMutex 50 | } 51 | 52 | func (fake *SchemaProvider) Close() error { 53 | fake.closeMutex.Lock() 54 | ret, specificReturn := fake.closeReturnsOnCall[len(fake.closeArgsForCall)] 55 | fake.closeArgsForCall = append(fake.closeArgsForCall, struct { 56 | }{}) 57 | fake.recordInvocation("Close", []interface{}{}) 58 | fake.closeMutex.Unlock() 59 | if fake.CloseStub != nil { 60 | return fake.CloseStub() 61 | } 62 | if specificReturn { 63 | return ret.result1 64 | } 65 | fakeReturns := fake.closeReturns 66 | return fakeReturns.result1 67 | } 68 | 69 | func (fake *SchemaProvider) CloseCallCount() int { 70 | fake.closeMutex.RLock() 71 | defer fake.closeMutex.RUnlock() 72 | return len(fake.closeArgsForCall) 73 | } 74 | 75 | func (fake *SchemaProvider) CloseCalls(stub func() error) { 76 | fake.closeMutex.Lock() 77 | defer fake.closeMutex.Unlock() 78 | fake.CloseStub = stub 79 | } 80 | 81 | func (fake *SchemaProvider) CloseReturns(result1 error) { 82 | fake.closeMutex.Lock() 83 | defer fake.closeMutex.Unlock() 84 | fake.CloseStub = nil 85 | fake.closeReturns = struct { 86 | result1 error 87 | }{result1} 88 | } 89 | 90 | func (fake *SchemaProvider) CloseReturnsOnCall(i int, result1 error) { 91 | fake.closeMutex.Lock() 92 | defer fake.closeMutex.Unlock() 93 | fake.CloseStub = nil 94 | if fake.closeReturnsOnCall == nil { 95 | fake.closeReturnsOnCall = make(map[int]struct { 96 | result1 error 97 | }) 98 | } 99 | fake.closeReturnsOnCall[i] = struct { 100 | result1 error 101 | }{result1} 102 | } 103 | 104 | func (fake *SchemaProvider) Schema(arg1 string, arg2 ...string) (*sqlmodel.Schema, error) { 105 | fake.schemaMutex.Lock() 106 | ret, specificReturn := fake.schemaReturnsOnCall[len(fake.schemaArgsForCall)] 107 | fake.schemaArgsForCall = append(fake.schemaArgsForCall, struct { 108 | arg1 string 109 | arg2 []string 110 | }{arg1, arg2}) 111 | fake.recordInvocation("Schema", []interface{}{arg1, arg2}) 112 | fake.schemaMutex.Unlock() 113 | if fake.SchemaStub != nil { 114 | return fake.SchemaStub(arg1, arg2...) 115 | } 116 | if specificReturn { 117 | return ret.result1, ret.result2 118 | } 119 | fakeReturns := fake.schemaReturns 120 | return fakeReturns.result1, fakeReturns.result2 121 | } 122 | 123 | func (fake *SchemaProvider) SchemaCallCount() int { 124 | fake.schemaMutex.RLock() 125 | defer fake.schemaMutex.RUnlock() 126 | return len(fake.schemaArgsForCall) 127 | } 128 | 129 | func (fake *SchemaProvider) SchemaCalls(stub func(string, ...string) (*sqlmodel.Schema, error)) { 130 | fake.schemaMutex.Lock() 131 | defer fake.schemaMutex.Unlock() 132 | fake.SchemaStub = stub 133 | } 134 | 135 | func (fake *SchemaProvider) SchemaArgsForCall(i int) (string, []string) { 136 | fake.schemaMutex.RLock() 137 | defer fake.schemaMutex.RUnlock() 138 | argsForCall := fake.schemaArgsForCall[i] 139 | return argsForCall.arg1, argsForCall.arg2 140 | } 141 | 142 | func (fake *SchemaProvider) SchemaReturns(result1 *sqlmodel.Schema, result2 error) { 143 | fake.schemaMutex.Lock() 144 | defer fake.schemaMutex.Unlock() 145 | fake.SchemaStub = nil 146 | fake.schemaReturns = struct { 147 | result1 *sqlmodel.Schema 148 | result2 error 149 | }{result1, result2} 150 | } 151 | 152 | func (fake *SchemaProvider) SchemaReturnsOnCall(i int, result1 *sqlmodel.Schema, result2 error) { 153 | fake.schemaMutex.Lock() 154 | defer fake.schemaMutex.Unlock() 155 | fake.SchemaStub = nil 156 | if fake.schemaReturnsOnCall == nil { 157 | fake.schemaReturnsOnCall = make(map[int]struct { 158 | result1 *sqlmodel.Schema 159 | result2 error 160 | }) 161 | } 162 | fake.schemaReturnsOnCall[i] = struct { 163 | result1 *sqlmodel.Schema 164 | result2 error 165 | }{result1, result2} 166 | } 167 | 168 | func (fake *SchemaProvider) Tables(arg1 string) ([]string, error) { 169 | fake.tablesMutex.Lock() 170 | ret, specificReturn := fake.tablesReturnsOnCall[len(fake.tablesArgsForCall)] 171 | fake.tablesArgsForCall = append(fake.tablesArgsForCall, struct { 172 | arg1 string 173 | }{arg1}) 174 | fake.recordInvocation("Tables", []interface{}{arg1}) 175 | fake.tablesMutex.Unlock() 176 | if fake.TablesStub != nil { 177 | return fake.TablesStub(arg1) 178 | } 179 | if specificReturn { 180 | return ret.result1, ret.result2 181 | } 182 | fakeReturns := fake.tablesReturns 183 | return fakeReturns.result1, fakeReturns.result2 184 | } 185 | 186 | func (fake *SchemaProvider) TablesCallCount() int { 187 | fake.tablesMutex.RLock() 188 | defer fake.tablesMutex.RUnlock() 189 | return len(fake.tablesArgsForCall) 190 | } 191 | 192 | func (fake *SchemaProvider) TablesCalls(stub func(string) ([]string, error)) { 193 | fake.tablesMutex.Lock() 194 | defer fake.tablesMutex.Unlock() 195 | fake.TablesStub = stub 196 | } 197 | 198 | func (fake *SchemaProvider) TablesArgsForCall(i int) string { 199 | fake.tablesMutex.RLock() 200 | defer fake.tablesMutex.RUnlock() 201 | argsForCall := fake.tablesArgsForCall[i] 202 | return argsForCall.arg1 203 | } 204 | 205 | func (fake *SchemaProvider) TablesReturns(result1 []string, result2 error) { 206 | fake.tablesMutex.Lock() 207 | defer fake.tablesMutex.Unlock() 208 | fake.TablesStub = nil 209 | fake.tablesReturns = struct { 210 | result1 []string 211 | result2 error 212 | }{result1, result2} 213 | } 214 | 215 | func (fake *SchemaProvider) TablesReturnsOnCall(i int, result1 []string, result2 error) { 216 | fake.tablesMutex.Lock() 217 | defer fake.tablesMutex.Unlock() 218 | fake.TablesStub = nil 219 | if fake.tablesReturnsOnCall == nil { 220 | fake.tablesReturnsOnCall = make(map[int]struct { 221 | result1 []string 222 | result2 error 223 | }) 224 | } 225 | fake.tablesReturnsOnCall[i] = struct { 226 | result1 []string 227 | result2 error 228 | }{result1, result2} 229 | } 230 | 231 | func (fake *SchemaProvider) Invocations() map[string][][]interface{} { 232 | fake.invocationsMutex.RLock() 233 | defer fake.invocationsMutex.RUnlock() 234 | fake.closeMutex.RLock() 235 | defer fake.closeMutex.RUnlock() 236 | fake.schemaMutex.RLock() 237 | defer fake.schemaMutex.RUnlock() 238 | fake.tablesMutex.RLock() 239 | defer fake.tablesMutex.RUnlock() 240 | copiedInvocations := map[string][][]interface{}{} 241 | for key, value := range fake.invocations { 242 | copiedInvocations[key] = value 243 | } 244 | return copiedInvocations 245 | } 246 | 247 | func (fake *SchemaProvider) recordInvocation(key string, args []interface{}) { 248 | fake.invocationsMutex.Lock() 249 | defer fake.invocationsMutex.Unlock() 250 | if fake.invocations == nil { 251 | fake.invocations = map[string][][]interface{}{} 252 | } 253 | if fake.invocations[key] == nil { 254 | fake.invocations[key] = [][]interface{}{} 255 | } 256 | fake.invocations[key] = append(fake.invocations[key], args) 257 | } 258 | 259 | var _ sqlmodel.SchemaProvider = new(SchemaProvider) 260 | -------------------------------------------------------------------------------- /sqlmodel/generator_test.go: -------------------------------------------------------------------------------- 1 | package sqlmodel_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "go/format" 7 | "io/ioutil" 8 | "strings" 9 | 10 | "github.com/phogolabs/prana/sqlmodel" 11 | "golang.org/x/tools/imports" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("Codegen", func() { 18 | var ( 19 | generator *sqlmodel.Codegen 20 | schemaDef *sqlmodel.Schema 21 | ) 22 | 23 | BeforeEach(func() { 24 | schemaDef = NewSchema() 25 | generator = &sqlmodel.Codegen{} 26 | }) 27 | 28 | Describe("Model", func() { 29 | BeforeEach(func() { 30 | generator.Format = true 31 | }) 32 | 33 | ItGeneratesTheModelSuccessfully := func(table string) { 34 | It("generates the schema successfully", func() { 35 | source := &bytes.Buffer{} 36 | fmt.Fprintln(source, "package model") 37 | fmt.Fprintln(source) 38 | fmt.Fprintf(source, "type %s struct {", table) 39 | fmt.Fprintln(source, " ID string `db`") 40 | fmt.Fprintln(source, " Name string `db`") 41 | fmt.Fprintln(source, "}") 42 | 43 | data, err := imports.Process("model", source.Bytes(), nil) 44 | Expect(err).To(BeNil()) 45 | 46 | data, err = format.Source(data) 47 | Expect(err).To(BeNil()) 48 | 49 | reader := &bytes.Buffer{} 50 | 51 | ctx := &sqlmodel.GeneratorContext{ 52 | Writer: reader, 53 | Template: "model", 54 | Schema: schemaDef, 55 | } 56 | 57 | Expect(generator.Generate(ctx)).To(Succeed()) 58 | Expect(reader.String()).To(Equal(string(data))) 59 | }) 60 | } 61 | 62 | ItGeneratesTheModelSuccessfully("Table1") 63 | 64 | Context("when the schema is not default", func() { 65 | BeforeEach(func() { 66 | schemaDef.IsDefault = false 67 | }) 68 | 69 | ItGeneratesTheModelSuccessfully("Table1") 70 | }) 71 | 72 | Context("when no tables are provided", func() { 73 | BeforeEach(func() { 74 | schemaDef.Tables = []sqlmodel.Table{} 75 | }) 76 | 77 | It("generates the schema successfully", func() { 78 | reader := &bytes.Buffer{} 79 | ctx := &sqlmodel.GeneratorContext{ 80 | Writer: reader, 81 | Template: "model", 82 | Schema: schemaDef, 83 | } 84 | 85 | Expect(generator.Generate(ctx)).To(Succeed()) 86 | Expect(reader.String()).To(BeEmpty()) 87 | }) 88 | }) 89 | 90 | Context("when the package name is not provided", func() { 91 | It("returns an error", func() { 92 | reader := &bytes.Buffer{} 93 | schemaDef.Model.Package = "" 94 | ctx := &sqlmodel.GeneratorContext{ 95 | Writer: reader, 96 | Template: "model", 97 | Schema: schemaDef, 98 | } 99 | err := generator.Generate(ctx) 100 | Expect(err.Error()).To(Equal("model:3:1: expected 'IDENT', found 'type'")) 101 | }) 102 | }) 103 | }) 104 | 105 | Describe("Routine", func() { 106 | BeforeEach(func() { 107 | generator.Format = false 108 | }) 109 | 110 | ItGeneratesTheScriptSuccessfully := func(table string) { 111 | It("generates the SQL script successfully", func() { 112 | w := &bytes.Buffer{} 113 | t := strings.Replace(table, ".", "-", -1) 114 | fmt.Fprintf(w, "-- name: select-all-%s\n", t) 115 | fmt.Fprintf(w, "SELECT * FROM %s;\n\n", table) 116 | fmt.Fprintf(w, "-- name: select-%s-by-pk\n", t) 117 | fmt.Fprintf(w, "SELECT * FROM %s\n", table) 118 | fmt.Fprint(w, "WHERE id = ?;\n\n") 119 | fmt.Fprintf(w, "-- name: insert-%s\n", t) 120 | fmt.Fprintf(w, "INSERT INTO %s (id, name)\n", table) 121 | fmt.Fprint(w, "VALUES (?, ?);\n\n") 122 | fmt.Fprintf(w, "-- name: update-%s-by-pk\n", t) 123 | fmt.Fprintf(w, "UPDATE %s\n", table) 124 | fmt.Fprint(w, "SET name = ?\n") 125 | fmt.Fprint(w, "WHERE id = ?;\n\n") 126 | fmt.Fprintf(w, "-- name: delete-%s-by-pk\n", t) 127 | fmt.Fprintf(w, "DELETE FROM %s\n", table) 128 | fmt.Fprint(w, "WHERE id = ?;\n\n") 129 | 130 | reader := &bytes.Buffer{} 131 | ctx := &sqlmodel.GeneratorContext{ 132 | Writer: reader, 133 | Template: "routine", 134 | Schema: schemaDef, 135 | } 136 | 137 | Expect(generator.Generate(ctx)).To(Succeed()) 138 | Expect(reader.String()).To(Equal(w.String())) 139 | }) 140 | } 141 | 142 | ItGeneratesTheScriptSuccessfully("table1") 143 | 144 | Context("when no tables are provided", func() { 145 | BeforeEach(func() { 146 | schemaDef.Tables = []sqlmodel.Table{} 147 | }) 148 | 149 | It("generates the schema successfully", func() { 150 | reader := &bytes.Buffer{} 151 | ctx := &sqlmodel.GeneratorContext{ 152 | Writer: reader, 153 | Template: "routine", 154 | Schema: schemaDef, 155 | } 156 | 157 | Expect(generator.Generate(ctx)).To(Succeed()) 158 | Expect(reader.String()).To(BeEmpty()) 159 | }) 160 | }) 161 | 162 | Context("when the table does not have columns", func() { 163 | BeforeEach(func() { 164 | schemaDef.Tables[0].Columns = []sqlmodel.Column{} 165 | }) 166 | 167 | It("generates the schema successfully", func() { 168 | reader := &bytes.Buffer{} 169 | ctx := &sqlmodel.GeneratorContext{ 170 | Writer: reader, 171 | Template: "routine", 172 | Schema: schemaDef, 173 | } 174 | 175 | Expect(generator.Generate(ctx)).To(Succeed()) 176 | Expect(reader.String()).To(ContainSubstring("select-all-table1")) 177 | }) 178 | }) 179 | 180 | Context("when more than one table are provided", func() { 181 | BeforeEach(func() { 182 | schemaDef.Tables = append(schemaDef.Tables, 183 | sqlmodel.Table{ 184 | Name: "table2", 185 | Columns: []sqlmodel.Column{ 186 | { 187 | Name: "id", 188 | ScanType: "string", 189 | Type: sqlmodel.ColumnType{ 190 | Name: "varchar", 191 | IsPrimaryKey: true, 192 | IsNullable: true, 193 | CharMaxLength: 200, 194 | }, 195 | }, 196 | { 197 | Name: "name", 198 | ScanType: "string", 199 | Type: sqlmodel.ColumnType{ 200 | Name: "varchar", 201 | IsPrimaryKey: false, 202 | IsNullable: false, 203 | CharMaxLength: 200, 204 | }, 205 | }, 206 | }, 207 | }, 208 | ) 209 | }) 210 | 211 | It("generates the script successfully", func() { 212 | reader := &bytes.Buffer{} 213 | ctx := &sqlmodel.GeneratorContext{ 214 | Writer: reader, 215 | Template: "routine", 216 | Schema: schemaDef, 217 | } 218 | 219 | Expect(generator.Generate(ctx)).To(Succeed()) 220 | Expect(reader.String()).To(ContainSubstring("table1")) 221 | Expect(reader.String()).To(ContainSubstring("table2")) 222 | }) 223 | }) 224 | }) 225 | 226 | Describe("Repository", func() { 227 | BeforeEach(func() { 228 | generator.Format = true 229 | generator.Meta = map[string]interface{}{ 230 | "RepositoryPackage": "model", 231 | } 232 | }) 233 | 234 | ItGeneratesTheRepositorySuccessfully := func(table string) { 235 | It("generates the repository successfully", func() { 236 | source, err := ioutil.ReadFile("./fixture/repository.txt") 237 | Expect(err).To(BeNil()) 238 | 239 | data, err := imports.Process("model", source, nil) 240 | Expect(err).To(BeNil()) 241 | 242 | data, err = format.Source(data) 243 | Expect(err).To(BeNil()) 244 | 245 | reader := &bytes.Buffer{} 246 | 247 | ctx := &sqlmodel.GeneratorContext{ 248 | Writer: reader, 249 | Template: "repository", 250 | Schema: schemaDef, 251 | } 252 | 253 | Expect(generator.Generate(ctx)).To(Succeed()) 254 | Expect(reader.String()).To(Equal(string(data))) 255 | }) 256 | } 257 | 258 | ItGeneratesTheRepositorySuccessfully("Table1") 259 | 260 | Context("when the schema is not default", func() { 261 | BeforeEach(func() { 262 | schemaDef.IsDefault = false 263 | }) 264 | 265 | ItGeneratesTheRepositorySuccessfully("Table1") 266 | }) 267 | 268 | Context("when no tables are provided", func() { 269 | BeforeEach(func() { 270 | schemaDef.Tables = []sqlmodel.Table{} 271 | }) 272 | 273 | It("generates the schema successfully", func() { 274 | reader := &bytes.Buffer{} 275 | ctx := &sqlmodel.GeneratorContext{ 276 | Writer: reader, 277 | Template: "repository", 278 | Schema: schemaDef, 279 | } 280 | 281 | Expect(generator.Generate(ctx)).To(Succeed()) 282 | Expect(reader.String()).To(BeEmpty()) 283 | }) 284 | }) 285 | 286 | Context("when the package name is not provided", func() { 287 | It("returns an error", func() { 288 | generator.Meta = map[string]interface{}{} 289 | reader := &bytes.Buffer{} 290 | schemaDef.Model.Package = "" 291 | ctx := &sqlmodel.GeneratorContext{ 292 | Writer: reader, 293 | Template: "repository", 294 | Schema: schemaDef, 295 | } 296 | err := generator.Generate(ctx) 297 | Expect(err.Error()).To(Equal("repository:4:1: expected 'IDENT', found 'type'")) 298 | }) 299 | }) 300 | }) 301 | }) 302 | -------------------------------------------------------------------------------- /sqlmodel/executor_test.go: -------------------------------------------------------------------------------- 1 | package sqlmodel_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | 9 | "github.com/phogolabs/prana/fake" 10 | "github.com/phogolabs/prana/sqlmodel" 11 | "github.com/phogolabs/prana/storage" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("Executor", func() { 18 | var ( 19 | directory string 20 | executor *sqlmodel.Executor 21 | spec *sqlmodel.Spec 22 | provider *fake.SchemaProvider 23 | composer *fake.ModelGenerator 24 | schemaDef *sqlmodel.Schema 25 | ) 26 | 27 | BeforeEach(func() { 28 | schemaDef = &sqlmodel.Schema{ 29 | Name: "public", 30 | IsDefault: true, 31 | Tables: []sqlmodel.Table{ 32 | { 33 | Name: "table1", 34 | Columns: []sqlmodel.Column{ 35 | { 36 | Name: "ID", 37 | ScanType: "string", 38 | }, 39 | }, 40 | }, 41 | }, 42 | } 43 | 44 | var err error 45 | directory, err = ioutil.TempDir("", "prana") 46 | Expect(err).To(BeNil()) 47 | 48 | spec = &sqlmodel.Spec{ 49 | Schema: "public", 50 | Template: "model", 51 | Tables: []string{"table1"}, 52 | Filename: "schema.go", 53 | FileSystem: storage.New(directory), 54 | } 55 | 56 | provider = &fake.SchemaProvider{} 57 | provider.TablesReturns([]string{"table1"}, nil) 58 | provider.SchemaReturns(schemaDef, nil) 59 | 60 | composer = &fake.ModelGenerator{} 61 | executor = &sqlmodel.Executor{ 62 | Provider: provider, 63 | Generator: composer, 64 | } 65 | }) 66 | 67 | Describe("Write", func() { 68 | BeforeEach(func() { 69 | composer.GenerateStub = func(ctx *sqlmodel.GeneratorContext) error { 70 | ctx.Writer.Write([]byte("source")) 71 | return nil 72 | } 73 | }) 74 | 75 | It("writes the generated source successfully", func() { 76 | writer := &bytes.Buffer{} 77 | Expect(executor.Write(writer, spec)).To(Succeed()) 78 | Expect(writer.String()).To(Equal("source")) 79 | 80 | Expect(provider.TablesCallCount()).To(BeZero()) 81 | Expect(provider.SchemaCallCount()).To(Equal(1)) 82 | 83 | schemaName, tables := provider.SchemaArgsForCall(0) 84 | Expect(schemaName).To(Equal("public")) 85 | Expect(tables).To(ContainElement("table1")) 86 | 87 | Expect(composer.GenerateCallCount()).To(Equal(1)) 88 | ctx := composer.GenerateArgsForCall(0) 89 | 90 | Expect(ctx.Schema).To(Equal(schemaDef)) 91 | }) 92 | 93 | Context("when the schema is not default", func() { 94 | BeforeEach(func() { 95 | schemaDef.IsDefault = false 96 | }) 97 | 98 | It("uses the schema name as package name", func() { 99 | Expect(executor.Write(ioutil.Discard, spec)).To(Succeed()) 100 | Expect(composer.GenerateCallCount()).To(Equal(1)) 101 | ctx := composer.GenerateArgsForCall(0) 102 | 103 | Expect(ctx.Schema).To(Equal(schemaDef)) 104 | }) 105 | }) 106 | 107 | Context("when the tables are not provided", func() { 108 | BeforeEach(func() { 109 | spec.Tables = []string{} 110 | }) 111 | 112 | It("writes the generated source successfully", func() { 113 | writer := &bytes.Buffer{} 114 | 115 | Expect(executor.Write(writer, spec)).To(Succeed()) 116 | Expect(writer.String()).To(Equal("source")) 117 | 118 | Expect(provider.TablesCallCount()).To(Equal(1)) 119 | Expect(provider.TablesArgsForCall(0)).To(Equal("public")) 120 | 121 | Expect(provider.SchemaCallCount()).To(Equal(1)) 122 | 123 | schemaName, tables := provider.SchemaArgsForCall(0) 124 | Expect(schemaName).To(Equal("public")) 125 | Expect(tables).To(ContainElement("table1")) 126 | 127 | Expect(composer.GenerateCallCount()).To(Equal(1)) 128 | ctx := composer.GenerateArgsForCall(0) 129 | 130 | Expect(ctx.Schema).To(Equal(schemaDef)) 131 | }) 132 | 133 | Context("when getting the schema tables fails", func() { 134 | BeforeEach(func() { 135 | provider.TablesReturns([]string{}, fmt.Errorf("Oh no!")) 136 | }) 137 | 138 | It("returns the error", func() { 139 | writer := &bytes.Buffer{} 140 | Expect(executor.Write(writer, spec)).To(MatchError("Oh no!")) 141 | Expect(writer.Bytes()).To(BeEmpty()) 142 | }) 143 | }) 144 | }) 145 | 146 | Context("when the composer fails", func() { 147 | BeforeEach(func() { 148 | composer.GenerateReturns(fmt.Errorf("Oh no!")) 149 | }) 150 | 151 | It("returns the error", func() { 152 | Expect(executor.Write(ioutil.Discard, spec)).To(MatchError("Oh no!")) 153 | }) 154 | }) 155 | }) 156 | 157 | Describe("Create", func() { 158 | BeforeEach(func() { 159 | composer.GenerateStub = func(ctx *sqlmodel.GeneratorContext) error { 160 | ctx.Writer.Write([]byte("source")) 161 | return nil 162 | } 163 | }) 164 | 165 | ItCreatesTheSchemaInRootPkg := func(filename, pkg string) { 166 | It("creates a package with generated source successfully", func() { 167 | path, err := executor.Create(spec) 168 | Expect(err).To(Succeed()) 169 | Expect(path).To(Equal(filename)) 170 | 171 | dir := fmt.Sprintf("%v", directory) 172 | Expect(dir).To(BeADirectory()) 173 | Expect(filepath.Join(dir, path)).To(BeARegularFile()) 174 | 175 | Expect(provider.TablesCallCount()).To(BeZero()) 176 | Expect(provider.SchemaCallCount()).To(Equal(1)) 177 | 178 | schemaName, tables := provider.SchemaArgsForCall(0) 179 | Expect(schemaName).To(Equal("public")) 180 | Expect(tables).To(ContainElement("table1")) 181 | 182 | Expect(composer.GenerateCallCount()).To(Equal(1)) 183 | ctx := composer.GenerateArgsForCall(0) 184 | 185 | Expect(ctx.Schema).To(Equal(schemaDef)) 186 | }) 187 | } 188 | 189 | ItCreatesTheSchemaInRootPkg("schema.go", "entity") 190 | 191 | Context("when the schema is not default", func() { 192 | BeforeEach(func() { 193 | schemaDef.IsDefault = false 194 | }) 195 | 196 | ItCreatesTheSchemaInRootPkg("public.go", "entity") 197 | }) 198 | 199 | Context("when the KeepSchema is false", func() { 200 | ItCreatesTheSchemaInRootPkg("schema.go", "entity") 201 | 202 | Context("when the schema is not default", func() { 203 | BeforeEach(func() { 204 | schemaDef.IsDefault = false 205 | }) 206 | 207 | ItCreatesTheSchemaInRootPkg("public.go", "entity") 208 | }) 209 | }) 210 | 211 | Context("when the tables are not provided", func() { 212 | BeforeEach(func() { 213 | spec.Tables = []string{} 214 | }) 215 | 216 | It("creates a package with generated source successfully", func() { 217 | path, err := executor.Create(spec) 218 | Expect(err).To(Succeed()) 219 | Expect(path).To(Equal("schema.go")) 220 | 221 | Expect(provider.TablesCallCount()).To(Equal(1)) 222 | Expect(provider.TablesArgsForCall(0)).To(Equal("public")) 223 | Expect(provider.SchemaCallCount()).To(Equal(1)) 224 | 225 | schemaName, tables := provider.SchemaArgsForCall(0) 226 | Expect(schemaName).To(Equal("public")) 227 | Expect(tables).To(ContainElement("table1")) 228 | 229 | Expect(composer.GenerateCallCount()).To(Equal(1)) 230 | ctx := composer.GenerateArgsForCall(0) 231 | 232 | Expect(ctx.Schema).To(Equal(schemaDef)) 233 | }) 234 | 235 | Context("when the provider fails to get table names", func() { 236 | BeforeEach(func() { 237 | provider.TablesReturns([]string{}, fmt.Errorf("Oh no!")) 238 | }) 239 | 240 | It("returns the error", func() { 241 | path, err := executor.Create(spec) 242 | Expect(err).To(MatchError("Oh no!")) 243 | Expect(path).To(BeEmpty()) 244 | }) 245 | }) 246 | }) 247 | 248 | Context("when the spec schema is not default", func() { 249 | BeforeEach(func() { 250 | schemaDef.IsDefault = false 251 | }) 252 | 253 | It("creates a package with generated source successfully", func() { 254 | path, err := executor.Create(spec) 255 | Expect(err).To(Succeed()) 256 | Expect(path).To(Equal("public.go")) 257 | }) 258 | }) 259 | 260 | Context("when getting the schame fails", func() { 261 | BeforeEach(func() { 262 | provider.SchemaReturns(nil, fmt.Errorf("Oh no!")) 263 | }) 264 | 265 | It("returns the error", func() { 266 | path, err := executor.Create(spec) 267 | Expect(err).To(MatchError("Oh no!")) 268 | Expect(path).To(BeEmpty()) 269 | }) 270 | }) 271 | 272 | Context("when the reader has empty content", func() { 273 | BeforeEach(func() { 274 | composer.GenerateStub = func(ctx *sqlmodel.GeneratorContext) error { 275 | return nil 276 | } 277 | }) 278 | 279 | It("creates a package with generated source successfully", func() { 280 | path, err := executor.Create(spec) 281 | Expect(err).To(Succeed()) 282 | Expect(path).To(BeEmpty()) 283 | }) 284 | }) 285 | 286 | Context("when the composer fails", func() { 287 | BeforeEach(func() { 288 | composer.GenerateReturns(fmt.Errorf("Oh no!")) 289 | }) 290 | 291 | It("returns the error", func() { 292 | path, err := executor.Create(spec) 293 | Expect(err).To(MatchError("Oh no!")) 294 | Expect(path).To(BeEmpty()) 295 | }) 296 | }) 297 | }) 298 | }) 299 | --------------------------------------------------------------------------------