├── .github └── workflows │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── ast.go ├── ast_test.go ├── cli ├── eval.go ├── generate.go ├── list.go ├── main.go ├── param.go ├── param_test.go ├── pasre.go ├── read.go ├── run.go ├── run_test.go ├── unittest.go └── unittest_test.go ├── cmd └── twowaysql │ └── main.go ├── db_test.go ├── docker-compose-test.yml ├── docker-compose.yml ├── e2e_test.go ├── eval.go ├── eval_test.go ├── example_test.go ├── go.mod ├── go.sum ├── markdown.go ├── markdown_const.go ├── markdown_test.go ├── parse.go ├── parse_test.go ├── private └── testhelper │ └── testhelper.go ├── sqltest ├── testing.go └── testing_test.go ├── testdata └── postgres │ ├── init │ └── init.sql │ ├── markdown │ ├── select_person.sql.md │ ├── select_person_notest.sql.md │ └── select_person_with_param.sql.md │ └── sql │ ├── delete_person.sql │ ├── init_database.sql │ ├── insert_person.sql │ ├── select_all.sql │ └── select_person.sql ├── tokenizer.go ├── tokenizer_test.go └── twowaysql.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out code into the Go module directory 11 | uses: actions/checkout@v2 12 | 13 | - name: Test 14 | run: docker compose -f docker-compose-test.yml up --build --exit-code-from go 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binary 2 | server/bin 3 | *.zip 4 | *.exe 5 | **/bin/lambda 6 | **/bin/lambda.zip 7 | !*.go 8 | cmd/twowaysql/twowaysql 9 | 10 | # Goland 11 | .idea 12 | 13 | .env.local 14 | 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 2 | 3 | WORKDIR /go/src/twowaysql 4 | COPY go.* . 5 | RUN go mod download 6 | COPY . . 7 | 8 | RUN go install -v ./... 9 | CMD ["go", "test", "-v", "./..."] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [2020] [osaki-lab twowaysql-team] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twowaysql 2 | ![test](https://github.com/future-architect/go-twowaysql/actions/workflows/test.yml/badge.svg) 3 | 4 | 5 | 2-Way-SQL Go implementation 6 | 7 | ## Installation 8 | 9 | ``` 10 | go get github.com/future-architect/go-twowaysql 11 | ``` 12 | 13 | ## Usage 14 | 15 | TODO Below is an example which shows some common use cases for twowaysql. 16 | 17 | ```go 18 | package main 19 | 20 | import ( 21 | "context" 22 | "fmt" 23 | "log" 24 | 25 | "github.com/future-architect/go-twowaysql" 26 | "github.com/jmoiron/sqlx" 27 | _ "github.com/jackc/pgx/v4/stdlib" 28 | ) 29 | 30 | type Person struct { 31 | EmpNo int `db:"employee_no"` 32 | DeptNo int `db:"dept_no"` 33 | FirstName string `db:"first_name"` 34 | LastName string `db:"last_name"` 35 | Email string `db:"email"` 36 | } 37 | 38 | type Params struct { 39 | Name string `twowaysql:"name"` 40 | EmpNo int `twowaysql:"EmpNo"` 41 | MaxEmpNo int `twowaysql:"maxEmpNo"` 42 | DeptNo int `twowaysql:"deptNo"` 43 | } 44 | 45 | func main() { 46 | ctx := context.Background() 47 | 48 | db, err := sqlx.Open("pgx", "user=postgres password=postgres dbname=postgres sslmode=disable") 49 | 50 | defer db.Close() 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | 55 | tw := twowaysql.New(db) 56 | 57 | var people []Person 58 | var params = Params{ 59 | MaxEmpNo: 2000, 60 | DeptNo: 15, 61 | } 62 | 63 | err = tw.Select(ctx, &people, `SELECT * FROM persons WHERE employee_no < /*maxEmpNo*/1000 /* IF deptNo */ AND dept_no < /*deptNo*/1 /* END */`, ¶ms) 64 | if err != nil { 65 | log.Fatalf("select failed: %v", err) 66 | } 67 | 68 | fmt.Printf("%#v\n%#v\n%#v", people[0], people[1], people[2]) 69 | //Person{EmpNo:1, DeptNo:10, FirstName:"Evan", LastName:"MacMans", Email:"evanmacmans@example.com"} 70 | //Person{EmpNo:3, DeptNo:12, FirstName:"Jimmie", LastName:"Bruce", Email:"jimmiebruce@example.com"} 71 | //Person{EmpNo:2, DeptNo:11, FirstName:"Malvina", LastName:"FitzSimons", Email:"malvinafitzsimons@example.com"} 72 | 73 | } 74 | ``` 75 | 76 | ## CLI Tool 77 | 78 | CLI tool `twowaysql` provides helper functions about two way sql 79 | 80 | ``` 81 | go install github.com/future-architect/go-twowaysql/... 82 | ``` 83 | 84 | ### Database Connection 85 | 86 | To connect database, *driver* and *source* strings are required. Driver is like `pgx` and source is `postgres://user:pass@host/dbname?sslmode=disable`. 87 | 88 | You can pass them via options(`-d DRIVER`, `--driver=DRIVER`, `-c SOURCE`, `--source=SOURCE`) or by using `TWOWAYSQL_DRIVER`/`TWOWAYSQL_CONNECTION` environment variables. 89 | 90 | This tool also read `.env` and `.env.local` files. 91 | 92 | ### Execute SQL 93 | 94 | ``` 95 | $ twowaysql run -p first_name=Malvina testdata/postgres/sql/select_person.sql 96 | ┌───────────────────────────────┬────────────┬────────────┐ 97 | │ email │ first_name │ last_name │ 98 | ╞═══════════════════════════════╪════════════╪════════════╡ 99 | │ malvinafitzsimons@example.com │ Malvina │ FitzSimons │ 100 | └───────────────────────────────┴────────────┴────────────┘ 101 | 102 | Query takes 22.804166ms 103 | ``` 104 | 105 | * -p, --param=PARAM ... Parameter in single value or JSON (name=bob, or {"name": "bob"}) 106 | * -e, --explain Run with EXPLAIN to show execution plan 107 | * -r, --rollback Run within transaction and then rollback 108 | * -o, --output-format=default Result output format (default, md, json, yaml) 109 | 110 | ### Evaluate 2-Way-SQL 111 | 112 | ```sh 113 | $ twowaysql eval -p first_name=Malvina testdata/postgres/sql/select_person.sql 114 | # Converted Source 115 | 116 | SELECT email, first_name, last_name FROM persons WHERE first_name=?/*first_name*/; 117 | 118 | # Parameters 119 | 120 | - Malvina 121 | ``` 122 | 123 | ### Unittesting 124 | 125 | ```sh 126 | $ twowaysql test testdata/postgres/sql/select_person.sql 127 | ``` 128 | 129 | Test code is written in Markdown with the following format: 130 | 131 | * Single level 2 heading with "Test" or "Tests" label that contains all tests 132 | * Each test has level 3 headings with "Case:" prefix and test name 133 | * Each test can have YAML as a test code with the following keys: 134 | * `fixtures`(optional): These contents are imported as a test data 135 | * `params`(optional): This is an parameter of two way SQL 136 | * `testQuery`(optional): This is an query SQL to access table to check result. If you omit this, test runner gets result from SQL itself. 137 | * `expect`: This is an expected result. 138 | 139 | Fixtures and expect should be nested list(first line is header) or list of maps. 140 | 141 | ~~~~md 142 | ## Tests 143 | 144 | ### Case: Query Evan Test 145 | 146 | ```yaml 147 | fixtures: 148 | persons: 149 | - [employee_no, dept_no, first_name, last_name, email, created_at] 150 | - [4, 13, Dan, Conner, dan@example.com, 2022-09-13 10:30:15] 151 | params: { first_name: Dan } 152 | expect: 153 | - { email: dan@example.com, first_name: Dan } 154 | ``` 155 | ~~~~ 156 | 157 | ### Customize CLI tool 158 | 159 | by default `twowaysql` integrated with the following drivers: 160 | 161 | * ``github.com/jackc/pgx/v4`` 162 | * ``modernc.org/sqlite`` 163 | * ``github.com/go-sql-driver/mysql`` 164 | 165 | If you want to add/remove [drivers](https://github.com/golang/go/wiki/SQLDrivers), create simple main package and call `cli.Main()`. 166 | 167 | ```go 168 | package main 169 | 170 | import ( 171 | _ "github.com/sijms/go-ora/v2" // Oracle 172 | 173 | "github.com/future-architect/go-twowaysql/cli" 174 | ) 175 | 176 | func main() { 177 | cli.Main() 178 | } 179 | ``` 180 | 181 | ## License 182 | 183 | Apache License Version 2.0 184 | 185 | ## Contribution 186 | 187 | Launch database for testing: 188 | 189 | ``` 190 | $ docker compose up --build 191 | ``` 192 | 193 | Run acceptance test: 194 | 195 | ``` 196 | $ docker compose -f docker-compose-test.yml up --build 197 | ``` -------------------------------------------------------------------------------- /ast.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | type nodeKind int 9 | 10 | const ( 11 | ndSQLStmt nodeKind = iota + 1 12 | ndBind 13 | ndIf 14 | ndElif 15 | ndElse 16 | ndEnd 17 | ndEndOfProgram 18 | ) 19 | 20 | // tree is a component of an abstract syntax tree 21 | type tree struct { 22 | Kind nodeKind 23 | Left *tree 24 | Right *tree 25 | Token *token 26 | } 27 | 28 | // astはトークン列から抽象構文木を生成する。 29 | // 生成規則: 30 | // program = stmt 31 | // stmt = SQLStmt stmt | 32 | // 33 | // BIND stmt | 34 | // "IF" stmt ("ELLF" stmt)* ("ELSE" stmt)? "END" stmt | 35 | // EndOfProgram 36 | func ast(tokens []token) (*tree, error) { 37 | node, err := program(tokens) 38 | if err != nil { 39 | return nil, err 40 | } 41 | if node.nodeCount() != len(tokens) { 42 | return nil, errors.New("can not generate abstract syntax tree") 43 | } 44 | 45 | return node, nil 46 | } 47 | 48 | func program(tokens []token) (*tree, error) { 49 | index := 0 50 | return stmt(tokens, &index) 51 | } 52 | 53 | // token index token[index]を見ている 54 | func stmt(tokens []token, index *int) (*tree, error) { 55 | var node *tree 56 | var err error 57 | 58 | if consume(tokens, index, tkSQLStmt) { 59 | // SQLStmt stmt 60 | node = &tree{ 61 | Kind: ndSQLStmt, 62 | Token: &tokens[*index-1], 63 | } 64 | 65 | node.Left, err = stmt(tokens, index) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | } else if consume(tokens, index, tkBind) { 71 | // Bind stmt 72 | node = &tree{ 73 | Kind: ndBind, 74 | Token: &tokens[*index-1], 75 | } 76 | 77 | node.Left, err = stmt(tokens, index) 78 | if err != nil { 79 | return nil, err 80 | } 81 | } else if consume(tokens, index, tkEndOfProgram) { 82 | // EndOfProgram 83 | node = &tree{ 84 | Kind: nodeKind(tkEndOfProgram), 85 | // consumeはTkEndOfProgramの時はインクリメントしないから1を引かない 86 | // かなりよくない設計、一貫性がない。 87 | Token: &tokens[*index], 88 | } 89 | return node, nil 90 | 91 | } else if consume(tokens, index, tkIf) { 92 | // "IF" stmt ("ELLF" stmt)* ("ELSE" stmt)? "END" stmt 93 | node = &tree{ 94 | Kind: ndIf, 95 | Token: &tokens[*index-1], 96 | } 97 | node.Left, err = stmt(tokens, index) 98 | if err != nil { 99 | return nil, err 100 | } 101 | tmpNode := node 102 | for { 103 | // ("ELLF" stmt)* 104 | if consume(tokens, index, tkElif) { 105 | child := &tree{ 106 | Kind: ndElif, 107 | Token: &tokens[*index-1], 108 | } 109 | tmpNode.Right = child 110 | tmpNode = child 111 | 112 | child.Left, err = stmt(tokens, index) 113 | if err != nil { 114 | return nil, err 115 | } 116 | continue 117 | } 118 | break 119 | } 120 | 121 | if consume(tokens, index, tkElse) { 122 | // ("ELSE" stmt)? 123 | child := &tree{ 124 | Kind: ndElse, 125 | Token: &tokens[*index-1], 126 | } 127 | tmpNode.Right = child 128 | tmpNode = child 129 | 130 | child.Left, err = stmt(tokens, index) 131 | if err != nil { 132 | return nil, err 133 | } 134 | } 135 | 136 | if consume(tokens, index, tkEnd) { 137 | // "END" 138 | child := &tree{ 139 | Kind: ndEnd, 140 | Token: &tokens[*index-1], 141 | } 142 | tmpNode.Right = child 143 | 144 | child.Left, err = stmt(tokens, index) 145 | if err != nil { 146 | return nil, err 147 | } 148 | } else { 149 | return nil, fmt.Errorf("can not parse: expected /* END */, but got %v", tokens[*index].kind) 150 | } 151 | 152 | // どれも一致しなかった 153 | return node, nil 154 | } 155 | return node, nil 156 | } 157 | 158 | // tokenが所望のものか調べる。一致していればインデックスを一つ進める 159 | func consume(tokens []token, index *int, kind tokenKind) bool { 160 | //println("str: ", tokens[*index].str, "kind: ", tokens[*index].kind, "want kind: ", kind) 161 | if tokens[*index].kind == kind { 162 | // TkEndOfProgramでインクリメントしてしまうと 163 | // その後のconsume呼び出しでIndex Out Of Bounds例外が発生してしまう 164 | if kind != tkEndOfProgram { 165 | *index++ 166 | } 167 | return true 168 | } 169 | return false 170 | } 171 | 172 | func (t *tree) nodeCount() int { 173 | count := 1 174 | if t.Left != nil { 175 | t.Left.countInner(&count) 176 | } 177 | if t.Right != nil { 178 | t.Right.countInner(&count) 179 | } 180 | return count 181 | } 182 | 183 | func (t *tree) countInner(count *int) { 184 | *count++ 185 | if t.Left != nil { 186 | t.Left.countInner(count) 187 | } 188 | if t.Right != nil { 189 | t.Right.countInner(count) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /ast_test.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestAst(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | input []token 12 | want *tree 13 | }{ 14 | { 15 | name: "empty", 16 | input: []token{ 17 | { 18 | kind: tkEndOfProgram, 19 | }, 20 | }, 21 | want: wantEmpty, 22 | }, 23 | { 24 | name: "no comment", 25 | input: []token{ 26 | { 27 | kind: tkSQLStmt, 28 | str: "SELECT * FROM person WHERE employee_no < 1000 AND dept_no = 1", 29 | }, 30 | { 31 | kind: tkEndOfProgram, 32 | }, 33 | }, 34 | want: wantNoComment, 35 | }, 36 | { 37 | name: "if", 38 | input: []token{ 39 | { 40 | kind: tkSQLStmt, 41 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 42 | }, 43 | { 44 | kind: tkIf, 45 | str: "/* IF true */", 46 | condition: "true", 47 | }, 48 | { 49 | kind: tkSQLStmt, 50 | str: " AND dept_no = 1", 51 | }, 52 | { 53 | kind: tkEnd, 54 | str: "/* END */", 55 | }, 56 | { 57 | kind: tkEndOfProgram, 58 | }, 59 | }, 60 | want: wantTreeIf, 61 | }, 62 | { 63 | name: "if and bind", 64 | input: []token{ 65 | { 66 | kind: tkSQLStmt, 67 | str: "SELECT * FROM person WHERE employee_no < ", 68 | }, 69 | { 70 | kind: tkBind, 71 | str: "?/*maxEmpNo*/", 72 | value: "maxEmpNo", 73 | }, 74 | { 75 | kind: tkSQLStmt, 76 | str: " ", 77 | }, 78 | { 79 | kind: tkIf, 80 | str: "/* IF false */", 81 | condition: "false", 82 | }, 83 | { 84 | kind: tkSQLStmt, 85 | str: " AND dept_no = ", 86 | }, 87 | { 88 | kind: tkBind, 89 | str: "?/*deptNo*/", 90 | value: "deptNo", 91 | }, 92 | { 93 | kind: tkSQLStmt, 94 | str: " ", 95 | }, 96 | { 97 | kind: tkEnd, 98 | str: "/* END */", 99 | }, 100 | { 101 | kind: tkEndOfProgram, 102 | }, 103 | }, 104 | want: wantTreeIfBind, 105 | }, 106 | { 107 | name: "if elif else", 108 | input: []token{ 109 | { 110 | kind: tkSQLStmt, 111 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 112 | }, 113 | { 114 | kind: tkIf, 115 | str: "/* IF true */", 116 | condition: "true", 117 | }, 118 | { 119 | kind: tkSQLStmt, 120 | str: "AND dept_no =1", 121 | }, 122 | { 123 | kind: tkElif, 124 | str: "/* ELIF true*/", 125 | condition: "true", 126 | }, 127 | { 128 | kind: tkSQLStmt, 129 | str: " AND boss_no = 2 ", 130 | }, 131 | { 132 | kind: tkElse, 133 | str: "/*ELSE */", 134 | }, 135 | { 136 | kind: tkSQLStmt, 137 | str: " AND id=3", 138 | }, 139 | { 140 | kind: tkEnd, 141 | str: "/* END */", 142 | }, 143 | { 144 | kind: tkEndOfProgram, 145 | }, 146 | }, 147 | want: wantIfElifElse, 148 | }, 149 | { 150 | name: "if nest", 151 | input: []token{ 152 | { 153 | kind: tkSQLStmt, 154 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 155 | }, 156 | { 157 | kind: tkIf, 158 | str: "/* IF true */", 159 | condition: "true", 160 | }, 161 | { 162 | kind: tkSQLStmt, 163 | str: " ", 164 | }, 165 | { 166 | kind: tkIf, 167 | str: "/* IF false */", 168 | condition: "false", 169 | }, 170 | { 171 | kind: tkSQLStmt, 172 | str: " AND dept_no =1 ", 173 | }, 174 | { 175 | kind: tkElse, 176 | str: "/* ELSE */", 177 | }, 178 | { 179 | kind: tkSQLStmt, 180 | str: " AND id=3 ", 181 | }, 182 | { 183 | kind: tkEnd, 184 | str: "/* END */", 185 | }, 186 | { 187 | kind: tkSQLStmt, 188 | str: " ", 189 | }, 190 | { 191 | kind: tkElse, 192 | str: "/* ELSE*/", 193 | }, 194 | { 195 | kind: tkSQLStmt, 196 | str: " AND boss_id=4 ", 197 | }, 198 | { 199 | kind: tkEnd, 200 | str: "/* END */", 201 | }, 202 | { 203 | kind: tkEndOfProgram, 204 | }, 205 | }, 206 | want: wantIfNest, 207 | }, 208 | } 209 | 210 | for _, tt := range tests { 211 | t.Run(tt.name, func(t *testing.T) { 212 | if got, err := ast(tt.input); err != nil || !treeEqual(tt.want, got) { 213 | if err != nil { 214 | t.Log(err) 215 | } 216 | t.Errorf("Doesn't Match expected: %v, but got: %v\n", tt.want, got) 217 | fmt.Println("want:") 218 | printTree(tt.want) 219 | fmt.Println("got:") 220 | printTree(got) 221 | } 222 | }) 223 | } 224 | 225 | } 226 | 227 | func walk(t *tree, ch chan *token) { 228 | defer close(ch) 229 | 230 | if t == nil { 231 | return 232 | } 233 | 234 | walkInner(t.Left, ch) 235 | ch <- t.Token 236 | walkInner(t.Right, ch) 237 | 238 | } 239 | 240 | func walkInner(t *tree, ch chan *token) { 241 | if t == nil { 242 | return 243 | } 244 | 245 | walkInner(t.Left, ch) 246 | ch <- t.Token 247 | walkInner(t.Right, ch) 248 | } 249 | 250 | func treeEqual(t1, t2 *tree) bool { 251 | ch1 := make(chan *token) 252 | ch2 := make(chan *token) 253 | 254 | go walk(t1, ch1) 255 | go walk(t2, ch2) 256 | 257 | var s1, s2 []*token 258 | 259 | for n := range ch1 { 260 | s1 = append(s1, n) 261 | } 262 | for n := range ch2 { 263 | s2 = append(s2, n) 264 | } 265 | 266 | if len(s1) == len(s2) { 267 | for i := 0; i < len(s1); i++ { 268 | if *s1[i] != *s2[i] { 269 | fmt.Printf("not macth %v != %v\n", s1[i], s2[i]) 270 | return false 271 | } 272 | } 273 | return true 274 | } 275 | return false 276 | } 277 | 278 | func printTree(t *tree) { 279 | if t == nil { 280 | return 281 | } 282 | 283 | printWalkInner(t.Left) 284 | fmt.Println(t.Token) 285 | printWalkInner(t.Right) 286 | 287 | } 288 | 289 | func printWalkInner(t *tree) { 290 | if t == nil { 291 | return 292 | } 293 | 294 | printWalkInner(t.Left) 295 | fmt.Println(t.Token) 296 | printWalkInner(t.Right) 297 | } 298 | 299 | // テストの期待する結果を作成 300 | var ( 301 | wantEmpty = &tree{ 302 | Kind: ndEndOfProgram, 303 | Token: &token{ 304 | kind: tkEndOfProgram, 305 | }, 306 | } 307 | 308 | wantNoComment = &tree{ 309 | Kind: ndSQLStmt, 310 | Left: &tree{ 311 | Kind: ndEndOfProgram, 312 | Token: &token{ 313 | kind: tkEndOfProgram, 314 | }, 315 | }, 316 | Token: &token{ 317 | kind: tkSQLStmt, 318 | str: "SELECT * FROM person WHERE employee_no < 1000 AND dept_no = 1", 319 | }, 320 | } 321 | 322 | wantTreeIf = &tree{ 323 | Kind: ndSQLStmt, 324 | Left: &tree{ 325 | Kind: ndIf, 326 | Left: &tree{ 327 | Kind: ndSQLStmt, 328 | Token: &token{ 329 | kind: tkSQLStmt, 330 | str: " AND dept_no = 1", 331 | }, 332 | }, 333 | Right: &tree{ 334 | Kind: ndEnd, 335 | Left: &tree{ 336 | Kind: ndEndOfProgram, 337 | Token: &token{ 338 | kind: tkEndOfProgram, 339 | }, 340 | }, 341 | Token: &token{ 342 | kind: tkEnd, 343 | str: "/* END */", 344 | }, 345 | }, 346 | Token: &token{ 347 | kind: tkIf, 348 | str: "/* IF true */", 349 | condition: "true", 350 | }, 351 | }, 352 | Token: &token{ 353 | kind: tkSQLStmt, 354 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 355 | }, 356 | } 357 | 358 | wantTreeIfBind = &tree{ 359 | Kind: ndSQLStmt, 360 | Left: &tree{ 361 | Kind: ndBind, 362 | Left: &tree{ 363 | Kind: ndSQLStmt, 364 | Left: &tree{ 365 | Kind: ndIf, 366 | Left: &tree{ 367 | Kind: ndSQLStmt, 368 | Left: &tree{ 369 | Kind: ndBind, 370 | Left: &tree{ 371 | Kind: ndSQLStmt, 372 | Token: &token{ 373 | kind: tkSQLStmt, 374 | str: " ", 375 | }, 376 | }, 377 | Token: &token{ 378 | kind: tkBind, 379 | str: "?/*deptNo*/", 380 | value: "deptNo", 381 | }, 382 | }, 383 | Token: &token{ 384 | kind: tkSQLStmt, 385 | str: " AND dept_no = ", 386 | }, 387 | }, 388 | Right: &tree{ 389 | Kind: ndEnd, 390 | Left: &tree{ 391 | Kind: ndEndOfProgram, 392 | Token: &token{ 393 | kind: tkEndOfProgram, 394 | }, 395 | }, 396 | Token: &token{ 397 | kind: tkEnd, 398 | str: "/* END */", 399 | }, 400 | }, 401 | Token: &token{ 402 | kind: tkIf, 403 | str: "/* IF false */", 404 | condition: "false", 405 | }, 406 | }, 407 | Token: &token{ 408 | kind: tkSQLStmt, 409 | str: " ", 410 | }, 411 | }, 412 | Token: &token{ 413 | kind: tkBind, 414 | str: "?/*maxEmpNo*/", 415 | value: "maxEmpNo", 416 | }, 417 | }, 418 | Token: &token{ 419 | kind: tkSQLStmt, 420 | str: "SELECT * FROM person WHERE employee_no < ", 421 | }, 422 | } 423 | 424 | wantIfElifElse = &tree{ 425 | Kind: ndSQLStmt, 426 | Left: &tree{ 427 | Kind: ndIf, 428 | Left: &tree{ 429 | Kind: ndSQLStmt, 430 | Token: &token{ 431 | kind: tkSQLStmt, 432 | str: "AND dept_no =1", 433 | }, 434 | }, 435 | Right: &tree{ 436 | Kind: ndElif, 437 | Left: &tree{ 438 | Kind: ndSQLStmt, 439 | Token: &token{ 440 | kind: tkSQLStmt, 441 | str: " AND boss_no = 2 ", 442 | }, 443 | }, 444 | Right: &tree{ 445 | Kind: ndElse, 446 | Left: &tree{ 447 | Kind: ndSQLStmt, 448 | Token: &token{ 449 | kind: tkSQLStmt, 450 | str: " AND id=3", 451 | }, 452 | }, 453 | Right: &tree{ 454 | Kind: ndEnd, 455 | Left: &tree{ 456 | Kind: ndEndOfProgram, 457 | Token: &token{ 458 | kind: tkEndOfProgram, 459 | }, 460 | }, 461 | Token: &token{ 462 | kind: tkEnd, 463 | str: "/* END */", 464 | }, 465 | }, 466 | Token: &token{ 467 | kind: tkElse, 468 | str: "/*ELSE */", 469 | }, 470 | }, 471 | Token: &token{ 472 | kind: tkElif, 473 | str: "/* ELIF true*/", 474 | condition: "true", 475 | }, 476 | }, 477 | Token: &token{ 478 | kind: tkIf, 479 | str: "/* IF true */", 480 | condition: "true", 481 | }, 482 | }, 483 | Token: &token{ 484 | kind: tkSQLStmt, 485 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 486 | }, 487 | } 488 | 489 | wantIfNest = &tree{ 490 | Kind: ndSQLStmt, 491 | Left: &tree{ 492 | Kind: ndIf, 493 | Left: &tree{ 494 | Kind: ndSQLStmt, 495 | Left: &tree{ 496 | Kind: ndIf, 497 | Left: &tree{ 498 | Kind: ndSQLStmt, 499 | Token: &token{ 500 | kind: tkSQLStmt, 501 | str: " AND dept_no =1 ", 502 | }, 503 | }, 504 | Right: &tree{ 505 | Kind: ndElse, 506 | Left: &tree{ 507 | Kind: ndSQLStmt, 508 | Token: &token{ 509 | kind: tkSQLStmt, 510 | str: " AND id=3 ", 511 | }, 512 | }, 513 | Right: &tree{ 514 | Kind: ndEnd, 515 | Left: &tree{ 516 | Kind: ndSQLStmt, 517 | Token: &token{ 518 | kind: tkSQLStmt, 519 | str: " ", 520 | }, 521 | }, 522 | Token: &token{ 523 | kind: tkEnd, 524 | str: "/* END */", 525 | }, 526 | }, 527 | Token: &token{ 528 | kind: tkElse, 529 | str: "/* ELSE */", 530 | }, 531 | }, 532 | Token: &token{ 533 | kind: tkIf, 534 | str: "/* IF false */", 535 | condition: "false", 536 | }, 537 | }, 538 | Token: &token{ 539 | kind: tkSQLStmt, 540 | str: " ", 541 | }, 542 | }, 543 | Right: &tree{ 544 | Kind: ndElse, 545 | Left: &tree{ 546 | Kind: ndSQLStmt, 547 | Token: &token{ 548 | kind: tkSQLStmt, 549 | str: " AND boss_id=4 ", 550 | }, 551 | }, 552 | Right: &tree{ 553 | Kind: ndEnd, 554 | Left: &tree{ 555 | Kind: ndEndOfProgram, 556 | Token: &token{ 557 | kind: tkEndOfProgram, 558 | }, 559 | }, 560 | Token: &token{ 561 | kind: tkEnd, 562 | str: "/* END */", 563 | }, 564 | }, 565 | Token: &token{ 566 | kind: tkElse, 567 | str: "/* ELSE*/", 568 | }, 569 | }, 570 | Token: &token{ 571 | kind: tkIf, 572 | str: "/* IF true */", 573 | condition: "true", 574 | }, 575 | }, 576 | Token: &token{ 577 | kind: tkSQLStmt, 578 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 579 | }, 580 | } 581 | ) 582 | -------------------------------------------------------------------------------- /cli/eval.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/alecthomas/chroma/quick" 8 | "github.com/fatih/color" 9 | "github.com/goccy/go-yaml" 10 | 11 | "github.com/future-architect/go-twowaysql" 12 | ) 13 | 14 | func eval(srcPath string, params []string) error { 15 | stat, _ := os.Stdin.Stat() 16 | var finalParams map[string]any 17 | var err error 18 | if (stat.Mode() & os.ModeCharDevice) == 0 { 19 | finalParams, err = parseParams(params, os.Stdout) 20 | } else { 21 | finalParams, err = parseParams(params, nil) 22 | } 23 | if err != nil { 24 | return err 25 | } 26 | 27 | srcSql, err := readSql(srcPath) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | convertedSrc, sqlParams, err := twowaysql.Eval(srcSql, finalParams) 33 | if err != nil { 34 | return err 35 | } 36 | title := color.New(color.FgHiRed, color.Bold) 37 | title.Println("# Converted Source") 38 | fmt.Printf("\n") 39 | quick.Highlight(os.Stdout, convertedSrc, "sql", "terminal", "monokai") 40 | title.Println("\n# Parameters") 41 | fmt.Printf("\n") 42 | sqlParamYaml, _ := yaml.Marshal(sqlParams) 43 | quick.Highlight(os.Stdout, string(sqlParamYaml), "yaml", "terminal", "monokai") 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /cli/generate.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | 7 | "github.com/alecthomas/chroma/quick" 8 | "github.com/future-architect/go-twowaysql" 9 | ) 10 | 11 | func generateTemplate(outFile, language string) error { 12 | if outFile == "" || outFile == "--" { 13 | if isTerminal(os.Stdout) { 14 | b := &bytes.Buffer{} 15 | err := twowaysql.GenerateMarkdown(b, language) 16 | if err != nil { 17 | return err 18 | } 19 | quick.Highlight(os.Stdout, b.String(), "markdown", "terminal", "github") 20 | return nil 21 | } else { 22 | err := twowaysql.GenerateMarkdown(os.Stdout, language) 23 | if err != nil { 24 | return err 25 | } 26 | return nil 27 | } 28 | } else { 29 | f, err := os.Create(outFile) 30 | if err != nil { 31 | return err 32 | } 33 | defer f.Close() 34 | return twowaysql.GenerateMarkdown(f, language) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /cli/list.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "database/sql" 5 | "os" 6 | 7 | "github.com/alecthomas/chroma/quick" 8 | "github.com/goccy/go-yaml" 9 | ) 10 | 11 | func listDriver() { 12 | drivers, _ := yaml.Marshal(sql.Drivers()) 13 | quick.Highlight(os.Stdout, string(drivers), "yaml", "terminal", "monokai") 14 | } 15 | -------------------------------------------------------------------------------- /cli/main.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "database/sql" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | "github.com/joho/godotenv" 9 | "gopkg.in/alecthomas/kingpin.v2" 10 | ) 11 | 12 | var ( 13 | app = kingpin.New("twowaysql", "2-Way-SQL helper tool") 14 | driver = app.Flag("driver", `Database driver. TWOWAYSQL_DRIVER envvar is acceptable.`).Short('d').Envar("TWOWAYSQL_DRIVER").Enum(sql.Drivers()...) 15 | source = app.Flag("source", `Database source (e.g. postgres://user:pass@host/dbname?sslmode=disable). TWOWAYSQL_CONNECTION envvar is acceptable.`).Short('c').Envar("TWOWAYSQL_CONNECTION").String() 16 | 17 | runCommand = app.Command("run", "Execute SQL file") 18 | runFile = runCommand.Arg("file", "SQL/Markdown file").Required().NoEnvar().ExistingFile() 19 | runParam = runCommand.Flag("param", "Parameter in single value or JSON (name=bob, or {\"name\": \"bob\"})").Short('p').NoEnvar().Strings() 20 | runExplain = runCommand.Flag("explain", "Run with EXPLAIN to show execution plan").Short('e').NoEnvar().Bool() 21 | runRollback = runCommand.Flag("rollback", "Run within transaction and then rollback").Short('r').NoEnvar().Bool() 22 | runOutputFormat = runCommand.Flag("output-format", "Result output format (default, md, json, yaml)").Short('o').Default("default").Enum("default", "md", "json", "yaml") 23 | 24 | testCommand = app.Command("test", "Run test") 25 | testFiles = testCommand.Arg("file/dir", "Markdown file").Required().NoEnvar().ExistingFilesOrDirs() 26 | testVerbose = testCommand.Flag("verbose", "Show more information").Short('v').Bool() 27 | testQuiet = testCommand.Flag("quiet", "Reduce information").Short('q').Bool() 28 | 29 | evalCommand = app.Command("eval", "Parse and evaluate SQL") 30 | evalFile = evalCommand.Arg("file", "SQL/Markdown file").Required().NoEnvar().ExistingFile() 31 | evalParam = evalCommand.Flag("param", "Parameter in single value or JSON (name=bob, or {\"name\": \"bob\"})").Short('p').NoEnvar().Strings() 32 | 33 | parseCommand = app.Command("parse", "Parse SQL/Markdown source file") 34 | parseSrcFile = parseCommand.Arg("file", "SQL file").Required().NoEnvar().ExistingFile() 35 | parseDumpFormat = parseCommand.Flag("dump-format", "Dump content in specified format (default, json, yaml)").Short('f').Default("default").Enum("default", "json", "yaml") 36 | 37 | generateCommand = app.Command("generate", "Generate file") 38 | generateTemplateCommand = generateCommand.Command("template", "Markdown template") 39 | generateTemplateOutput = generateTemplateCommand.Arg("file", "Output file").String() 40 | generateTemplateLanguage = generateTemplateCommand.Flag("lang", "Language").Short('l').Enum("ja", "en") 41 | 42 | listCommand = app.Command("list", "Inspection command") 43 | listDriverCommand = listCommand.Command("driver", "Show supported drivers") 44 | ) 45 | 46 | func Main() { 47 | app.HelpFlag.Short('h') 48 | godotenv.Load(".env.local", ".env") 49 | 50 | var err error 51 | ok := true 52 | switch kingpin.MustParse(app.Parse(os.Args[1:])) { 53 | case listDriverCommand.FullCommand(): 54 | listDriver() 55 | case evalCommand.FullCommand(): 56 | err = eval(*evalFile, *evalParam) 57 | case runCommand.FullCommand(): 58 | err = run(*driver, *source, *runFile, *runParam, *runExplain, *runRollback, *runOutputFormat, nil) 59 | case testCommand.FullCommand(): 60 | ok, err = unittest(*driver, *source, *testFiles, *testVerbose, *testQuiet) 61 | case parseCommand.FullCommand(): 62 | err = parseFile(*parseSrcFile, *parseDumpFormat) 63 | case generateTemplateCommand.FullCommand(): 64 | err = generateTemplate(*generateTemplateOutput, *generateTemplateLanguage) 65 | } 66 | if err != nil { 67 | color.New(color.FgHiRed).Fprintln(os.Stderr, err.Error()) 68 | } 69 | if err != nil || !ok { 70 | os.Exit(1) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /cli/param.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/hashicorp/go-multierror" 11 | ) 12 | 13 | func parseParams(params []string, stdin io.Reader) (map[string]any, error) { 14 | err := &multierror.Error{} 15 | 16 | result := make(map[string]any) 17 | 18 | if stdin != nil { 19 | d := json.NewDecoder(stdin) 20 | e := d.Decode(&result) 21 | if e != nil { 22 | err = multierror.Append(err, fmt.Errorf("JSON parse error: %w", e)) 23 | } 24 | } 25 | 26 | for _, s := range params { 27 | if strings.HasPrefix(s, "{") { 28 | d := json.NewDecoder(strings.NewReader(s)) 29 | e := d.Decode(&result) 30 | if e != nil { 31 | err = multierror.Append(err, fmt.Errorf("JSON parse error: %w", e)) 32 | } 33 | } else { 34 | key, raw, found := strings.Cut(s, "=") 35 | if !found { 36 | err = multierror.Append(err, fmt.Errorf("invalid format: '%s' key=value or JSON is supported", s)) 37 | } 38 | if value, err := strconv.ParseFloat(raw, 64); err == nil { 39 | result[key] = value 40 | } else { 41 | result[key] = raw 42 | } 43 | } 44 | } 45 | 46 | if err.Len() > 0 { 47 | return nil, err 48 | } 49 | 50 | return result, nil 51 | } 52 | -------------------------------------------------------------------------------- /cli/param_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "io" 5 | "math" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func Test_parseParams(t *testing.T) { 14 | type args struct { 15 | params []string 16 | stdin io.Reader 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | want map[string]any 22 | wantErr string 23 | }{ 24 | { 25 | name: "empty values", 26 | args: args{ 27 | params: []string{}, 28 | }, 29 | want: map[string]any{}, 30 | }, 31 | { 32 | name: "single raw values", 33 | args: args{ 34 | params: []string{"name=tokyo", "utcOffset=9", "lat=35.6", "lon=139.6"}, 35 | }, 36 | want: map[string]any{ 37 | "name": "tokyo", 38 | "utcOffset": float64(9), 39 | "lat": 35.6, 40 | "lon": 139.6, 41 | }, 42 | }, 43 | { 44 | name: "JSON values", 45 | args: args{ 46 | params: []string{`{"name": "tokyo", "utcOffset": 9, "lat": 35.6, "lon": 139.6}`}, 47 | }, 48 | want: map[string]any{ 49 | "name": "tokyo", 50 | "utcOffset": float64(9), 51 | "lat": 35.6, 52 | "lon": 139.6, 53 | }, 54 | }, 55 | { 56 | name: "JSON from stdin", 57 | args: args{ 58 | stdin: strings.NewReader(`{"name": "tokyo", "utcOffset": 9, "lat": 35.6, "lon": 139.6}`), 59 | }, 60 | want: map[string]any{ 61 | "name": "tokyo", 62 | "utcOffset": float64(9), 63 | "lat": 35.6, 64 | "lon": 139.6, 65 | }, 66 | }, 67 | { 68 | name: "invalid error (1): key only", 69 | args: args{ 70 | params: []string{"name"}, 71 | }, 72 | want: map[string]any{}, 73 | wantErr: "1 error occurred:\n\t* invalid format: 'name' key=value or JSON is supported\n\n", 74 | }, 75 | { 76 | name: "invalid error (2): JSON parse error", 77 | args: args{ 78 | params: []string{`{"name": "tokyo",}`}, 79 | }, 80 | want: map[string]any{}, 81 | wantErr: "1 error occurred:\n\t* JSON parse error: invalid character '}' looking for beginning of object key string\n\n", 82 | }, 83 | } 84 | for _, tt := range tests { 85 | t.Run(tt.name, func(t *testing.T) { 86 | got, err := parseParams(tt.args.params, tt.args.stdin) 87 | if tt.wantErr != "" { 88 | assert.Error(t, err, tt.wantErr) 89 | } else { 90 | assert.NilError(t, err) 91 | assert.DeepEqual(t, got, tt.want, cmp.Comparer(func(x, y float64) bool { 92 | return math.Abs(x-y) < 0.01 93 | })) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /cli/pasre.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/alecthomas/chroma/quick" 12 | "github.com/future-architect/go-twowaysql" 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | func parseFile(srcPath, dumpFormat string) error { 17 | switch filepath.Ext(srcPath) { 18 | case ".md": 19 | return parseMarkdownFile(srcPath, dumpFormat) 20 | case ".sql": 21 | return parseSQLFile(srcPath, dumpFormat) 22 | default: 23 | return fmt.Errorf("parse command only supports .sql/.md file, but %s", filepath.Ext(srcPath)) 24 | } 25 | } 26 | 27 | type sqlParams struct { 28 | Params []string `json:"params"` 29 | } 30 | 31 | func parseSQLFile(srcPath, dumpFormat string) error { 32 | src, err := os.ReadFile(srcPath) 33 | if err != nil { 34 | return err 35 | } 36 | // need more clever function... 37 | params := make(map[string]any) 38 | var paramNames []string 39 | prefix := "no parameter that matches the bind value: " 40 | for { 41 | _, _, err := twowaysql.Eval(string(src), params) 42 | if err == nil { 43 | break 44 | } 45 | if strings.HasPrefix(err.Error(), prefix) { 46 | param := strings.TrimPrefix(err.Error(), prefix) 47 | paramNames = append(paramNames, param) 48 | params[param] = "" 49 | } else { 50 | return fmt.Errorf("parse error: %w", err) 51 | } 52 | } 53 | return dump(&sqlParams{Params: paramNames}, dumpFormat) 54 | } 55 | 56 | func parseMarkdownFile(srcPath, dumpFormat string) error { 57 | doc, err := twowaysql.ParseMarkdownFile(srcPath) 58 | if err != nil { 59 | return err 60 | } 61 | return dump(doc, dumpFormat) 62 | } 63 | 64 | func dump(src any, dumpFormat string) error { 65 | if isTerminal(os.Stdout) { 66 | if dumpFormat == "json" { 67 | var b bytes.Buffer 68 | e := json.NewEncoder(&b) 69 | e.SetIndent("", " ") 70 | e.Encode(src) 71 | return quick.Highlight(os.Stdout, b.String(), "json", "terminal16", "github") 72 | } else { 73 | var b bytes.Buffer 74 | e := yaml.NewEncoder(&b) 75 | e.Encode(src) 76 | return quick.Highlight(os.Stdout, b.String(), "yaml", "terminal16", "github") 77 | } 78 | } else { 79 | if dumpFormat == "json" { 80 | e := json.NewEncoder(os.Stdout) 81 | e.SetIndent("", " ") 82 | return e.Encode(src) 83 | } else { 84 | e := yaml.NewEncoder(os.Stdout) 85 | return e.Encode(src) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /cli/read.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/future-architect/go-twowaysql" 8 | ) 9 | 10 | func readSql(srcPath string) (sql string, err error) { 11 | if strings.HasSuffix(srcPath, ".md") { 12 | doc, err := twowaysql.ParseMarkdownFile(srcPath) 13 | if err != nil { 14 | return "", err 15 | } 16 | return doc.SQL, err 17 | } 18 | src, err := os.ReadFile(srcPath) 19 | if err != nil { 20 | return "", err 21 | } 22 | 23 | return string(src), nil 24 | } 25 | -------------------------------------------------------------------------------- /cli/run.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "io" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | "github.com/fatih/color" 14 | "github.com/future-architect/go-twowaysql" 15 | "github.com/jmoiron/sqlx" 16 | "github.com/shibukawa/formatdata-go" 17 | "golang.org/x/crypto/ssh/terminal" 18 | ) 19 | 20 | type explainConfig struct { 21 | Statement string 22 | ResultIsTable bool 23 | RollbackRequired bool 24 | } 25 | 26 | var explainStatements = map[string]*explainConfig{ 27 | "pgx": {"EXPLAIN ANALYZE ", false, true}, 28 | "postgres": {"EXPLAIN ANALYZE ", false, true}, 29 | "sqlite": {"EXPLAIN QUERY PLAN ", true, false}, 30 | "sqlite3": {"EXPLAIN QUERY PLAN ", true, false}, 31 | "mysql": {"EXPLAIN ", true, false}, 32 | } 33 | 34 | var outputFormats = map[string]formatdata.OutputFormat{ 35 | "default": formatdata.Terminal, 36 | "md": formatdata.Markdown, 37 | "json": formatdata.JSON, 38 | "yaml": formatdata.YAML, 39 | } 40 | 41 | func run(driver, dbSrc, srcFilePath string, params []string, explain, rollback bool, outputFormat string, out io.Writer) error { 42 | stat, _ := os.Stdin.Stat() 43 | var finalParams map[string]any 44 | var err error 45 | if (stat.Mode() & os.ModeCharDevice) == 0 { 46 | finalParams, err = parseParams(params, os.Stdout) 47 | } else { 48 | finalParams, err = parseParams(params, nil) 49 | } 50 | if err != nil { 51 | return err 52 | } 53 | 54 | srcSql, err := readSql(srcFilePath) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | var result []map[string]any 60 | 61 | // todo: time limit param 62 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 63 | defer cancel() 64 | 65 | var explainStatement *explainConfig 66 | 67 | if explain { 68 | var ok bool 69 | if explainStatement, ok = explainStatements[driver]; ok { 70 | rollback = explainStatement.RollbackRequired 71 | srcSql = explainStatement.Statement + srcSql 72 | } else { 73 | return fmt.Errorf("unknown driver to execute explain. pull request is welcome to add support to twowaysql: %s", driver) 74 | } 75 | } 76 | 77 | db, err := sqlx.Open(driver, dbSrc) 78 | if err != nil { 79 | return err 80 | } 81 | tws := twowaysql.New(db) 82 | defer tws.Close() 83 | 84 | start := time.Now() 85 | if rollback { 86 | tr, err := tws.Begin(ctx) 87 | if err != nil { 88 | return err 89 | } 90 | defer tr.Rollback() 91 | if useQuery(srcSql, explain) { 92 | err = tr.Select(ctx, &result, srcSql, finalParams) 93 | } else { 94 | result, err = mapResult(tr.Exec(ctx, srcSql, finalParams)) 95 | } 96 | if err != nil { 97 | return err 98 | } 99 | } else { 100 | if useQuery(srcSql, explain) { 101 | err = tws.Select(ctx, &result, srcSql, finalParams) 102 | } else { 103 | result, err = mapResult(tws.Exec(ctx, srcSql, finalParams)) 104 | } 105 | if err != nil { 106 | return err 107 | } 108 | } 109 | 110 | duration := time.Now().Sub(start) 111 | 112 | if explainStatement != nil && !explainStatement.ResultIsTable { 113 | dumpResult(result, out) 114 | } else { 115 | if len(result) > 0 { 116 | if out != nil { 117 | formatdata.FormatDataTo(result, out, formatdata.Opt{ 118 | OutputFormat: outputFormats[outputFormat], 119 | }) 120 | } else { 121 | formatdata.FormatData(result, formatdata.Opt{ 122 | OutputFormat: outputFormats[outputFormat], 123 | }) 124 | } 125 | } 126 | if !explain && isTerminal(out) { 127 | color.HiRed("\nQuery takes %v\n", duration) 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func dumpResult(result []map[string]any, out io.Writer) { 135 | var builder strings.Builder 136 | var key string 137 | for _, row := range result { 138 | for k, v := range row { 139 | key = k 140 | if line, ok := v.(string); ok { 141 | builder.WriteString(line) 142 | builder.WriteByte('\n') 143 | } 144 | } 145 | } 146 | if isTerminal(out) { 147 | title := color.New(color.FgHiRed, color.Bold) 148 | title.Printf("# %s\n\n", key) 149 | color.Yellow(builder.String()) 150 | } else { 151 | fmt.Println(builder.String()) 152 | } 153 | } 154 | 155 | func isTerminal(out io.Writer) bool { 156 | if out == nil { 157 | return true 158 | } 159 | o, ok := out.(*os.File) 160 | return ok && terminal.IsTerminal(int(o.Fd())) 161 | } 162 | 163 | var splitter = regexp.MustCompile(`\s+`) 164 | 165 | func useQuery(sql string, explain bool) bool { 166 | if explain { 167 | return true 168 | } 169 | for _, w := range splitter.Split(sql, -1) { 170 | if w == "" { 171 | continue 172 | } 173 | if strings.ToLower(w) == "select" { 174 | return true 175 | } else { 176 | return false 177 | } 178 | } 179 | return false 180 | } 181 | 182 | func mapResult(dbResult sql.Result, err error) ([]map[string]any, error) { 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | result := make(map[string]any) 188 | lastInsertId, err := dbResult.LastInsertId() 189 | // not all driver support these values 190 | if err == nil { 191 | result["Last Insert Id"] = lastInsertId 192 | } 193 | rowsAffected, err := dbResult.RowsAffected() 194 | if err == nil { 195 | result["Rows Affected"] = rowsAffected 196 | } 197 | if len(result) > 0 { 198 | return []map[string]any{ 199 | result, 200 | }, nil 201 | } 202 | return nil, nil 203 | } 204 | -------------------------------------------------------------------------------- /cli/run_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/future-architect/go-twowaysql/private/testhelper" 9 | "github.com/shibukawa/acquire-go" 10 | "gotest.tools/v3/assert" 11 | 12 | _ "github.com/jackc/pgx/v4/stdlib" 13 | ) 14 | 15 | func Test_run(t *testing.T) { 16 | driver := "pgx" 17 | dbSrc := testhelper.SourceStr(t) 18 | type args struct { 19 | srcPath string 20 | params []string 21 | explain bool 22 | rollback bool 23 | outputFormat string 24 | } 25 | tests := []struct { 26 | name string 27 | args args 28 | wantOut string 29 | wantError string 30 | }{ 31 | { 32 | name: "simple get: json out", 33 | args: args{ 34 | srcPath: "testdata/postgres/sql/select_person.sql", 35 | params: []string{"first_name=Evan"}, 36 | outputFormat: "json", 37 | }, 38 | wantOut: testhelper.TrimIndent(t, ` 39 | [ 40 | { 41 | "email": "evanmacmans@example.com", 42 | "first_name": "Evan", 43 | "last_name": "MacMans" 44 | } 45 | ]`), 46 | }, 47 | { 48 | name: "simple get: yaml out", 49 | args: args{ 50 | srcPath: "testdata/postgres/sql/select_person.sql", 51 | params: []string{"first_name=Evan"}, 52 | outputFormat: "yaml", 53 | }, 54 | wantOut: testhelper.TrimIndent(t, ` 55 | - email: evanmacmans@example.com 56 | first_name: Evan 57 | last_name: MacMans`), 58 | }, 59 | } 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | t.Log(tt.args.srcPath) 63 | files := acquire.MustAcquire(acquire.File, tt.args.srcPath) 64 | out := &bytes.Buffer{} 65 | err := run(driver, dbSrc, files[0], tt.args.params, tt.args.explain, tt.args.rollback, tt.args.outputFormat, out) 66 | if tt.wantError != "" { 67 | assert.Error(t, err, tt.wantError) 68 | } else { 69 | assert.NilError(t, err) 70 | assert.Equal(t, tt.wantOut, strings.TrimSpace(out.String())) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /cli/unittest.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io/fs" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strings" 13 | 14 | "github.com/alecthomas/chroma/quick" 15 | "github.com/fatih/color" 16 | "github.com/future-architect/go-twowaysql" 17 | "github.com/future-architect/go-twowaysql/sqltest" 18 | "github.com/goccy/go-yaml" 19 | "github.com/hashicorp/go-multierror" 20 | "github.com/jmoiron/sqlx" 21 | ) 22 | 23 | func init() { 24 | log.SetFlags(log.LstdFlags | log.Lshortfile) 25 | } 26 | 27 | type entry struct { 28 | path string 29 | doc *twowaysql.Document 30 | } 31 | 32 | func newTestCallback(filePath string, verbose, quiet bool) *testCallback { 33 | return &testCallback{ 34 | filePath: filePath, 35 | file: color.New(color.FgHiBlue, color.Underline, color.Bold), 36 | testcase: color.New(color.FgHiCyan, color.Underline, color.Bold), 37 | name: color.New(color.Bold), 38 | verbose: verbose, 39 | quiet: quiet, 40 | } 41 | } 42 | 43 | type testCallback struct { 44 | filePath string 45 | file *color.Color 46 | testcase *color.Color 47 | name *color.Color 48 | verbose bool 49 | quiet bool 50 | } 51 | 52 | func (c testCallback) StartTest(doc *twowaysql.Document, tc twowaysql.TestCase) { 53 | if c.verbose { 54 | if !c.quiet { 55 | c.testcase.Printf("## RUN %s / %s\n", doc.Title, tc.Name) 56 | } 57 | } 58 | } 59 | 60 | func (c testCallback) ExecFixture(doc *twowaysql.Document, tc twowaysql.TestCase) { 61 | if c.verbose { 62 | fmt.Printf(" Running fixture SQL\n") 63 | } 64 | } 65 | 66 | func (c testCallback) InsertFixtureTable(doc *twowaysql.Document, tc twowaysql.TestCase, tb twowaysql.Table) { 67 | if c.verbose { 68 | fmt.Printf(" Inserting fixture table %s\n", c.name.Sprint(tb.Name)) 69 | } 70 | } 71 | 72 | func (c testCallback) Exec(doc *twowaysql.Document, tc twowaysql.TestCase) { 73 | if c.verbose { 74 | sqlParamYaml, _ := yaml.Marshal(tc.Params) 75 | var buf bytes.Buffer 76 | quick.Highlight(&buf, string(sqlParamYaml), "yaml", "terminal", "monokai") 77 | fmt.Printf(" Exec SQL with: %s\n", strings.ReplaceAll(buf.String(), "\n", "")) 78 | } 79 | } 80 | 81 | func (c testCallback) ExecTestQuery(doc *twowaysql.Document, tc twowaysql.TestCase) { 82 | if c.verbose { 83 | fmt.Println(" Exec test query") 84 | } 85 | } 86 | 87 | func (c testCallback) EndTest(doc *twowaysql.Document, tc twowaysql.TestCase, failure error, err error) { 88 | if err != nil { 89 | if c.verbose { 90 | color.HiRed(" Test Error\n") 91 | } else { 92 | fmt.Printf("%s / %s: %s at %s\n\n", doc.Title, tc.Name, color.HiRedString("Test Error"), c.filePath) 93 | } 94 | color.HiRed(err.Error()) 95 | } else if failure != nil { 96 | if c.verbose { 97 | color.Yellow(" Test Failure\n") 98 | } else { 99 | fmt.Printf("%s / %s: %s at %s\n\n", doc.Title, tc.Name, color.HiRedString("Test Failure"), c.filePath) 100 | } 101 | color.Yellow(failure.Error()) 102 | } else if c.verbose { 103 | color.HiGreen(" Test OK\n") 104 | } 105 | } 106 | 107 | func unittest(driver, dbSrc string, filesOrDirs []string, verbose, quiet bool) (ok bool, err error) { 108 | if verbose { 109 | quiet = false 110 | } 111 | var entries []entry 112 | var errs *multierror.Error 113 | for _, f := range findFiles(filesOrDirs) { 114 | doc, err := twowaysql.ParseMarkdownFile(f) 115 | if err != nil { 116 | errs = multierror.Append(errs, fmt.Errorf("%s: %w", f, err)) 117 | continue 118 | } 119 | entries = append(entries, entry{path: f, doc: doc}) 120 | } 121 | if errs != nil { 122 | return false, errs 123 | } 124 | 125 | // timeout 126 | ctx := context.Background() 127 | db, err := sqlx.Open(driver, dbSrc) 128 | if err != nil { 129 | return false, err 130 | } 131 | // quiet: show only error 132 | // verbose: show all 133 | // !quiet && !verbose: show test name and errors 134 | 135 | file := color.New(color.FgHiBlue, color.Underline, color.Bold) 136 | name := color.New(color.Bold) 137 | 138 | var totalFailureCount int 139 | var totalErrorCount int 140 | for _, e := range entries { 141 | if !quiet { 142 | fmt.Printf("%s at %s\n", file.Sprintf("# %s", e.doc.Title), name.Sprint(e.path)) 143 | } 144 | if len(e.doc.TestCases) == 0 { 145 | if !quiet { 146 | color.Yellow(" No Test") 147 | } 148 | continue 149 | } 150 | if verbose { 151 | fmt.Print("\n") 152 | quick.Highlight(os.Stdout, e.doc.SQL, "sql", "terminal", "monokai") 153 | fmt.Print("\n\n") 154 | } 155 | failureCount, errCount, err := sqltest.Run(ctx, db, e.doc, newTestCallback(e.path, verbose, quiet)) 156 | if err != nil { 157 | return false, err 158 | } 159 | if !quiet { 160 | if verbose { 161 | fmt.Print("\n") 162 | } 163 | fmt.Printf("%s %s\n", file.Sprintf("# %s: Result", e.doc.Title), formatResult(failureCount, errCount, "ok")) 164 | if verbose { 165 | fmt.Print("\n") 166 | } 167 | } 168 | totalFailureCount += failureCount 169 | totalErrorCount += errCount 170 | } 171 | fmt.Println(formatResult(totalFailureCount, totalErrorCount, "pass")) 172 | return (totalErrorCount + totalFailureCount) == 0, nil 173 | } 174 | 175 | func formatResult(failureCount, errorCount int, okMessage string) string { 176 | var result []string 177 | if failureCount == 1 { 178 | result = append(result, color.YellowString("1 failure")) 179 | } else if failureCount > 1 { 180 | result = append(result, color.YellowString("%d failures", failureCount)) 181 | } 182 | if errorCount == 1 { 183 | result = append(result, color.HiRedString("1 error")) 184 | } else if errorCount > 1 { 185 | result = append(result, color.HiRedString("%d errors", errorCount)) 186 | } 187 | if len(result) == 0 { 188 | result = append(result, color.HiGreenString(okMessage)) 189 | } 190 | return strings.Join(result, " ") 191 | } 192 | 193 | func findFiles(filesOrDirs []string) []string { 194 | result := []string{} 195 | found := make(map[string]bool) 196 | for _, f := range filesOrDirs { 197 | s, err := os.Stat(f) 198 | if err != nil { 199 | panic(err) // input should be existing files/dirs by using kingpin. 200 | } 201 | if s.IsDir() { 202 | filepath.Walk(f, func(path string, info fs.FileInfo, err error) error { 203 | if info.IsDir() { 204 | return nil 205 | } 206 | if strings.HasSuffix(info.Name(), ".sql.md") && !found[path] { 207 | result = append(result, path) 208 | found[path] = true 209 | } 210 | return nil 211 | }) 212 | } else if !found[f] { 213 | result = append(result, f) 214 | found[f] = true 215 | } 216 | } 217 | sort.Strings(result) 218 | return result 219 | } 220 | -------------------------------------------------------------------------------- /cli/unittest_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/shibukawa/acquire-go" 7 | "gotest.tools/v3/assert" 8 | "gotest.tools/v3/assert/cmp" 9 | ) 10 | 11 | func Test_findFiles(t *testing.T) { 12 | type args struct { 13 | filesOrDirs []string 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | want []string 19 | }{ 20 | { 21 | name: "empty file", 22 | args: args{ 23 | filesOrDirs: []string{}, 24 | }, 25 | want: []string{}, 26 | }, 27 | { 28 | name: "single file", 29 | args: args{ 30 | filesOrDirs: []string{ 31 | "testdata/postgres/markdown/select_person.sql.md", 32 | }, 33 | }, 34 | want: []string{ 35 | acquire.MustAcquire(acquire.File, "testdata/postgres/markdown/select_person.sql.md")[0], 36 | }, 37 | }, 38 | { 39 | name: "search dir", 40 | args: args{ 41 | filesOrDirs: []string{ 42 | "testdata/postgres/markdown", 43 | }, 44 | }, 45 | want: []string{ 46 | acquire.MustAcquire(acquire.File, "testdata/postgres/markdown/select_person.sql.md")[0], 47 | acquire.MustAcquire(acquire.File, "testdata/postgres/markdown/select_person_notest.sql.md")[0], 48 | acquire.MustAcquire(acquire.File, "testdata/postgres/markdown/select_person_with_param.sql.md")[0], 49 | }, 50 | }, 51 | { 52 | name: "search dir (remove duplication)", 53 | args: args{ 54 | filesOrDirs: []string{ 55 | "testdata/postgres/markdown", 56 | "testdata/postgres", 57 | }, 58 | }, 59 | want: []string{ 60 | acquire.MustAcquire(acquire.File, "testdata/postgres/markdown/select_person.sql.md")[0], 61 | acquire.MustAcquire(acquire.File, "testdata/postgres/markdown/select_person_notest.sql.md")[0], 62 | acquire.MustAcquire(acquire.File, "testdata/postgres/markdown/select_person_with_param.sql.md")[0], 63 | }, 64 | }, 65 | } 66 | for _, tt := range tests { 67 | t.Run(tt.name, func(t *testing.T) { 68 | var files []string 69 | for _, f := range tt.args.filesOrDirs { 70 | files = append(files, acquire.MustAcquire(acquire.All, f)...) 71 | } 72 | got := findFiles(files) 73 | assert.Check(t, cmp.DeepEqual(tt.want, got)) 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /cmd/twowaysql/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "github.com/go-sql-driver/mysql" 5 | _ "github.com/jackc/pgx/v4/stdlib" 6 | _ "modernc.org/sqlite" 7 | 8 | "github.com/future-architect/go-twowaysql/cli" 9 | ) 10 | 11 | func main() { 12 | cli.Main() 13 | } 14 | -------------------------------------------------------------------------------- /db_test.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | func TestDBConnection(t *testing.T) { 12 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 13 | var db *sql.DB 14 | var err error 15 | 16 | if host := os.Getenv("POSTGRES_HOST"); host != "" { 17 | db, err = sql.Open("pgx", fmt.Sprintf("host=%s user=postgres password=postgres dbname=postgres sslmode=disable", host)) 18 | } else { 19 | db, err = sql.Open("pgx", "host=localhost user=postgres password=postgres dbname=postgres sslmode=disable") 20 | } 21 | 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | t.Cleanup(func() { 27 | db.Close() 28 | }) 29 | 30 | ctx := context.Background() 31 | 32 | rows, err := db.QueryContext(ctx, "SELECT first_name from persons") 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | t.Cleanup(func() { 38 | rows.Close() 39 | }) 40 | for rows.Next() { 41 | var name string 42 | if err := rows.Scan(&name); err != nil { 43 | t.Error(err) 44 | } 45 | t.Logf("first_name: %v\n", name) 46 | } 47 | if err := rows.Err(); err != nil { 48 | t.Error(err) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | go: 4 | build: . 5 | environment: 6 | - POSTGRES_HOST=postgres 7 | depends_on: 8 | db: 9 | condition: service_healthy 10 | db: 11 | image: postgres 12 | container_name: postgres 13 | restart: always 14 | environment: 15 | - POSTGRES_USER=postgres 16 | - POSTGRES_PASSWORD=postgres 17 | - POSTGRES_INITDB_ARGS=--encoding=UTF-8 18 | #- POSTGRES_DB=postgres 19 | ports: 20 | - "5432:5432" 21 | user: root 22 | volumes: 23 | - ./testdata/postgres/init:/docker-entrypoint-initdb.d 24 | - pg-data:/var/lib/pgdata 25 | healthcheck: 26 | test: pg_isready -U postgres -d postgres 27 | interval: 10s 28 | timeout: 10s 29 | retries: 3 30 | start_period: 10s 31 | volumes: 32 | pg-data: 33 | driver: local -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | postgresql: 4 | image: postgres 5 | container_name: postgres 6 | restart: always 7 | environment: 8 | - POSTGRES_USER=postgres 9 | - POSTGRES_PASSWORD=postgres 10 | - POSTGRES_INITDB_ARGS=--encoding=UTF-8 11 | #- POSTGRES_DB=postgres 12 | 13 | ports: 14 | - "5432:5432" 15 | user: root 16 | volumes: 17 | - ./testdata/postgres/init:/docker-entrypoint-initdb.d 18 | - pg-data:/var/lib/pgdata 19 | volumes: 20 | pg-data: 21 | driver: local -------------------------------------------------------------------------------- /e2e_test.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | _ "github.com/jackc/pgx/v4/stdlib" 13 | "github.com/jmoiron/sqlx" 14 | "golang.org/x/sync/errgroup" 15 | "gotest.tools/v3/assert" 16 | "gotest.tools/v3/assert/cmp" 17 | ) 18 | 19 | type Person struct { 20 | FirstName string `db:"first_name"` 21 | LastName string `db:"last_name"` 22 | Email string `db:"email"` 23 | NullString sql.NullString `db:"null_string"` 24 | NullInt sql.NullInt64 `db:"null_int"` 25 | CreatedAt time.Time `db:"created_at"` 26 | UpdatedAt sql.NullTime `db:"updated_at"` 27 | } 28 | 29 | func TestSelect(t *testing.T) { 30 | //このテストはinit.sqlに依存しています。 31 | //データベースは/testdata/postgres/init以下のsqlファイルを用いて初期化されている。 32 | db := open(t) 33 | t.Cleanup(func() { 34 | db.Close() 35 | }) 36 | tw := New(db) 37 | ctx := context.Background() 38 | 39 | // SELECT 40 | var people []Person 41 | var params = Info{ 42 | MaxEmpNo: 3, 43 | DeptNo: 12, 44 | } 45 | 46 | expected := []Person{ 47 | { 48 | FirstName: "Evan", 49 | LastName: "MacMans", 50 | Email: "evanmacmans@example.com", 51 | }, 52 | { 53 | FirstName: "Malvina", 54 | LastName: "FitzSimons", 55 | Email: "malvinafitzsimons@example.com", 56 | }, 57 | } 58 | 59 | sql := `-- comment 60 | SELECT 61 | first_name 62 | , last_name 63 | , email 64 | FROM 65 | persons 66 | WHERE 67 | employee_no < /*maxEmpNo*/1000 -- comment 68 | /* IF deptNo */ 69 | AND dept_no < /*deptNo*/1 70 | /* END */ 71 | -- comment` 72 | err := tw.Select(ctx, &people, sql, ¶ms) 73 | if err != nil { 74 | t.Errorf("select: failed: %v", err) 75 | } 76 | assert.Check(t, cmp.DeepEqual(people, expected)) 77 | } 78 | 79 | func TestSelectMap(t *testing.T) { 80 | //このテストはinit.sqlに依存しています。 81 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 82 | db := open(t) 83 | defer db.Close() 84 | tw := New(db) 85 | ctx := context.Background() 86 | 87 | // SELECT 88 | var people []map[string]interface{} 89 | var params = Info{ 90 | MaxEmpNo: 3, 91 | DeptNo: 12, 92 | } 93 | 94 | expected := []map[string]interface{}{ 95 | { 96 | "first_name": "Evan", 97 | "last_name": "MacMans", 98 | "email": "evanmacmans@example.com", 99 | }, 100 | { 101 | "first_name": "Malvina", 102 | "last_name": "FitzSimons", 103 | "email": "malvinafitzsimons@example.com", 104 | }, 105 | } 106 | 107 | sql := `-- comment 108 | SELECT 109 | first_name 110 | , last_name 111 | , email 112 | FROM 113 | persons 114 | WHERE 115 | employee_no < /*maxEmpNo*/1000 -- comment 116 | /* IF deptNo */ 117 | AND dept_no < /*deptNo*/1 118 | /* END */ 119 | -- comment` 120 | err := tw.Select(ctx, &people, sql, ¶ms) 121 | if err != nil { 122 | t.Errorf("select: failed: %v", err) 123 | } 124 | 125 | assert.Check(t, cmp.DeepEqual(people, expected)) 126 | 127 | } 128 | 129 | func TestUpdate(t *testing.T) { 130 | //このテストはinit.sqlに依存しています。 131 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 132 | db := open(t) 133 | defer db.Close() 134 | tw := New(db) 135 | ctx := context.Background() 136 | 137 | var params = Info{ 138 | MaxEmpNo: 3, 139 | DeptNo: 12, 140 | } 141 | _, err := tw.Exec(ctx, `UPDATE persons SET dept_no = /*deptNo*/1 WHERE employee_no = /*EmpNo*/1`, ¶ms) 142 | if err != nil { 143 | t.Fatalf("exec: failed: %v", err) 144 | } 145 | var people []Person 146 | err = tw.Select(ctx, &people, `SELECT first_name, last_name, email FROM persons WHERE dept_no = 11`, nil) 147 | if err != nil { 148 | t.Fatalf("select: failed: %v", err) 149 | } 150 | // 元に戻す。本当はトランザクションのラッパーを実装するべきかも 151 | _, err = tw.Exec(ctx, `UPDATE persons SET dept_no = /*deptNo*/0 WHERE employee_no = /*EmpNo*/1`, ¶ms) 152 | if err != nil { 153 | t.Fatalf("exec: failed: %v", err) 154 | } 155 | var expected = []Person{ 156 | { 157 | FirstName: "Malvina", 158 | LastName: "FitzSimons", 159 | Email: "malvinafitzsimons@example.com", 160 | }, 161 | } 162 | assert.Check(t, cmp.DeepEqual(people, expected)) 163 | } 164 | 165 | func TestExec_Parallel(t *testing.T) { 166 | //このテストはinit.sqlに依存しています。 167 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 168 | 169 | db := open(t) 170 | defer db.Close() 171 | tw := New(db) 172 | ctx := context.Background() 173 | 174 | var params = []Info{} 175 | var expect []Person 176 | 177 | for i := 0; i < 100; i++ { 178 | empNo := i + 4 179 | info := 180 | Info{ 181 | EmpNo: empNo, 182 | DeptNo: 1, 183 | FirstName: fmt.Sprintf("FirstName-%d", empNo), 184 | LastName: fmt.Sprintf("LastName-%d", empNo), 185 | Email: fmt.Sprintf("%d@test", empNo), 186 | } 187 | params = append(params, info) 188 | 189 | person := 190 | Person{ 191 | FirstName: fmt.Sprintf("FirstName-%d", empNo), 192 | LastName: fmt.Sprintf("LastName-%d", empNo), 193 | Email: fmt.Sprintf("%d@test", empNo), 194 | } 195 | expect = append(expect, person) 196 | } 197 | 198 | sql := 199 | ` 200 | INSERT INTO persons(employee_no, dept_no, first_name, last_name, email, created_at) VALUES 201 | (/*EmpNo*/1, /*deptNo*/1, /*firstName*/'firstName', /*lastName*/'lastName', /*email*/'temp', CURRENT_TIMESTAMP) 202 | ` 203 | 204 | var eg errgroup.Group 205 | 206 | for _, param := range params { 207 | p := param 208 | eg.Go(func() error { 209 | _, err := tw.Exec(ctx, sql, &p) 210 | return err 211 | }) 212 | } 213 | if err := eg.Wait(); err != nil { 214 | t.Fatalf("exec: failed: %v", err) 215 | } 216 | 217 | var people []Person 218 | err := tw.Select(ctx, &people, `SELECT first_name, last_name, email FROM persons WHERE dept_no = 1 order by employee_no`, nil) 219 | if err != nil { 220 | t.Fatalf("select: failed: %v", err) 221 | } 222 | 223 | // 元に戻す。 224 | for _, param := range params { 225 | p := param 226 | _, err = tw.Exec(ctx, `DELETE from persons WHERE employee_no = /*EmpNo*/1`, &p) 227 | if err != nil { 228 | t.Fatalf("exec: failed: %v", err) 229 | } 230 | } 231 | 232 | assert.Check(t, cmp.DeepEqual(people, expect)) 233 | } 234 | 235 | func TestInsertAndDelete(t *testing.T) { 236 | //このテストはinit.sqlに依存しています。 237 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 238 | db := open(t) 239 | defer db.Close() 240 | tw := New(db) 241 | ctx := context.Background() 242 | 243 | var params = Info{ 244 | EmpNo: 100, 245 | FirstName: "Jeff", 246 | LastName: "Dean", 247 | DeptNo: 1011, 248 | Email: "jeffdean@example.com", 249 | NullString: sql.NullString{String: "value", Valid: true}, 250 | NullInt: sql.NullInt64{Int64: 11, Valid: false}, // NULL 登録 251 | CreatedAt: time.Date(2022, 6, 10, 17, 0, 0, 0, time.UTC), 252 | UpdatedAt: sql.NullTime{Time: time.Date(2022, 6, 10, 18, 0, 0, 0, time.UTC), Valid: true}, 253 | } 254 | _, err := tw.Exec(ctx, ` 255 | INSERT INTO persons 256 | (employee_no, dept_no, first_name, last_name, email, null_string, null_int, created_at, updated_at) 257 | VALUES 258 | (/*EmpNo*/1, /*deptNo*/1, /*firstName*/"Tim", /*lastName*/"Cook", /*email*/"timcook@example.com", /*null_string*/'null', /*null_int*/1, /*created_at*/'2022-06-01 10:00:00', /*updated_at*/'2022-06-02 10:00:00')`, 259 | ¶ms) 260 | if err != nil { 261 | t.Fatalf("exec: failed: %v", err) 262 | } 263 | 264 | var people []Person 265 | err = tw.Select(ctx, &people, `SELECT first_name, last_name, email, null_string, null_int, created_at, updated_at FROM persons WHERE dept_no = /*deptNo*/0`, ¶ms) 266 | if err != nil { 267 | t.Fatalf("select: failed: %v", err) 268 | } 269 | 270 | var expected = []Person{ 271 | { 272 | FirstName: "Jeff", 273 | LastName: "Dean", 274 | Email: "jeffdean@example.com", 275 | NullString: sql.NullString{String: "value", Valid: true}, 276 | NullInt: sql.NullInt64{Int64: 0, Valid: false}, // NULL 確認 277 | CreatedAt: time.Date(2022, 6, 10, 17, 0, 0, 0, time.UTC), 278 | UpdatedAt: sql.NullTime{Time: time.Date(2022, 6, 10, 18, 0, 0, 0, time.UTC), Valid: true}, 279 | }, 280 | } 281 | assert.Check(t, cmp.DeepEqual(people, expected)) 282 | 283 | _, err = tw.Exec(ctx, `DELETE FROM persons WHERE employee_no = /*EmpNo*/2`, ¶ms) 284 | if err != nil { 285 | t.Fatalf("exec: failed: %v", err) 286 | } 287 | 288 | people = []Person{} 289 | err = tw.Select(ctx, &people, `SELECT first_name, last_name, email FROM persons WHERE dept_no = /*deptNo*/0`, ¶ms) 290 | if err != nil { 291 | t.Fatalf("select: failed: %v", err) 292 | } 293 | 294 | expected = []Person{} 295 | assert.Check(t, cmp.DeepEqual(people, expected)) 296 | } 297 | 298 | func TestTxCommit(t *testing.T) { 299 | //このテストはinit.sqlに依存しています。 300 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 301 | db := open(t) 302 | defer db.Close() 303 | tw := New(db) 304 | ctx := context.Background() 305 | 306 | // insert test data 307 | const insertSQL = ` 308 | INSERT INTO persons 309 | (employee_no, dept_no, first_name, last_name, email, created_at) VALUES 310 | (11, 111, 'Clegg', 'George', 'clegggeorge@example.com', CURRENT_TIMESTAMP) 311 | ; 312 | ` 313 | if _, err := tw.Exec(ctx, insertSQL, nil); err != nil { 314 | t.Fatal(err) 315 | } 316 | defer tw.Exec(ctx, `DELETE FROM persons WHERE employee_no = 11`, nil) 317 | 318 | // begin 319 | tx, err := tw.Begin(ctx) 320 | if err != nil { 321 | t.Fatal(err) 322 | } 323 | 324 | // update 325 | type Param struct { 326 | EmpNo int `twowaysql:"EmpNo"` 327 | FirstName string `twowaysql:"FirstName"` 328 | } 329 | const sql = ` 330 | UPDATE 331 | persons 332 | SET first_name = /*FirstName*/Jon 333 | WHERE employee_no = /*EmpNo*/10` 334 | param := Param{EmpNo: 11, FirstName: "Rimmer"} 335 | res, err := tx.Exec(ctx, sql, ¶m) 336 | if err != nil { 337 | t.Error(err) 338 | } 339 | rows, err := res.RowsAffected() 340 | if err != nil { 341 | t.Error(err) 342 | } 343 | if rows != 1 { 344 | t.Errorf("update rows = %v", rows) 345 | } 346 | 347 | // commit 348 | if err := tx.Commit(); err != nil { 349 | t.Error(err) 350 | } 351 | 352 | // check 353 | people := []Person{} 354 | if err := tw.Select(ctx, &people, `SELECT first_name, last_name, email FROM persons WHERE employee_no = /*EmpNo*/10`, ¶m); err != nil { 355 | t.Error(err) 356 | } 357 | expectedAfterCommit := []Person{ 358 | { 359 | FirstName: "Rimmer", 360 | LastName: "George", 361 | Email: "clegggeorge@example.com", 362 | }, 363 | } 364 | if !match(expectedAfterCommit, people) { 365 | t.Errorf("expected:\n%v\nbut got\n%v\n", expectedAfterCommit, people) 366 | } 367 | } 368 | 369 | func TestTxRollback(t *testing.T) { 370 | //このテストはinit.sqlに依存しています。 371 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 372 | db := open(t) 373 | defer db.Close() 374 | tw := New(db) 375 | ctx := context.Background() 376 | 377 | // insert test data 378 | const insertSQL = ` 379 | INSERT INTO persons 380 | (employee_no, dept_no, first_name, last_name, email, created_at) VALUES 381 | (12, 121, 'Chmmg', 'Dudley', 'chmmgdudley@example.com', CURRENT_TIMESTAMP) 382 | ; 383 | ` 384 | if _, err := tw.Exec(ctx, insertSQL, nil); err != nil { 385 | t.Fatal(err) 386 | } 387 | defer tw.Exec(ctx, `DELETE FROM persons WHERE employee_no = 12`, nil) 388 | 389 | // begin 390 | tx, err := tw.Begin(ctx) 391 | if err != nil { 392 | t.Fatal(err) 393 | } 394 | 395 | // update 396 | type Param struct { 397 | EmpNo int `twowaysql:"EmpNo"` 398 | FirstName string `twowaysql:"firstName"` 399 | } 400 | const sql = ` 401 | UPDATE 402 | persons 403 | SET first_name = /*firstName*/Jon 404 | WHERE employee_no = /*EmpNo*/10` 405 | param := Param{EmpNo: 12, FirstName: "Emerson"} 406 | res, err := tx.Exec(ctx, sql, ¶m) 407 | if err != nil { 408 | t.Error(err) 409 | } 410 | rows, err := res.RowsAffected() 411 | if err != nil { 412 | t.Error(err) 413 | } 414 | if rows != 1 { 415 | t.Errorf("update rows = %v", rows) 416 | } 417 | 418 | // rollback 419 | if err := tx.Rollback(); err != nil { 420 | t.Error(err) 421 | } 422 | 423 | // check 424 | people := []Person{} 425 | if err := tw.Select(ctx, &people, `SELECT first_name, last_name, email FROM persons WHERE employee_no = /*EmpNo*/10`, ¶m); err != nil { 426 | t.Error(err) 427 | } 428 | expectedAfterCommit := []Person{ 429 | { 430 | FirstName: "Chmmg", 431 | LastName: "Dudley", 432 | Email: "chmmgdudley@example.com", 433 | }, 434 | } 435 | if !match(expectedAfterCommit, people) { 436 | t.Errorf("expected:\n%v\nbut got\n%v\n", expectedAfterCommit, people) 437 | } 438 | } 439 | 440 | func TestTxBlock(t *testing.T) { 441 | //このテストはinit.sqlに依存しています。 442 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 443 | db := open(t) 444 | defer db.Close() 445 | tw := New(db) 446 | ctx := context.Background() 447 | 448 | // insert test data 449 | const insertSQL = ` 450 | INSERT INTO persons 451 | (employee_no, dept_no, first_name, last_name, email, created_at) VALUES 452 | (13, 131, 'Darling', 'Wat', 'darlingwat@example.com', CURRENT_TIMESTAMP), 453 | (14, 141, 'Hallows', 'Jessie', 'hallowsjessie@example.com', CURRENT_TIMESTAMP) 454 | ;` 455 | if _, err := tw.Exec(ctx, insertSQL, nil); err != nil { 456 | t.Fatal(err) 457 | } 458 | defer tw.Exec(ctx, `DELETE FROM persons WHERE employee_no = 13`, nil) 459 | defer tw.Exec(ctx, `DELETE FROM persons WHERE employee_no = 14`, nil) 460 | 461 | type Param struct { 462 | EmpNo int `twowaysql:"EmpNo"` 463 | FirstName string `twowaysql:"firstName"` 464 | } 465 | // commit case 466 | err := tw.Transaction(ctx, func(tx *TwowaysqlTx) error { 467 | // update 468 | const sql = ` 469 | UPDATE 470 | persons 471 | SET first_name = /*firstName*/Jon 472 | WHERE employee_no = /*EmpNo*/10` 473 | param := Param{EmpNo: 13, FirstName: "COMMITED"} 474 | res, err := tx.Exec(ctx, sql, ¶m) 475 | if err != nil { 476 | return err 477 | } 478 | rows, err := res.RowsAffected() 479 | if err != nil { 480 | return err 481 | } 482 | if rows != 1 { 483 | return fmt.Errorf("update rows = %v", rows) 484 | } 485 | 486 | return nil 487 | }) 488 | if err != nil { 489 | t.Error(err) 490 | } 491 | 492 | // rollcack case 493 | err = tw.Transaction(ctx, func(tx *TwowaysqlTx) error { 494 | // update 495 | const sql = ` 496 | UPDATE 497 | persons 498 | SET first_name = /*firstName*/Jon 499 | WHERE employee_no = /*EmpNo*/10` 500 | param := Param{EmpNo: 14, FirstName: "ROLLBACKED"} 501 | res, err := tx.Exec(ctx, sql, ¶m) 502 | if err != nil { 503 | return err 504 | } 505 | rows, err := res.RowsAffected() 506 | if err != nil { 507 | return err 508 | } 509 | if rows != 1 { 510 | return fmt.Errorf("update rows = %v", rows) 511 | } 512 | 513 | // generate error 514 | return errors.New("TEST ERROR") 515 | }) 516 | if err == nil { 517 | t.Error("unexpecte err == nil") 518 | } 519 | 520 | // check 521 | people := []Person{} 522 | const checkSQL = `SELECT first_name, last_name, email FROM persons WHERE employee_no IN (13, 14) order by employee_no` 523 | if err := tw.Select(ctx, &people, checkSQL, nil); err != nil { 524 | t.Error(err) 525 | } 526 | expectedAfterCommit := []Person{ 527 | // commit 528 | { 529 | FirstName: "COMMITED", 530 | LastName: "Wat", 531 | Email: "darlingwat@example.com", 532 | }, 533 | // rollback 534 | { 535 | FirstName: "Hallows", 536 | LastName: "Jessie", 537 | Email: "hallowsjessie@example.com", 538 | }, 539 | } 540 | if !match(expectedAfterCommit, people) { 541 | t.Errorf("expected:\n%v\nbut got\n%v\n", expectedAfterCommit, people) 542 | } 543 | 544 | } 545 | 546 | func TestTxBlockPanic(t *testing.T) { 547 | //このテストはinit.sqlに依存しています。 548 | //データベースは/postgres/init以下のsqlファイルを用いて初期化されている。 549 | db := open(t) 550 | defer db.Close() 551 | tw := New(db) 552 | ctx := context.Background() 553 | 554 | // insert test data 555 | const insertSQL = ` 556 | INSERT INTO persons 557 | (employee_no, dept_no, first_name, last_name, email, created_at) VALUES 558 | (15, 151, 'Tom', 'Mike', 'tommike@example.com', CURRENT_TIMESTAMP) 559 | ;` 560 | if _, err := tw.Exec(ctx, insertSQL, nil); err != nil { 561 | t.Fatal(err) 562 | } 563 | defer tw.Exec(ctx, `DELETE FROM persons WHERE employee_no = 15`, nil) 564 | 565 | type Param struct { 566 | EmpNo int `twowaysql:"EmpNo"` 567 | FirstName string `twowaysql:"firstName"` 568 | } 569 | 570 | defer func() { 571 | p := recover() 572 | assert.Equal(t, p != nil, true) 573 | 574 | // check 575 | people := []Person{} 576 | const checkSQL = `SELECT first_name, last_name, email FROM persons WHERE employee_no = 15` 577 | if err := tw.Select(ctx, &people, checkSQL, nil); err != nil { 578 | t.Error(err) 579 | } 580 | expectedAfterCommit := []Person{ 581 | { 582 | FirstName: "Tom", 583 | LastName: "Mike", 584 | Email: "tommike@example.com", 585 | }, 586 | } 587 | if !match(expectedAfterCommit, people) { 588 | t.Errorf("expected:\n%v\nbut got\n%v\n", expectedAfterCommit, people) 589 | } 590 | }() 591 | 592 | // rollcack case (panic recover) 593 | err := tw.Transaction(ctx, func(tx *TwowaysqlTx) error { 594 | // update 595 | const sql = ` 596 | UPDATE 597 | persons 598 | SET first_name = /*firstName*/Jon 599 | WHERE employee_no = /*EmpNo*/10` 600 | param := Param{EmpNo: 15, FirstName: "ROLLBACKED(PANIC)"} 601 | res, err := tx.Exec(ctx, sql, ¶m) 602 | if err != nil { 603 | return err 604 | } 605 | rows, err := res.RowsAffected() 606 | if err != nil { 607 | return err 608 | } 609 | if rows != 1 { 610 | return fmt.Errorf("update rows = %v", rows) 611 | } 612 | 613 | // occure panic 614 | panic("test panic") 615 | }) 616 | 617 | assert.NilError(t, err) 618 | } 619 | 620 | func open(t *testing.T) *sqlx.DB { 621 | t.Helper() 622 | var db *sqlx.DB 623 | var err error 624 | 625 | if host := os.Getenv("POSTGRES_HOST"); host != "" { 626 | db, err = sqlx.Open("pgx", fmt.Sprintf("host=%s user=postgres password=postgres dbname=postgres sslmode=disable", host)) 627 | } else { 628 | db, err = sqlx.Open("pgx", "host=localhost user=postgres password=postgres dbname=postgres sslmode=disable") 629 | } 630 | 631 | if err != nil { 632 | t.Fatal(err) 633 | } 634 | 635 | return db 636 | } 637 | 638 | func match(p1, p2 []Person) bool { 639 | if len(p1) != len(p2) { 640 | return false 641 | } 642 | for i := 0; i < len(p1); i++ { 643 | if p1[i] != p2[i] { 644 | return false 645 | } 646 | } 647 | return true 648 | } 649 | -------------------------------------------------------------------------------- /eval.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "fmt" 7 | "reflect" 8 | "strings" 9 | "time" 10 | "unicode" 11 | 12 | "github.com/future-architect/tagscanner/runtimescan" 13 | ) 14 | 15 | // Eval returns converted query and bind value. 16 | // inputParams takes a tagged struct. Tags must be in the form `map:"tag_name"`. 17 | // The return value is expected to be used to issue queries to the database 18 | func Eval(inputQuery string, inputParams interface{}) (string, []interface{}, error) { 19 | mapParams := map[string]interface{}{} 20 | 21 | if inputParams != nil { 22 | if err := encode(mapParams, inputParams); err != nil { 23 | return "", nil, err 24 | } 25 | } else { 26 | mapParams = nil 27 | } 28 | 29 | tokens, err := tokenize(inputQuery) 30 | if err != nil { 31 | return "", nil, err 32 | } 33 | 34 | generatedTokens, err := parseCondition(tokens, mapParams) 35 | if err != nil { 36 | return "", nil, err 37 | } 38 | 39 | convertedQuery, params, err := build(generatedTokens, mapParams) 40 | if err != nil { 41 | return "", nil, err 42 | } 43 | 44 | return arrangeWhiteSpace(convertedQuery), params, nil 45 | } 46 | 47 | func build(tokens []token, inputParams map[string]interface{}) (string, []interface{}, error) { 48 | var b strings.Builder 49 | params := make([]interface{}, 0, len(tokens)) 50 | 51 | for _, token := range tokens { 52 | if token.kind == tkBind { 53 | if elem, ok := inputParams[token.value]; ok { 54 | switch elemTyp := elem.(type) { 55 | case []string: 56 | token.str = bindLiterals(token.str, len(elemTyp)) 57 | for _, value := range elemTyp { 58 | params = append(params, value) 59 | } 60 | case []int: 61 | token.str = bindLiterals(token.str, len(elemTyp)) 62 | for _, value := range elemTyp { 63 | params = append(params, value) 64 | } 65 | case [][]interface{}: 66 | token.str = bindTable(token.str, len(elemTyp), len(elemTyp[0])) 67 | for _, rows := range elemTyp { 68 | for _, columns := range rows { 69 | params = append(params, columns) 70 | } 71 | } 72 | default: 73 | params = append(params, elem) 74 | } 75 | } else { 76 | return "", nil, fmt.Errorf("no parameter that matches the bind value: %s", token.value) 77 | } 78 | } 79 | b.WriteString(token.str) 80 | } 81 | return b.String(), params, nil 82 | } 83 | 84 | // ?/* ... */ -> (?, ?, ?)/* ... */みたいにする 85 | func bindLiterals(str string, number int) string { 86 | str = strings.TrimLeftFunc(str, func(r rune) bool { 87 | return r != unicode.SimpleFold('/') 88 | }) 89 | var b strings.Builder 90 | b.WriteRune('(') 91 | for i := 0; i < number; i++ { 92 | b.WriteRune('?') 93 | if i != number-1 { 94 | b.WriteString(", ") 95 | } 96 | } 97 | b.WriteRune(')') 98 | 99 | return fmt.Sprint(b.String(), str) 100 | } 101 | 102 | func bindTable(str string, rowNumber, columnNumber int) string { 103 | str = strings.TrimLeftFunc(str, func(r rune) bool { 104 | return r != unicode.SimpleFold('/') 105 | }) 106 | 107 | var column strings.Builder 108 | column.WriteRune('(') 109 | for i := 0; i < columnNumber; i++ { 110 | column.WriteRune('?') 111 | if i != columnNumber-1 { 112 | column.WriteString(", ") 113 | } 114 | } 115 | column.WriteRune(')') 116 | 117 | var row strings.Builder 118 | row.WriteRune('(') 119 | for i := 0; i < rowNumber; i++ { 120 | row.WriteString(column.String()) 121 | if i != rowNumber-1 { 122 | row.WriteString(", ") 123 | } 124 | } 125 | row.WriteRune(')') 126 | 127 | return fmt.Sprint(row.String(), str) 128 | } 129 | 130 | // 空白が二つ以上続いていたら一つにする。=1 -> = 1のような変換はできない 131 | // 単純な空白を想定。 -> issue: よりロバストな実装 132 | func arrangeWhiteSpace(str string) string { 133 | ret := "" 134 | buff := bytes.NewBufferString(ret) 135 | for i := 0; i < len(str); i++ { 136 | if i < len(str)-1 && str[i] == ' ' && str[i+1] == ' ' { 137 | continue 138 | } 139 | buff.WriteByte(str[i]) 140 | } 141 | ret = buff.String() 142 | return strings.Trim(ret, " ") 143 | } 144 | 145 | type encoder struct { 146 | dest map[string]interface{} 147 | } 148 | 149 | func (m encoder) ParseTag(name, tagKey, tagStr, pathStr string, elemType reflect.Type) (tag interface{}, err error) { 150 | return runtimescan.BasicParseTag(name, tagKey, tagStr, pathStr, elemType) 151 | } 152 | 153 | func (m *encoder) VisitField(tag, value interface{}) (err error) { 154 | t := tag.(*runtimescan.BasicTag) 155 | m.dest[t.Tag] = value 156 | return nil 157 | } 158 | 159 | func (m encoder) EnterChild(tag interface{}) (err error) { 160 | return nil 161 | } 162 | 163 | func (m encoder) LeaveChild(tag interface{}) (err error) { 164 | return nil 165 | } 166 | 167 | func encode(dest map[string]interface{}, src interface{}) error { 168 | v := reflect.ValueOf(src) 169 | 170 | if v.Kind() == reflect.Pointer && v.Elem().Kind() == reflect.Map { 171 | if convertToMapStringAny(v.Elem(), dest) { 172 | return nil 173 | } 174 | } 175 | if v.Kind() == reflect.Map { 176 | if convertToMapStringAny(v, dest) { 177 | return nil 178 | } 179 | } 180 | 181 | tags := []string{"twowaysql", "db"} 182 | if err := runtimescan.Encode(src, tags, &encoder{ 183 | dest: dest, 184 | }); err != nil { 185 | return err 186 | } 187 | 188 | // tagscanner does not support nest struct type. 189 | encodeStructField(src, dest, tags) 190 | 191 | return nil 192 | } 193 | 194 | func convertToMapStringAny(mp reflect.Value, dest map[string]interface{}) bool { 195 | if mp.Type().Key().Kind() != reflect.String { 196 | return false 197 | } 198 | for _, k := range mp.MapKeys() { 199 | dest[k.String()] = mp.MapIndex(k).Interface() 200 | } 201 | return true 202 | } 203 | 204 | func encodeStructField(src interface{}, dest map[string]interface{}, tags []string) { 205 | srcFieldValues := reflect.ValueOf(src) 206 | srcFieldTyps := srcFieldValues.Type() 207 | if srcFieldTyps.Kind() == reflect.Pointer { 208 | srcFieldTyps = srcFieldTyps.Elem() 209 | srcFieldValues = srcFieldValues.Elem() 210 | } 211 | for i := 0; i < srcFieldTyps.NumField(); i++ { 212 | srcFieldTyp := srcFieldTyps.Field(i) 213 | srcFieldValue := srcFieldValues.Field(i) 214 | 215 | tagValue := getTagValue(srcFieldTyp.Tag, tags) 216 | 217 | if srcFieldTyp.Type.Kind() != reflect.Struct { 218 | continue 219 | } 220 | switch srcFieldTyp.Type.PkgPath() { 221 | case "database/sql": 222 | if tagValue == "" { 223 | continue 224 | } 225 | encodeSQLNullTyp(srcFieldValue, dest, tagValue) 226 | case "time": 227 | if tagValue == "" { 228 | continue 229 | } 230 | encodeTimeTyp(srcFieldValue, dest, tagValue) 231 | default: 232 | encodeStructField(srcFieldValue.Interface(), dest, tags) 233 | } 234 | } 235 | } 236 | 237 | func encodeSQLNullTyp(srcFieldValue reflect.Value, dest map[string]interface{}, tagValue string) { 238 | switch v := srcFieldValue.Interface().(type) { 239 | case sql.NullBool: 240 | if v.Valid { 241 | dest[tagValue] = v.Bool 242 | } else { 243 | dest[tagValue] = nil 244 | } 245 | case sql.NullByte: 246 | // not support 247 | return 248 | case sql.NullFloat64: 249 | if v.Valid { 250 | dest[tagValue] = v.Float64 251 | } else { 252 | dest[tagValue] = nil 253 | } 254 | case sql.NullInt16: 255 | if v.Valid { 256 | dest[tagValue] = v.Int16 257 | } else { 258 | dest[tagValue] = nil 259 | } 260 | case sql.NullInt32: 261 | if v.Valid { 262 | dest[tagValue] = v.Int32 263 | } else { 264 | dest[tagValue] = nil 265 | } 266 | case sql.NullInt64: 267 | if v.Valid { 268 | dest[tagValue] = v.Int64 269 | } else { 270 | dest[tagValue] = nil 271 | } 272 | case sql.NullString: 273 | if v.Valid { 274 | dest[tagValue] = v.String 275 | } else { 276 | dest[tagValue] = nil 277 | } 278 | case sql.NullTime: 279 | if v.Valid { 280 | dest[tagValue] = v.Time 281 | } else { 282 | dest[tagValue] = nil 283 | } 284 | } 285 | } 286 | 287 | func encodeTimeTyp(srcFieldValue reflect.Value, dest map[string]interface{}, tagValue string) { 288 | switch v := srcFieldValue.Interface().(type) { 289 | case time.Time: 290 | dest[tagValue] = v 291 | } 292 | } 293 | 294 | func getTagValue(structTag reflect.StructTag, targetTags []string) string { 295 | for _, t := range targetTags { 296 | tag := structTag.Get(t) 297 | if tag != "" { 298 | return tag 299 | } 300 | } 301 | return "" 302 | } 303 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package twowaysql_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/future-architect/go-twowaysql" 9 | ) 10 | 11 | var ( 12 | tw *twowaysql.Twowaysql 13 | ctx context.Context 14 | ) 15 | 16 | func ExampleTwowaysql_Exec() { 17 | 18 | type Info struct { 19 | Name string `twowaysql:"name"` 20 | EmpNo int `twowaysql:"EmpNo"` 21 | MaxEmpNo int `twowaysql:"maxEmpNo"` 22 | DeptNo int `twowaysql:"deptNo"` 23 | Email string `twowaysql:"email"` 24 | GenderList []string `twowaysql:"gender_list"` 25 | IntList []int `twowaysql:"int_list"` 26 | } 27 | 28 | var params = Info{ 29 | MaxEmpNo: 3, 30 | DeptNo: 12, 31 | } 32 | 33 | result, err := tw.Exec(ctx, `UPDATE persons SET dept_no = /*deptNo*/1 WHERE employee_no = /*EmpNo*/1`, ¶ms) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | 38 | rows, err := result.RowsAffected() 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | if rows != 1 { 44 | log.Fatalf("expected to affect 1 row. affected %d", rows) 45 | } 46 | } 47 | 48 | func ExampleTwowaysql_Select() { 49 | type Person struct { 50 | FirstName string `db:"first_name"` 51 | LastName string `db:"last_name"` 52 | Email string `db:"email"` 53 | } 54 | 55 | type Info struct { 56 | Name string `twowaysql:"name"` 57 | EmpNo int `twowaysql:"EmpNo"` 58 | MaxEmpNo int `twowaysql:"maxEmpNo"` 59 | DeptNo int `twowaysql:"deptNo"` 60 | Email string `twowaysql:"email"` 61 | GenderList []string `twowaysql:"gender_list"` 62 | IntList []int `twowaysql:"int_list"` 63 | } 64 | 65 | var params = Info{ 66 | MaxEmpNo: 3, 67 | DeptNo: 12, 68 | } 69 | 70 | var people []Person 71 | err := tw.Select(ctx, &people, `SELECT first_name, last_name, email FROM persons WHERE employee_no < /*maxEmpNo*/1000 /* IF deptNo */ AND dept_no < /*deptNo*/1 /* END */`, ¶ms) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | } 76 | 77 | func ExampleEval() { 78 | 79 | type Info struct { 80 | Name string `twowaysql:"name"` 81 | EmpNo int `twowaysql:"EmpNo"` 82 | MaxEmpNo int `twowaysql:"maxEmpNo"` 83 | DeptNo int `twowaysql:"deptNo"` 84 | Email string `twowaysql:"email"` 85 | GenderList []string `twowaysql:"gender_list"` 86 | IntList []int `twowaysql:"int_list"` 87 | } 88 | 89 | var params = Info{ 90 | Name: "Jeff", 91 | MaxEmpNo: 3, 92 | DeptNo: 12, 93 | GenderList: []string{"M", "F"}, 94 | IntList: []int{1, 2, 3}, 95 | } 96 | var before = `SELECT * FROM person WHERE employee_no = /*maxEmpNo*/1000 AND /* IF int_list !== null */ person.gender in /*int_list*/(3,5,7) /* END */` 97 | 98 | after, afterParams, _ := twowaysql.Eval(before, ¶ms) 99 | 100 | fmt.Println(after) 101 | fmt.Println(afterParams) 102 | 103 | // Output: 104 | // SELECT * FROM person WHERE employee_no = ?/*maxEmpNo*/ AND person.gender in (?, ?, ?)/*int_list*/ 105 | // [3 1 2 3] 106 | 107 | } 108 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/future-architect/go-twowaysql 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/alecthomas/chroma v0.10.0 7 | github.com/fatih/color v1.13.0 8 | github.com/future-architect/tagscanner v1.0.3 9 | github.com/go-sql-driver/mysql v1.6.0 10 | github.com/goccy/go-yaml v1.9.5 11 | github.com/google/go-cmp v0.5.9 12 | github.com/hashicorp/go-multierror v1.1.1 13 | github.com/jackc/pgx/v4 v4.17.0 14 | github.com/jmoiron/sqlx v1.3.1 15 | github.com/joho/godotenv v1.4.0 16 | github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac 17 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 18 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 19 | gopkg.in/sourcemap.v1 v1.0.5 // indirect 20 | gotest.tools/v3 v3.2.0 21 | modernc.org/sqlite v1.18.1 22 | ) 23 | 24 | require ( 25 | github.com/future-architect/go-exceltesting v0.3.1 26 | github.com/shibukawa/acquire-go v1.0.0 27 | github.com/shibukawa/formatdata-go v0.1.3 28 | github.com/shibukawa/mdd-go v0.1.7 29 | github.com/stretchr/testify v1.8.0 30 | golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b 31 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c 32 | gopkg.in/yaml.v2 v2.4.0 33 | ) 34 | 35 | require ( 36 | github.com/Songmu/gocredits v0.2.0 // indirect 37 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect 38 | github.com/alecthomas/chroma/v2 v2.2.0 // indirect 39 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 40 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 41 | github.com/davecgh/go-spew v1.1.1 // indirect 42 | github.com/dlclark/regexp2 v1.4.0 // indirect 43 | github.com/google/uuid v1.3.0 // indirect 44 | github.com/hashicorp/errwrap v1.0.0 // indirect 45 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 46 | github.com/jackc/pgconn v1.13.0 // indirect 47 | github.com/jackc/pgio v1.0.0 // indirect 48 | github.com/jackc/pgpassfile v1.0.0 // indirect 49 | github.com/jackc/pgproto3/v2 v2.3.1 // indirect 50 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect 51 | github.com/jackc/pgtype v1.12.0 // indirect 52 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 53 | github.com/mattn/go-colorable v0.1.13 // indirect 54 | github.com/mattn/go-isatty v0.0.16 // indirect 55 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 56 | github.com/pmezard/go-difflib v1.0.0 // indirect 57 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect 58 | github.com/richardlehane/mscfb v1.0.4 // indirect 59 | github.com/richardlehane/msoleps v1.0.1 // indirect 60 | github.com/shibukawa/stringwidth v0.2.0 // indirect 61 | github.com/xuri/efp v0.0.0-20220407160117-ad0f7a785be8 // indirect 62 | github.com/xuri/excelize/v2 v2.6.0 // indirect 63 | github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22 // indirect 64 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect 65 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect 66 | golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 // indirect 67 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect 68 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 69 | golang.org/x/text v0.3.7 // indirect 70 | golang.org/x/tools v0.1.10 // indirect 71 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 72 | gopkg.in/yaml.v3 v3.0.1 // indirect 73 | lukechampine.com/uint128 v1.1.1 // indirect 74 | modernc.org/cc/v3 v3.36.0 // indirect 75 | modernc.org/ccgo/v3 v3.16.8 // indirect 76 | modernc.org/libc v1.16.19 // indirect 77 | modernc.org/mathutil v1.4.1 // indirect 78 | modernc.org/memory v1.1.1 // indirect 79 | modernc.org/opt v0.1.1 // indirect 80 | modernc.org/strutil v1.1.1 // indirect 81 | modernc.org/token v1.0.0 // indirect 82 | ) 83 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= 3 | github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 4 | github.com/Songmu/gocredits v0.2.0 h1:AbvFKEbwP5/0qisF0cTlUwVuCtzbJG+ynsXuEUC98vI= 5 | github.com/Songmu/gocredits v0.2.0/go.mod h1:JBywHzwOmBMF9uidu1EgS3mwVNqZCKOPLPrFd1h7qQo= 6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 7 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 8 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 9 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 10 | github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY= 11 | github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= 12 | github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY= 13 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= 14 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 15 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 16 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 17 | github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= 18 | github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 19 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 20 | github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 21 | github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 22 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= 26 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 27 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 28 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 29 | github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= 30 | github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= 31 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 32 | github.com/future-architect/go-exceltesting v0.3.1 h1:7/1VT2MBp22bacsyIJn5WI8k3+yMV1L72hPhlUYIEOU= 33 | github.com/future-architect/go-exceltesting v0.3.1/go.mod h1:IaQnLhItvrqgGVSjOjXjqkeLPuaXNja6QnLJWt9t/A8= 34 | github.com/future-architect/tagscanner v1.0.1 h1:a3bEyiWbPeI5Py1aZkrGpo9CRO7zMrvxNgKrzRbph4k= 35 | github.com/future-architect/tagscanner v1.0.1/go.mod h1:8oNrgjBYshWEPUCUNbOzxuaoPcB6ifKzUi4jIC0BcgA= 36 | github.com/future-architect/tagscanner v1.0.3 h1:pAyQnvGEhnv2DQdIps055XkUDgiifeZRiZy8BxX61kA= 37 | github.com/future-architect/tagscanner v1.0.3/go.mod h1:8oNrgjBYshWEPUCUNbOzxuaoPcB6ifKzUi4jIC0BcgA= 38 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 39 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 40 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 41 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 42 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 43 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 44 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 45 | github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= 46 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= 47 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 48 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= 49 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 50 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 51 | github.com/goccy/go-yaml v1.9.5 h1:Eh/+3uk9kLxG4koCX6lRMAPS1OaMSAi+FJcya0INdB0= 52 | github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= 53 | github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= 54 | github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 55 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 56 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 57 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 58 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 59 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 60 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 61 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 62 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 63 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 64 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 65 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 66 | github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= 67 | github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 68 | github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= 69 | github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 70 | github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= 71 | github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= 72 | github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= 73 | github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= 74 | github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= 75 | github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= 76 | github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys= 77 | github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI= 78 | github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= 79 | github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= 80 | github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= 81 | github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= 82 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= 83 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= 84 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 85 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 86 | github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= 87 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= 88 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= 89 | github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 90 | github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 91 | github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 92 | github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 93 | github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y= 94 | github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 95 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= 96 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= 97 | github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= 98 | github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= 99 | github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= 100 | github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= 101 | github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w= 102 | github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= 103 | github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= 104 | github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= 105 | github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= 106 | github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= 107 | github.com/jackc/pgx/v4 v4.17.0 h1:Hsx+baY8/zU2WtPLQyZi8WbecgcsWEeyoK1jvg/WgIo= 108 | github.com/jackc/pgx/v4 v4.17.0/go.mod h1:Gd6RmOhtFLTu8cp/Fhq4kP195KrshxYJH3oW8AWJ1pw= 109 | github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 110 | github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 111 | github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 112 | github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 113 | github.com/jmoiron/sqlx v1.3.1 h1:aLN7YINNZ7cYOPK3QC83dbM6KT0NMqVMw961TqrejlE= 114 | github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= 115 | github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= 116 | github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 117 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 118 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 119 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 120 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 121 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 122 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 123 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 124 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 125 | github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 126 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 127 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 128 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 129 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 130 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 131 | github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 132 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 133 | github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= 134 | github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 135 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 136 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 137 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 138 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 139 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 140 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 141 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 142 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 143 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 144 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 145 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 146 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 147 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 148 | github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw= 149 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= 150 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= 151 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 152 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 153 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 154 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 155 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= 156 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 157 | github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= 158 | github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= 159 | github.com/richardlehane/msoleps v1.0.1 h1:RfrALnSNXzmXLbGct/P2b4xkFz4e8Gmj/0Vj9M9xC1o= 160 | github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= 161 | github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac h1:kYPjbEN6YPYWWHI6ky1J813KzIq/8+Wg4TO4xU7A/KU= 162 | github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY= 163 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 164 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 165 | github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= 166 | github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= 167 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 168 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 169 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 170 | github.com/shibukawa/acquire-go v1.0.0 h1:wtWFn/SaUlpNKMdWhaxv91bohgAvSxWvZZwaY/1AVuk= 171 | github.com/shibukawa/acquire-go v1.0.0/go.mod h1:qNFR0+pDjqOx7ZpUVoF80jozjVpQKOi9JYaZyzp4Haw= 172 | github.com/shibukawa/formatdata-go v0.1.3 h1:GRdfYwi9akxZ9dsxzAwh9kLc1mTMrjsNqHmOJeZdhHs= 173 | github.com/shibukawa/formatdata-go v0.1.3/go.mod h1:bOJeFS09RL3OiiIX537WifjPum4ApQ5aKpsXfKKeHUw= 174 | github.com/shibukawa/mdd-go v0.1.7 h1:R5wqOLdA3A+YlI6W7iY3DoY5LhGAvKHTVBWx78G3ggE= 175 | github.com/shibukawa/mdd-go v0.1.7/go.mod h1:udgYJSAdFwAgfb+9ya4f4PEAps8UbZS3mIVjnW57+sA= 176 | github.com/shibukawa/stringwidth v0.2.0 h1:7yI2g++cXmcfkAQdjski1WdNS4/JXOlogIZvVUaXlZw= 177 | github.com/shibukawa/stringwidth v0.2.0/go.mod h1:Wrfv+6/9RP6lIMAZIujniybvvHYAfqqW81nNqXOXZsg= 178 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= 179 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= 180 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 181 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 182 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 183 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 184 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 185 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 186 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 187 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 188 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 189 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 190 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 191 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 192 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 193 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 194 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 195 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 196 | github.com/xuri/efp v0.0.0-20220407160117-ad0f7a785be8 h1:3X7aE0iLKJ5j+tz58BpvIZkXNV7Yq4jC93Z/rbN2Fxk= 197 | github.com/xuri/efp v0.0.0-20220407160117-ad0f7a785be8/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= 198 | github.com/xuri/excelize/v2 v2.6.0 h1:m/aXAzSAqxgt74Nfd+sNzpzVKhTGl7+S9nbG4A57mF4= 199 | github.com/xuri/excelize/v2 v2.6.0/go.mod h1:Q1YetlHesXEKwGFfeJn7PfEZz2IvHb6wdOeYjBxVcVs= 200 | github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22 h1:OAmKAfT06//esDdpi/DZ8Qsdt4+M5+ltca05dA5bG2M= 201 | github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= 202 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 203 | github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 204 | go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 205 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 206 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 207 | go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 208 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 209 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 210 | go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 211 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 212 | go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 213 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 214 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 215 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 216 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 217 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 218 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 219 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 220 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 221 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 222 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 223 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 224 | golang.org/x/crypto v0.0.0-20220408190544-5352b0902921/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 225 | golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 226 | golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0= 227 | golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 228 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= 229 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= 230 | golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= 231 | golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= 232 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 233 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 234 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 235 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 236 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o= 237 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 238 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 239 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 240 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 241 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 242 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 243 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 244 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 245 | golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c= 246 | golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 247 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 248 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 249 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 250 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 251 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 252 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 253 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 254 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 255 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 256 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 257 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 258 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 259 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 260 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 261 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 262 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 263 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 264 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 265 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 266 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 267 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 268 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 269 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= 270 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 271 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 272 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 273 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 274 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 275 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 276 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 277 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 278 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 279 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 280 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 281 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 282 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 283 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 284 | golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 285 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 286 | golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 287 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 288 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 289 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 290 | golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 291 | golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 292 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 293 | golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= 294 | golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 295 | golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 296 | golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 297 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 298 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 299 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 300 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 301 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 302 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 303 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 304 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 305 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 306 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 307 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 308 | gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= 309 | gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= 310 | gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= 311 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 312 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 313 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 314 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 315 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 316 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 317 | gotest.tools/v3 v3.2.0 h1:I0DwBVMGAx26dttAj1BtJLAkVGncrkkUXfJLC4Flt/I= 318 | gotest.tools/v3 v3.2.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= 319 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 320 | lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU= 321 | lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= 322 | modernc.org/cc/v3 v3.36.0 h1:0kmRkTmqNidmu3c7BNDSdVHCxXCkWLmWmCIVX4LUboo= 323 | modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= 324 | modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= 325 | modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= 326 | modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= 327 | modernc.org/ccgo/v3 v3.16.8 h1:G0QNlTqI5uVgczBWfGKs7B++EPwCfXPWGD2MdeKloDs= 328 | modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= 329 | modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= 330 | modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= 331 | modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= 332 | modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= 333 | modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= 334 | modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A= 335 | modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU= 336 | modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= 337 | modernc.org/libc v1.16.19 h1:S8flPn5ZeXx6iw/8yNa986hwTQDrY8RXU7tObZuAozo= 338 | modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= 339 | modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 340 | modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8= 341 | modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 342 | modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU= 343 | modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= 344 | modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A= 345 | modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 346 | modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8= 347 | modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= 348 | modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs= 349 | modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= 350 | modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao= 351 | modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= 352 | modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 353 | modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM= 354 | -------------------------------------------------------------------------------- /markdown.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/fs" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/shibukawa/mdd-go" 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | // Document contains SQL and metadata 15 | type Document struct { 16 | SQL string `json:"sql"` 17 | Title string `json:"title"` 18 | Params []Param `json:"params"` 19 | CRUDMatrix []CRUDMatrix `json:"crud_matrix,omitempty"` 20 | TestCases []TestCase `json:"testcases,omitempty"` 21 | CommonTestFixture Fixture `json:"common_test_fixtures,omitempty"` 22 | } 23 | 24 | type Fixture struct { 25 | Lang string 26 | Code string 27 | Tables []Table 28 | } 29 | 30 | // document absorb diffs between raw Markdown representation and public Document type 31 | type document struct { 32 | SQL string 33 | Title string 34 | Params []Param 35 | CRUDMatrix []CRUDMatrix 36 | TestCases []testCase 37 | RawCommonTestFixture string 38 | RawCommonTestFixtureLang string 39 | parsedCommonTestFixture []Table 40 | } 41 | 42 | // Param is parameter type of 2-Way-SQL 43 | type Param struct { 44 | Name string `json:"name"` 45 | Type ParamType `json:"type"` 46 | Value string `json:"value"` 47 | Description string `json:"description,omitempty"` 48 | } 49 | 50 | // CRUDMatrix represents CRUD Matrix 51 | type CRUDMatrix struct { 52 | Table string `json:"table"` 53 | C bool `json:"c"` 54 | R bool `json:"r"` 55 | U bool `json:"u"` 56 | D bool `json:"d"` 57 | Description string `json:"description,omitempty"` 58 | } 59 | 60 | type Table struct { 61 | MatchRule MatchRule `json:"type"` 62 | Name string `json:"name"` 63 | Cells [][]string `json:"cells"` 64 | } 65 | 66 | type TestCase struct { 67 | Name string 68 | Params map[string]string 69 | TestQuery string 70 | Expect [][]string 71 | Fixtures []Table 72 | } 73 | 74 | type testCase struct { 75 | Name string 76 | RawTest string 77 | parsedFixtures []Table 78 | parsedTestQuery string 79 | parsedExpect [][]string 80 | parsedParams map[string]string 81 | } 82 | 83 | func parseFixture(src string) (map[string][][]string, bool) { 84 | tempSliceYaml := struct { 85 | Tables map[string][][]string `yaml:"fixtures"` 86 | }{} 87 | tempMapYaml := struct { 88 | Tables map[string][]map[string]string `yaml:"fixtures"` 89 | }{} 90 | if err := yaml.Unmarshal([]byte(src), &tempSliceYaml); err == nil { 91 | return tempSliceYaml.Tables, true 92 | } else if err := yaml.Unmarshal([]byte(src), &tempMapYaml); err == nil { 93 | result := make(map[string][][]string) 94 | for k, v := range tempMapYaml.Tables { 95 | result[k] = convertTableMapToSlice(v) 96 | } 97 | return result, true 98 | } 99 | return nil, false 100 | } 101 | 102 | func parseExpect(src string) ([][]string, string, map[string]string, bool) { 103 | tempSliceYaml := struct { 104 | Param map[string]string `yaml:"params"` 105 | TestQuery string `yaml:"testQuery"` 106 | Expect [][]string `yaml:"expect"` 107 | }{} 108 | tempMapYaml := struct { 109 | Param map[string]string `yaml:"params"` 110 | TestQuery string `yaml:"testQuery"` 111 | Expect []map[string]string `yaml:"expect"` 112 | }{} 113 | if err := yaml.Unmarshal([]byte(src), &tempSliceYaml); err == nil { 114 | return tempSliceYaml.Expect, tempSliceYaml.TestQuery, tempSliceYaml.Param, true 115 | } else if err := yaml.Unmarshal([]byte(src), &tempMapYaml); err == nil { 116 | return convertTableMapToSlice(tempMapYaml.Expect), tempMapYaml.TestQuery, tempMapYaml.Param, true 117 | } 118 | return nil, "", nil, false 119 | } 120 | 121 | var ( 122 | acceptableKeysInGlobalFixture = map[string]bool{ 123 | "fixtures": true, 124 | } 125 | acceptableKeysInLocalTestCases = map[string]bool{ 126 | "fixtures": true, 127 | "params": true, 128 | "testQuery": true, 129 | "expect": true, 130 | } 131 | ) 132 | 133 | func checkKeys(src string, acceptableKeys map[string]bool, label string) error { 134 | var temp map[string]any 135 | err := yaml.Unmarshal([]byte(src), &temp) 136 | if err != nil { 137 | return err 138 | } 139 | var mismatch []string 140 | for key := range temp { 141 | if _, ok := acceptableKeys[key]; !ok { 142 | mismatch = append(mismatch, key) 143 | } 144 | } 145 | if len(mismatch) > 0 { 146 | var keys []string 147 | for key := range acceptableKeys { 148 | keys = append(keys, key) 149 | } 150 | sort.Strings(mismatch) 151 | sort.Strings(keys) 152 | return fmt.Errorf("YAML keys %s is invalid in %s (%s are acceptable)", strings.Join(mismatch, ", "), label, strings.Join(keys, ", ")) 153 | } 154 | return nil 155 | } 156 | 157 | func (d *document) PostProcess() error { 158 | if d.RawCommonTestFixtureLang == "yaml" { 159 | err := checkKeys(d.RawCommonTestFixture, acceptableKeysInGlobalFixture, d.Title) 160 | if err != nil { 161 | return err 162 | } 163 | if parsed, ok := parseFixture(d.RawCommonTestFixture); ok { 164 | for k, cells := range parsed { 165 | d.parsedCommonTestFixture = append(d.parsedCommonTestFixture, Table{ 166 | Name: k, 167 | Cells: cells, 168 | }) 169 | } 170 | } 171 | } 172 | for i, tc := range d.TestCases { 173 | err := checkKeys(tc.RawTest, acceptableKeysInLocalTestCases, tc.Name+" of "+d.Title) 174 | if err != nil { 175 | return err 176 | } 177 | if parsed, ok := parseFixture(tc.RawTest); ok { 178 | for k, cells := range parsed { 179 | tc.parsedFixtures = append(tc.parsedFixtures, Table{ 180 | Name: k, 181 | Cells: cells, 182 | }) 183 | } 184 | } 185 | if parsed, testQuery, params, ok := parseExpect(tc.RawTest); ok { 186 | tc.parsedExpect = parsed 187 | tc.parsedTestQuery = testQuery 188 | tc.parsedParams = params 189 | } else { 190 | return fmt.Errorf("can't parse yaml of test '%s'", tc.Name) 191 | } 192 | d.TestCases[i] = tc 193 | } 194 | return nil 195 | } 196 | 197 | func (d document) ToDocument() *Document { 198 | result := &Document{ 199 | SQL: d.SQL, 200 | Title: d.Title, 201 | Params: d.Params, 202 | CRUDMatrix: d.CRUDMatrix, 203 | } 204 | switch d.RawCommonTestFixtureLang { 205 | case "yaml": 206 | result.CommonTestFixture = Fixture{ 207 | Lang: "yaml", 208 | Tables: d.parsedCommonTestFixture, 209 | } 210 | case "sql": 211 | result.CommonTestFixture = Fixture{ 212 | Lang: "sql", 213 | Code: d.RawCommonTestFixture, 214 | } 215 | } 216 | for _, tc := range d.TestCases { 217 | result.TestCases = append(result.TestCases, TestCase{ 218 | Name: tc.Name, 219 | Params: tc.parsedParams, 220 | TestQuery: tc.parsedTestQuery, 221 | Expect: tc.parsedExpect, 222 | Fixtures: tc.parsedFixtures, 223 | }) 224 | } 225 | 226 | return result 227 | } 228 | 229 | var docJig = mdd.NewDocJig[document]() 230 | 231 | func init() { 232 | docJig.Alias("Title").Lang("ja", "タイトル") 233 | 234 | docJig.Alias("Parameters", "Parameter").Lang("ja", "パラメータ", "引数") 235 | docJig.Alias("CRUD Matrix", "CRUD Table").Lang("ja", "CRUDマトリックス", "CRUD図", "CRUD表") 236 | 237 | docJig.Alias("Name").Lang("ja", "パラメータ名", "名前") 238 | docJig.Alias("Type").Lang("ja", "型", "タイプ") 239 | docJig.Alias("Description", "Desc", "Detail").Lang("ja", "説明", "詳細") 240 | 241 | docJig.Alias("Table").Lang("ja", "テーブル") 242 | 243 | docJig.Alias("Test", "Tests", "Sample", "Samples", "Example", "Examples").Lang("ja", "テスト", "サンプル", "実行例") 244 | docJig.Alias("Case", "Test Case", "TestCase").Lang("ja", "ケース", "テストケース") 245 | 246 | root := docJig.Root().Label("Title") 247 | 248 | root.CodeFence("SQL", "sql").SampleCode("select * from table where id = 1;") 249 | 250 | params := root.Child(".", "Parameters").Table("Params") 251 | params.Field("Name", "Name").Required() 252 | params.Field("Type").Required().As(func(typeName string, d *document) (any, error) { 253 | t, ok := paramTypeMap[strings.ToLower(typeName)] 254 | if ok { 255 | return t, nil 256 | } 257 | return nil, fmt.Errorf("type '%s' is invalid", typeName) 258 | }) 259 | params.Field("Description") 260 | 261 | crudMatrix := root.Child(".", "CRUD Matrix").Table("CRUDMatrix") 262 | crudMatrix.Field("Table").Required() 263 | crudMatrix.Field("C").Required() 264 | crudMatrix.Field("R").Required() 265 | crudMatrix.Field("U").Required() 266 | crudMatrix.Field("D").Required() 267 | crudMatrix.Field("Description") 268 | 269 | test := root.Child(".", "Test") 270 | test.CodeFence("RawCommonTestFixture", "sql", "yaml").Language("RawCommonTestFixtureLang") 271 | testcase := test.Children("TestCases", "Case") 272 | testcase.Label("Name") 273 | testcase.CodeFence("RawTest", "yaml") 274 | } 275 | 276 | // ParseMarkdownFile parses markdown file 277 | func ParseMarkdownFile(filepath string) (*Document, error) { 278 | d, err := docJig.ParseFile(filepath) 279 | if err != nil { 280 | return nil, err 281 | } 282 | return d.ToDocument(), err 283 | } 284 | 285 | // ParseMarkdown parses markdown content 286 | func ParseMarkdown(r io.Reader) (*Document, error) { 287 | d, err := docJig.Parse(r) 288 | if err != nil { 289 | return nil, err 290 | } 291 | return d.ToDocument(), err 292 | } 293 | 294 | // ParseMarkdown parses markdown content 295 | func ParseMarkdownString(src string) (*Document, error) { 296 | d, err := docJig.ParseString(src) 297 | if err != nil { 298 | return nil, err 299 | } 300 | return d.ToDocument(), err 301 | } 302 | 303 | // ParseMarkdownGlob parses markdown files to match patterns 304 | func ParseMarkdownGlob(pattern ...string) (map[string]*Document, error) { 305 | ds, err := docJig.ParseGlob(pattern...) 306 | if err != nil { 307 | return nil, err 308 | } 309 | result := make(map[string]*Document) 310 | for k, d := range ds { 311 | result[k] = d.ToDocument() 312 | } 313 | return result, err 314 | } 315 | 316 | // ParseMarkdown parses markdown content 317 | func ParseMarkdownFS(fsys fs.FS, pattern ...string) (map[string]*Document, error) { 318 | ds, err := docJig.ParseFS(fsys, pattern...) 319 | if err != nil { 320 | return nil, err 321 | } 322 | result := make(map[string]*Document) 323 | for k, d := range ds { 324 | result[k] = d.ToDocument() 325 | } 326 | return result, err 327 | } 328 | 329 | // ParseMarkdown parses markdown content 330 | func GenerateMarkdown(w io.Writer, lang string) error { 331 | return docJig.GenerateTemplate(w, mdd.GenerateOption{ 332 | Language: lang, 333 | }) 334 | } 335 | 336 | func convertTableMapToSlice(table []map[string]string) [][]string { 337 | var headers []string 338 | existingCheck := map[string]bool{} 339 | for _, row := range table { 340 | for k := range row { 341 | if !existingCheck[k] { 342 | headers = append(headers, k) 343 | existingCheck[k] = true 344 | } 345 | } 346 | } 347 | sort.Strings(headers) 348 | 349 | slices := make([][]string, len(table)+1) 350 | slices[0] = headers 351 | for r, row := range table { 352 | rowSlice := make([]string, len(headers)) 353 | for c, h := range headers { 354 | if v, ok := row[h]; ok { 355 | rowSlice[c] = v 356 | } else { 357 | rowSlice[c] = "" 358 | } 359 | } 360 | slices[r+1] = rowSlice 361 | } 362 | return slices 363 | } 364 | -------------------------------------------------------------------------------- /markdown_const.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // ParamType is for describing parameters 9 | type ParamType int 10 | 11 | const ( 12 | InvalidType ParamType = iota 13 | BoolType 14 | ByteType 15 | FloatType 16 | IntType 17 | TextType 18 | TimestampType 19 | ) 20 | 21 | var paramTypeMap = map[string]ParamType{ 22 | "text": TextType, 23 | "string": TextType, 24 | "str": TextType, 25 | "varchar": TextType, 26 | "integer": IntType, 27 | "int": IntType, 28 | "float": FloatType, 29 | "float64": FloatType, 30 | "double": FloatType, 31 | "bool": BoolType, 32 | "boolean": BoolType, 33 | "time": TimestampType, 34 | "timestamp": TimestampType, 35 | "byte": ByteType, 36 | "tinyint": ByteType, 37 | } 38 | 39 | func (p ParamType) String() string { 40 | switch p { 41 | case InvalidType: 42 | return "invalid" 43 | case BoolType: 44 | return "bool" 45 | case ByteType: 46 | return "byte" 47 | case FloatType: 48 | return "float" 49 | case IntType: 50 | return "integer" 51 | case TextType: 52 | return "text" 53 | case TimestampType: 54 | return "timestamp" 55 | default: 56 | return "unknown" 57 | } 58 | } 59 | 60 | func (p ParamType) MarshalJSON() ([]byte, error) { 61 | return json.Marshal(p.String()) 62 | } 63 | 64 | func (p *ParamType) UnmarshalJSON(data []byte) error { 65 | var s string 66 | if err := json.Unmarshal(data, &s); err != nil { 67 | return fmt.Errorf("data should be a string, got %s", data) 68 | } 69 | pt, ok := paramTypeMap[s] 70 | if !ok { 71 | return fmt.Errorf("invalid UserRole %s", s) 72 | } 73 | *p = pt 74 | return nil 75 | } 76 | 77 | type MatchRule int 78 | 79 | const ( 80 | SelectExactMatch MatchRule = iota + 1 81 | SelectMatch 82 | ExecExactMatch 83 | ExecMatch 84 | ) 85 | 86 | var matchRuleMap = map[string]MatchRule{ 87 | "select(exact-order)": SelectExactMatch, 88 | "select": SelectExactMatch, 89 | "select(free-order)": SelectMatch, 90 | "exec(exact-order)": ExecExactMatch, 91 | "exec(free-order)": ExecMatch, 92 | "exec": ExecMatch, 93 | } 94 | 95 | func (m MatchRule) String() string { 96 | switch m { 97 | case SelectExactMatch: 98 | return "select(exact-order)" 99 | case SelectMatch: 100 | return "select(free-order)" 101 | case ExecExactMatch: 102 | return "exec(exact-order)" 103 | case ExecMatch: 104 | return "exec(free-order)" 105 | default: 106 | return "" 107 | } 108 | } 109 | 110 | func (m MatchRule) MarshalJSON() ([]byte, error) { 111 | return json.Marshal(m.String()) 112 | } 113 | 114 | func (m *MatchRule) UnmarshalJSON(data []byte) error { 115 | var s string 116 | if err := json.Unmarshal(data, &s); err != nil { 117 | return fmt.Errorf("data should be a string, got %s", data) 118 | } 119 | mr, ok := matchRuleMap[s] 120 | if !ok { 121 | return fmt.Errorf("invalid UserRole %s", s) 122 | } 123 | *m = mr 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /markdown_test.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | 7 | "github.com/future-architect/go-twowaysql/private/testhelper" 8 | gocmp "github.com/google/go-cmp/cmp" 9 | "gotest.tools/v3/assert" 10 | "gotest.tools/v3/assert/cmp" 11 | ) 12 | 13 | func init() { 14 | log.SetFlags(log.LstdFlags | log.Lshortfile) 15 | } 16 | 17 | func TestParseMarkdown(t *testing.T) { 18 | type args struct { 19 | src string 20 | } 21 | tests := []struct { 22 | name string 23 | args args 24 | want *Document 25 | wantErr string 26 | }{ 27 | { 28 | name: "title and sql", 29 | args: args{ 30 | src: testhelper.TrimIndent(t, ` 31 | # Search User Query 32 | 33 | ~~~sql 34 | SELECT email, name FROM persons WHERE first_name=/*first_name*/'bob'; 35 | ~~~ 36 | `), 37 | }, 38 | want: &Document{ 39 | Title: "Search User Query", 40 | SQL: `SELECT email, name FROM persons WHERE first_name=/*first_name*/'bob';`, 41 | }, 42 | }, 43 | { 44 | name: "with parameter", 45 | args: args{ 46 | src: testhelper.TrimIndent(t, ` 47 | # Search User Query 48 | 49 | ~~~sql 50 | SELECT email, name FROM persons WHERE first_name=/*first_name*/'bob'; 51 | ~~~ 52 | 53 | ## Parameter 54 | 55 | | Name | Type | Description | 56 | |------------|--------|-------------| 57 | | first_name | string | search key | 58 | `), 59 | }, 60 | want: &Document{ 61 | Title: "Search User Query", 62 | SQL: `SELECT email, name FROM persons WHERE first_name=/*first_name*/'bob';`, 63 | Params: []Param{ 64 | { 65 | Name: "first_name", 66 | Type: TextType, 67 | Description: "search key", 68 | }, 69 | }, 70 | }, 71 | }, 72 | { 73 | name: "with CRUD matrix", 74 | args: args{ 75 | src: testhelper.TrimIndent(t, ` 76 | # Search User Query 77 | 78 | ~~~sql 79 | SELECT email, name FROM persons WHERE first_name=/*first_name*/'bob'; 80 | ~~~ 81 | 82 | ## CRUD Matrix 83 | 84 | | Table | C | R | U | D | Description | 85 | |------------|---|---|---|---|-------------| 86 | | persons | X | | | | | 87 | `), 88 | }, 89 | want: &Document{ 90 | Title: "Search User Query", 91 | SQL: `SELECT email, name FROM persons WHERE first_name=/*first_name*/'bob';`, 92 | CRUDMatrix: []CRUDMatrix{ 93 | { 94 | Table: "persons", 95 | C: true, 96 | R: false, 97 | U: false, 98 | D: false, 99 | }, 100 | }, 101 | }, 102 | }, 103 | } 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | got, err := ParseMarkdownString(tt.args.src) 107 | if tt.wantErr != "" { 108 | assert.Error(t, err, tt.wantErr) 109 | } else { 110 | assert.NilError(t, err) 111 | assert.Check(t, cmp.DeepEqual(tt.want, got, gocmp.AllowUnexported(Document{}))) 112 | } 113 | }) 114 | } 115 | } 116 | 117 | func TestParseMarkdownTestCases(t *testing.T) { 118 | type args struct { 119 | src string 120 | } 121 | tests := []struct { 122 | name string 123 | args args 124 | want *Document 125 | wantErr string 126 | }{ 127 | { 128 | name: "with common test fixture in yaml (nested array)", 129 | args: args{ 130 | src: testhelper.TrimIndent(t, ` 131 | # Common Test Fixtures 132 | 133 | ## Test 134 | 135 | ~~~yaml 136 | fixtures: 137 | persons: 138 | - [employee_no, dept_no, first_name, last_name, email] 139 | - [1, 10, Evan, MacMans, evanmacmans@example.com] 140 | - [2, 11, Malvin, FitzSimons, malvinafitzsimons@example.com] 141 | - [3, 12, Jimmie, Bruce, jimmiebruce@example.com] 142 | ~~~ 143 | `), 144 | }, 145 | want: &Document{ 146 | Title: "Common Test Fixtures", 147 | CommonTestFixture: Fixture{ 148 | Lang: "yaml", 149 | Tables: []Table{ 150 | { 151 | Name: "persons", 152 | Cells: [][]string{ 153 | {"employee_no", "dept_no", "first_name", "last_name", "email"}, 154 | {"1", "10", "Evan", "MacMans", "evanmacmans@example.com"}, 155 | {"2", "11", "Malvin", "FitzSimons", "malvinafitzsimons@example.com"}, 156 | {"3", "12", "Jimmie", "Bruce", "jimmiebruce@example.com"}, 157 | }, 158 | }, 159 | }, 160 | }, 161 | }, 162 | }, 163 | { 164 | name: "with common test fixture in yaml (object list)", 165 | args: args{ 166 | src: testhelper.TrimIndent(t, ` 167 | # Common Test Fixtures 168 | 169 | ## Test 170 | 171 | ~~~yaml 172 | fixtures: 173 | persons: 174 | - { employee_no: 1, dept_no: 10, first_name: Evan, last_name: MacMans, email: evanmacmans@example.com } 175 | - { employee_no: 2, dept_no: 11, first_name: Malvin, last_name: FitzSimons, email: malvinafitzsimons@example.com } 176 | - { employee_no: 3, dept_no: 12, first_name: Jimmie, last_name: Bruce, email: jimmiebruce@example.com } 177 | ~~~ 178 | `), 179 | }, 180 | want: &Document{ 181 | Title: "Common Test Fixtures", 182 | CommonTestFixture: Fixture{ 183 | Lang: "yaml", 184 | Tables: []Table{ 185 | { 186 | Name: "persons", 187 | Cells: [][]string{ 188 | {"dept_no", "email", "employee_no", "first_name", "last_name"}, 189 | {"10", "evanmacmans@example.com", "1", "Evan", "MacMans"}, 190 | {"11", "malvinafitzsimons@example.com", "2", "Malvin", "FitzSimons"}, 191 | {"12", "jimmiebruce@example.com", "3", "Jimmie", "Bruce"}, 192 | }, 193 | }, 194 | }, 195 | }, 196 | }, 197 | }, 198 | { 199 | name: "with common test fixture in sql", 200 | args: args{ 201 | src: testhelper.TrimIndent(t, ` 202 | # Common Test Fixtures 203 | 204 | ~~~sql 205 | SELECT email FROM persons WHERE first_name=/*first_name*/'bob'; 206 | ~~~ 207 | 208 | ## Test 209 | 210 | ~~~sql 211 | DELETE FROM persons; 212 | ~~~ 213 | `), 214 | }, 215 | want: &Document{ 216 | Title: "Common Test Fixtures", 217 | SQL: "SELECT email FROM persons WHERE first_name=/*first_name*/'bob';", 218 | CommonTestFixture: Fixture{ 219 | Lang: "sql", 220 | Code: "DELETE FROM persons;", 221 | }, 222 | }, 223 | }, 224 | { 225 | name: "select test case (result is map list)", 226 | args: args{ 227 | src: testhelper.TrimIndent(t, ` 228 | # Test Cases 229 | 230 | ~~~sql 231 | SELECT email FROM persons WHERE first_name=/*first_name*/'bob'; 232 | ~~~ 233 | 234 | ## Test 235 | 236 | ### Case: select test 237 | 238 | ~~~yaml 239 | fixtures: 240 | persons: 241 | - [employee_no, dept_no, first_name, last_name, email] 242 | - [1, 10, Evan, MacMans, evanmacmans@example.com] 243 | params: { first_name: Evan } 244 | expect: 245 | - { email: evanmacmans@example.com } 246 | ~~~ 247 | `), 248 | }, 249 | want: &Document{ 250 | Title: "Test Cases", 251 | SQL: "SELECT email FROM persons WHERE first_name=/*first_name*/'bob';", 252 | TestCases: []TestCase{ 253 | { 254 | Name: "select test", 255 | Fixtures: []Table{ 256 | { 257 | Name: "persons", 258 | Cells: [][]string{ 259 | {"employee_no", "dept_no", "first_name", "last_name", "email"}, 260 | {"1", "10", "Evan", "MacMans", "evanmacmans@example.com"}, 261 | }, 262 | }, 263 | }, 264 | Params: map[string]string{"first_name": "Evan"}, 265 | Expect: [][]string{ 266 | {"email"}, {"evanmacmans@example.com"}, 267 | }, 268 | }, 269 | }, 270 | }, 271 | }, 272 | { 273 | name: "select test case (result is nested list)", 274 | args: args{ 275 | src: testhelper.TrimIndent(t, ` 276 | # Test Cases 277 | 278 | ~~~sql 279 | SELECT email FROM persons WHERE first_name=/*first_name*/'bob'; 280 | ~~~ 281 | 282 | ## Test 283 | 284 | ### Case: select test 285 | 286 | ~~~yaml 287 | fixtures: 288 | persons: 289 | - [employee_no, dept_no, first_name, last_name, email] 290 | - [1, 10, Evan, MacMans, evanmacmans@example.com] 291 | params: { first_name: Evan } 292 | expect: 293 | - [ email ] 294 | - [ evanmacmans@example.com ] 295 | ~~~ 296 | `), 297 | }, 298 | want: &Document{ 299 | Title: "Test Cases", 300 | SQL: "SELECT email FROM persons WHERE first_name=/*first_name*/'bob';", 301 | TestCases: []TestCase{ 302 | { 303 | Name: "select test", 304 | Fixtures: []Table{ 305 | { 306 | Name: "persons", 307 | Cells: [][]string{ 308 | {"employee_no", "dept_no", "first_name", "last_name", "email"}, 309 | {"1", "10", "Evan", "MacMans", "evanmacmans@example.com"}, 310 | }, 311 | }, 312 | }, 313 | Params: map[string]string{"first_name": "Evan"}, 314 | Expect: [][]string{ 315 | {"email"}, {"evanmacmans@example.com"}, 316 | }, 317 | }, 318 | }, 319 | }, 320 | }, 321 | { 322 | name: "delete test case", 323 | args: args{ 324 | src: testhelper.TrimIndent(t, ` 325 | # Test Cases 326 | 327 | ~~~sql 328 | DELETE FROM persons; 329 | ~~~ 330 | 331 | ## Test 332 | 333 | ### Case: delete test 334 | 335 | ~~~yaml 336 | testQuery: SELECT count(employee_no) FROM persons; 337 | expect: 338 | - { count: 1 } 339 | ~~~ 340 | `), 341 | }, 342 | want: &Document{ 343 | Title: "Test Cases", 344 | SQL: "DELETE FROM persons;", 345 | TestCases: []TestCase{ 346 | { 347 | Name: "delete test", 348 | TestQuery: `SELECT count(employee_no) FROM persons;`, 349 | Expect: [][]string{ 350 | {"count"}, {"1"}, 351 | }, 352 | }, 353 | }, 354 | }, 355 | }, 356 | { 357 | name: "error: unknown field key in yaml", 358 | args: args{ 359 | src: testhelper.TrimIndent(t, ` 360 | # Test Cases 361 | 362 | ~~~sql 363 | DELETE FROM persons; 364 | ~~~ 365 | 366 | ## Test 367 | 368 | ### Case: delete test 369 | 370 | testQueries should be testQuery. 371 | results should be result. 372 | 373 | ~~~yaml 374 | testQueries: SELECT count(employee_no) FROM persons; 375 | results: 376 | - { count: 1 } 377 | ~~~ 378 | `), 379 | }, 380 | wantErr: "YAML keys results, testQueries is invalid in delete test of Test Cases (expect, fixtures, params, testQuery are acceptable)", 381 | }, 382 | } 383 | for _, tt := range tests { 384 | t.Run(tt.name, func(t *testing.T) { 385 | got, err := ParseMarkdownString(tt.args.src) 386 | if tt.wantErr != "" { 387 | assert.Error(t, err, tt.wantErr) 388 | } else { 389 | assert.NilError(t, err) 390 | assert.Check(t, cmp.DeepEqual(tt.want, got, gocmp.AllowUnexported(Document{}))) 391 | } 392 | }) 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/robertkrimen/otto" 7 | ) 8 | 9 | type tokenGroup struct { 10 | tokens []token 11 | } 12 | 13 | func parseCondition(tokens []token, mapParams map[string]interface{}) ([]token, error) { 14 | var tokenGroups []tokenGroup 15 | var tmpTokens []token 16 | var idx int 17 | for idx < len(tokens) { 18 | if tokens[idx].kind != tkIf { 19 | tmpTokens = append(tmpTokens, tokens[idx]) 20 | idx++ 21 | continue 22 | } 23 | tokenGroups = append(tokenGroups, tokenGroup{tokens: tmpTokens}) 24 | tmpTokens = []token{} 25 | 26 | iftokenGroup, err := parseIftokenGroup(tokens, &idx, mapParams) 27 | if err != nil { 28 | return nil, err 29 | } 30 | tokenGroups = append(tokenGroups, tokenGroup{tokens: iftokenGroup}) 31 | } 32 | if len(tmpTokens) != 0 { 33 | tokenGroups = append(tokenGroups, tokenGroup{tokens: tmpTokens}) 34 | } 35 | 36 | var generatedTokens []token 37 | for _, tg := range tokenGroups { 38 | generatedTokens = append(generatedTokens, tg.tokens...) 39 | } 40 | 41 | if generatedTokens[len(generatedTokens)-1].kind != tkEndOfProgram { 42 | // 末尾に EndOfProgram を追加 43 | generatedTokens = append(generatedTokens, token{kind: tkEndOfProgram}) 44 | } 45 | // 構文エラーチェックのため生成 token 全体でも解析を行う 46 | _, err := ast(generatedTokens) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return generatedTokens, nil 52 | } 53 | 54 | func parseIftokenGroup(tokens []token, idx *int, mapParams map[string]interface{}) ([]token, error) { 55 | tmpTokens := []token{} 56 | iftokenGroup := []token{tokens[*idx]} // IF 57 | *idx++ 58 | for { 59 | if *idx >= len(tokens) { 60 | return nil, fmt.Errorf("can not parse: not found /* END */") 61 | } 62 | // nest IF 63 | if tokens[*idx].kind == tkIf { 64 | nestTokens, err := parseIftokenGroup(tokens, idx, mapParams) 65 | if err != nil { 66 | return nil, err 67 | } 68 | tmpTokens = append(tmpTokens, nestTokens...) 69 | // idx は parseIftokens 内で進んでいるためプラスしない 70 | continue 71 | } 72 | // ELSE/ELIF 73 | if tokens[*idx].kind == tkElse || tokens[*idx].kind == tkElif { 74 | nestTokens, err := parseCondition(tmpTokens, mapParams) 75 | if err != nil { 76 | return nil, err 77 | } 78 | // ネストしたブロックを解析する際に末尾に EndOfProgram が付与されるため除去 79 | if len(nestTokens) > 0 && nestTokens[len(nestTokens)-1].kind == tkEndOfProgram { 80 | nestTokens = nestTokens[0 : len(nestTokens)-1] 81 | } 82 | tmpTokens = nestTokens 83 | tmpTokens = append(tmpTokens, tokens[*idx]) // ELSE/ELIF を追加 84 | iftokenGroup = append(iftokenGroup, tmpTokens...) 85 | tmpTokens = []token{} 86 | *idx++ 87 | continue 88 | } 89 | 90 | if tokens[*idx].kind != tkEnd { 91 | tmpTokens = append(tmpTokens, tokens[*idx]) 92 | *idx++ 93 | continue 94 | } 95 | 96 | // END 97 | iftokenGroup = append(iftokenGroup, tmpTokens...) // IF ブロック内 98 | iftokenGroup = append(iftokenGroup, tokens[*idx]) // END 99 | tmpTokens = []token{} 100 | *idx++ 101 | break 102 | } 103 | 104 | // IF ブロックのみで解析するため、末尾に EndOfProgram を追加 105 | iftokenGroup = append(iftokenGroup, token{kind: tkEndOfProgram}) 106 | tree, err := ast(iftokenGroup) 107 | if err != nil { 108 | return nil, err 109 | } 110 | generatedTokens, err := tree.parse(mapParams) 111 | if err != nil { 112 | return nil, err 113 | } 114 | if len(generatedTokens) > 0 && generatedTokens[len(generatedTokens)-1].kind == tkEndOfProgram { 115 | // 末尾の EndOfProgram を除去 116 | generatedTokens = generatedTokens[0 : len(generatedTokens)-1] 117 | } 118 | 119 | return generatedTokens, nil 120 | } 121 | 122 | // 抽象構文木からトークン列を生成 123 | // 左部分木、右部分木と辿る 124 | // 現状右部分木を持つのはif, elif, elseだけ? 125 | func (t *tree) parse(params map[string]interface{}) ([]token, error) { 126 | return genInner(t, params) 127 | } 128 | 129 | func genInner(node *tree, params map[string]interface{}) ([]token, error) { 130 | if node == nil { 131 | return []token{}, nil 132 | } 133 | 134 | // 行きがけ 135 | 136 | // 左部分木に行く 137 | leftStr, err := genInner(node.Left, params) 138 | if err != nil { 139 | return []token{}, err 140 | } 141 | 142 | // 左部分木から戻ってきた 143 | 144 | // 右部分木に行く 145 | rightStr, err := genInner(node.Right, params) 146 | if err != nil { 147 | return []token{}, err 148 | } 149 | 150 | // 右部分木から戻ってきた 151 | // 何を返すか 152 | // 基本的に左部分木 153 | // If Elifの場合は条件次第 154 | switch kind := node.Kind; kind { 155 | case ndSQLStmt, ndBind: 156 | // めちゃめちゃ実行効率悪い気が... 157 | return append([]token{*node.Token}, leftStr...), nil 158 | case ndIf, ndElif: 159 | truth, err := evalCondition(node.Token.condition, params) 160 | if err != nil { 161 | return []token{}, err 162 | } 163 | if truth { 164 | return leftStr, nil 165 | } 166 | return rightStr, nil 167 | default: 168 | return leftStr, nil 169 | } 170 | } 171 | 172 | // /* If ... */ /* Elif ... */の条件を評価する 173 | func evalCondition(condition string, params map[string]interface{}) (bool, error) { 174 | vm := otto.New() 175 | for key, value := range params { 176 | err := vm.Set(key, value) 177 | if err != nil { 178 | return false, err 179 | } 180 | } 181 | 182 | result, err := vm.Run(condition) 183 | if err != nil { 184 | return false, err 185 | } 186 | 187 | truth, err := result.ToBoolean() 188 | if err != nil { 189 | return false, err 190 | } 191 | 192 | return truth, nil 193 | } 194 | -------------------------------------------------------------------------------- /parse_test.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParse(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | input *tree 11 | want []token 12 | }{ 13 | { 14 | name: "", 15 | input: wantEmpty, 16 | want: []token{}, 17 | }, 18 | { 19 | name: "no comment", 20 | input: wantNoComment, 21 | want: []token{ 22 | { 23 | kind: tkSQLStmt, 24 | str: "SELECT * FROM person WHERE employee_no < 1000 AND dept_no = 1", 25 | }, 26 | }, 27 | }, 28 | { 29 | name: "if", 30 | input: wantTreeIf, 31 | want: []token{ 32 | { 33 | kind: tkSQLStmt, 34 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 35 | }, 36 | { 37 | kind: tkSQLStmt, 38 | str: " AND dept_no = 1", 39 | }, 40 | }, 41 | }, 42 | { 43 | name: "if and bind", 44 | input: wantTreeIfBind, 45 | want: []token{ 46 | { 47 | kind: tkSQLStmt, 48 | str: "SELECT * FROM person WHERE employee_no < ", 49 | }, 50 | { 51 | kind: tkBind, 52 | str: "?/*maxEmpNo*/", 53 | value: "maxEmpNo", 54 | }, 55 | { 56 | kind: tkSQLStmt, 57 | str: " ", 58 | }, 59 | }, 60 | }, 61 | { 62 | name: "if elif else", 63 | input: wantIfElifElse, 64 | want: []token{ 65 | { 66 | kind: tkSQLStmt, 67 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 68 | }, 69 | { 70 | kind: tkSQLStmt, 71 | str: "AND dept_no =1", 72 | }, 73 | }, 74 | }, 75 | { 76 | name: "if nest", 77 | input: wantIfNest, 78 | want: []token{ 79 | { 80 | kind: tkSQLStmt, 81 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 82 | }, 83 | { 84 | kind: tkSQLStmt, 85 | str: " ", 86 | }, 87 | { 88 | kind: tkSQLStmt, 89 | str: " AND id=3 ", 90 | }, 91 | }, 92 | }, 93 | } 94 | 95 | for _, tt := range tests { 96 | t.Run(tt.name, func(t *testing.T) { 97 | if got, err := tt.input.parse(map[string]interface{}{}); err != nil || !tokensEqual(tt.want, got) { 98 | if err != nil { 99 | t.Error(err) 100 | } 101 | if !tokensEqual(tt.want, got) { 102 | t.Errorf("Doesn't Match:\nexpected: \n%v\n but got: \n%v\n", tt.want, got) 103 | } 104 | } 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /private/testhelper/testhelper.go: -------------------------------------------------------------------------------- 1 | package testhelper 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | var stripSpacePattern = regexp.MustCompile("(^[ \t]*)") 12 | 13 | func TrimIndent(t *testing.T, src string) string { 14 | t.Helper() 15 | lines := strings.Split(src, "\n") 16 | if lines[0] == "" { 17 | lines = lines[1:] 18 | } 19 | matches := stripSpacePattern.FindStringSubmatch(lines[0]) 20 | var b strings.Builder 21 | for i, line := range lines { 22 | b.WriteString(strings.TrimPrefix(line, matches[0])) 23 | if i != len(lines)-1 { 24 | b.WriteByte('\n') 25 | } 26 | } 27 | return b.String() 28 | } 29 | 30 | func SourceStr(t *testing.T) string { 31 | t.Helper() 32 | if host, ok := os.LookupEnv("POSTGRES_HOST"); ok { 33 | return fmt.Sprintf("host=%s user=postgres password=postgres dbname=postgres sslmode=disable", host) 34 | } else { 35 | return "host=localhost user=postgres password=postgres dbname=postgres sslmode=disable" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /sqltest/testing.go: -------------------------------------------------------------------------------- 1 | package sqltest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/future-architect/go-exceltesting" 10 | "github.com/future-architect/go-twowaysql" 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/jmoiron/sqlx" 13 | ) 14 | 15 | type callbackForTest struct { 16 | t *testing.T 17 | } 18 | 19 | func (c callbackForTest) StartTest(doc *twowaysql.Document, tc twowaysql.TestCase) { 20 | c.t.Log(" Executing Query for Fixture") 21 | } 22 | 23 | func (c callbackForTest) ExecFixture(doc *twowaysql.Document, tc twowaysql.TestCase) { 24 | c.t.Log(" Executing Query for Fixture") 25 | } 26 | 27 | func (c callbackForTest) InsertFixtureTable(doc *twowaysql.Document, tc twowaysql.TestCase, table twowaysql.Table) { 28 | c.t.Logf(" Inserting Fixture Table: %s", table.Name) 29 | } 30 | 31 | func (c callbackForTest) Exec(doc *twowaysql.Document, tc twowaysql.TestCase) { 32 | c.t.Logf("Exec Query with: %v", tc.Params) 33 | } 34 | 35 | func (c callbackForTest) ExecTestQuery(doc *twowaysql.Document, tc twowaysql.TestCase) { 36 | c.t.Log("Exec Query for getting result") 37 | } 38 | 39 | func (c callbackForTest) EndTest(doc *twowaysql.Document, tc twowaysql.TestCase, failure, err error) { 40 | if failure != nil { 41 | c.t.Errorf("Test Result: Failure: %v", err) 42 | } else if err != nil { 43 | c.t.Errorf("Test Result: Error: %v", err) 44 | } else { 45 | c.t.Log("Test Result: OK") 46 | } 47 | } 48 | 49 | var _ Callback = &callbackForTest{} 50 | 51 | func RunInTest(ctx context.Context, t *testing.T, db *sqlx.DB, doc *twowaysql.Document) { 52 | t.Helper() 53 | 54 | Run(ctx, db, doc, &callbackForTest{t: t}) 55 | } 56 | 57 | func RunInTests(ctx context.Context, t *testing.T, db *sqlx.DB, docs map[string]*twowaysql.Document) { 58 | t.Helper() 59 | for k, doc := range docs { 60 | t.Run(k, func(t *testing.T) { 61 | RunInTest(ctx, t, db, doc) 62 | }) 63 | } 64 | } 65 | 66 | type Callback interface { 67 | StartTest(doc *twowaysql.Document, tc twowaysql.TestCase) 68 | ExecFixture(doc *twowaysql.Document, tc twowaysql.TestCase) 69 | InsertFixtureTable(doc *twowaysql.Document, tc twowaysql.TestCase, tb twowaysql.Table) 70 | Exec(doc *twowaysql.Document, tc twowaysql.TestCase) 71 | ExecTestQuery(doc *twowaysql.Document, tc twowaysql.TestCase) 72 | EndTest(doc *twowaysql.Document, tc twowaysql.TestCase, failure, err error) 73 | } 74 | 75 | func Run(ctx context.Context, db *sqlx.DB, doc *twowaysql.Document, cb Callback) (failureCount, errCount int, err error) { 76 | err = func() error { 77 | tws := twowaysql.New(db) 78 | tx, err := tws.Begin(ctx) 79 | if err != nil { 80 | return fmt.Errorf("database connection test error: %w", err) 81 | } 82 | tx.Rollback() 83 | return nil 84 | }() 85 | if err != nil { 86 | return 0, 0, err 87 | } 88 | for _, tc := range doc.TestCases { 89 | func() { 90 | cb.StartTest(doc, tc) 91 | 92 | tws := twowaysql.New(db) 93 | tx, err := tws.Begin(ctx) 94 | if err != nil { 95 | errCount++ 96 | cb.EndTest(doc, tc, nil, err) 97 | return 98 | } 99 | defer tx.Rollback() 100 | switch doc.CommonTestFixture.Lang { 101 | case "sql": 102 | cb.ExecFixture(doc, tc) 103 | _, err := tx.Tx().ExecContext(ctx, doc.CommonTestFixture.Code) 104 | if err != nil { 105 | errCount++ 106 | cb.EndTest(doc, tc, nil, fmt.Errorf("common fixture exec error in %s: %w", tc.Name, err)) 107 | return 108 | } 109 | case "yaml": 110 | for _, t := range doc.CommonTestFixture.Tables { 111 | cb.InsertFixtureTable(doc, tc, t) 112 | err := exceltesting.LoadRaw(tx.Tx().Tx, exceltesting.LoadRawRequest{ 113 | TableName: t.Name, 114 | Columns: t.Cells[0], 115 | Values: t.Cells[1:], 116 | }) 117 | if err != nil { 118 | errCount++ 119 | cb.EndTest(doc, tc, nil, fmt.Errorf("common fixture error for %s table in %s: %w", t.Name, tc.Name, err)) 120 | return 121 | } 122 | } 123 | } 124 | for _, t := range tc.Fixtures { 125 | cb.InsertFixtureTable(doc, tc, t) 126 | err := exceltesting.LoadRaw(tx.Tx().Tx, exceltesting.LoadRawRequest{ 127 | TableName: t.Name, 128 | Columns: t.Cells[0], 129 | Values: t.Cells[1:], 130 | }) 131 | if err != nil { 132 | errCount++ 133 | cb.EndTest(doc, tc, nil, fmt.Errorf("fixture error for %s table in %s: %w", t.Name, tc.Name, err)) 134 | return 135 | } 136 | } 137 | var result []map[string]any 138 | if tc.TestQuery == "" { 139 | cb.Exec(doc, tc) 140 | err := tx.Select(ctx, &result, doc.SQL, tc.Params) 141 | if err != nil { 142 | errCount++ 143 | cb.EndTest(doc, tc, nil, fmt.Errorf("exec SQL error in %s: %w", tc.Name, err)) 144 | return 145 | } 146 | } else { 147 | cb.Exec(doc, tc) 148 | _, err := tx.Exec(ctx, doc.SQL, tc.Params) 149 | if err != nil { 150 | errCount++ 151 | cb.EndTest(doc, tc, nil, fmt.Errorf("exec SQL error in %s: %w", tc.Name, err)) 152 | return 153 | } 154 | cb.ExecTestQuery(doc, tc) 155 | err = tx.Select(ctx, &result, tc.TestQuery, nil) 156 | if err != nil { 157 | errCount++ 158 | cb.EndTest(doc, tc, nil, fmt.Errorf("exec SQL error for result in %s: %w", tc.Name, err)) 159 | return 160 | } 161 | } 162 | fail := compare(tc.Expect, result) 163 | if fail != nil { 164 | failureCount++ 165 | } 166 | cb.EndTest(doc, tc, fail, nil) 167 | }() 168 | } 169 | return failureCount, errCount, nil 170 | } 171 | 172 | func compare(expectedCells [][]string, actual []map[string]any) error { 173 | var expected []map[string]any 174 | if len(expectedCells) == 0 { 175 | if len(actual) == 0 { 176 | return nil 177 | } 178 | if diff := cmp.Diff(expected, actual); diff != "" { 179 | return fmt.Errorf("result mismatch: %s", diff) 180 | } 181 | } 182 | header := expectedCells[0] 183 | expectRows := expectedCells[1:] 184 | if len(actual) > 0 { 185 | for _, r := range expectRows { 186 | row := make(map[string]any) 187 | for i, h := range header { 188 | switch actual[0][h].(type) { 189 | case int: 190 | integer, err := strconv.ParseInt(r[i], 10, 64) 191 | if err != nil { 192 | row[h] = r[i] 193 | } else { 194 | row[h] = int(integer) 195 | } 196 | case int64: 197 | integer, err := strconv.ParseInt(r[i], 10, 64) 198 | if err != nil { 199 | row[h] = r[i] 200 | } else { 201 | row[h] = integer 202 | } 203 | case float64: 204 | f, err := strconv.ParseFloat(r[i], 64) 205 | if err != nil { 206 | row[h] = r[i] 207 | } else { 208 | row[h] = f 209 | } 210 | case bool: 211 | row[h] = r[i] == "true" 212 | default: 213 | row[h] = r[i] 214 | } 215 | } 216 | expected = append(expected, row) 217 | } 218 | } else { 219 | for _, r := range expectRows { 220 | row := make(map[string]any) 221 | for i, h := range header { 222 | row[h] = r[i] 223 | } 224 | expected = append(expected, row) 225 | } 226 | } 227 | if diff := cmp.Diff(expected, actual); diff != "" { 228 | return fmt.Errorf("result mismatch: %s", diff) 229 | } 230 | return nil 231 | } 232 | -------------------------------------------------------------------------------- /sqltest/testing_test.go: -------------------------------------------------------------------------------- 1 | package sqltest 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "testing" 7 | "time" 8 | 9 | "github.com/future-architect/go-twowaysql" 10 | "github.com/future-architect/go-twowaysql/private/testhelper" 11 | "github.com/jmoiron/sqlx" 12 | "github.com/stretchr/testify/assert" 13 | 14 | _ "github.com/jackc/pgx/v4/stdlib" 15 | ) 16 | 17 | func init() { 18 | log.SetFlags(log.LstdFlags | log.Lshortfile) 19 | } 20 | 21 | func rowCount(t *testing.T, db *sqlx.DB) int { 22 | var count int 23 | r, err := db.DB.Query("select count(*) from persons;") 24 | if err != nil { 25 | panic(err) 26 | } 27 | defer r.Close() 28 | for r.Next() { 29 | err := r.Scan(&count) 30 | if err != nil { 31 | panic(err) 32 | } 33 | } 34 | return count 35 | } 36 | 37 | type dummyCallback struct { 38 | t *testing.T 39 | } 40 | 41 | func (dc dummyCallback) StartTest(doc *twowaysql.Document, tc twowaysql.TestCase) { 42 | } 43 | 44 | func (dc dummyCallback) ExecFixture(doc *twowaysql.Document, tc twowaysql.TestCase) { 45 | } 46 | 47 | func (dc dummyCallback) InsertFixtureTable(doc *twowaysql.Document, tc twowaysql.TestCase, tb twowaysql.Table) { 48 | } 49 | 50 | func (dc dummyCallback) Exec(doc *twowaysql.Document, tc twowaysql.TestCase) { 51 | } 52 | 53 | func (dc dummyCallback) ExecTestQuery(doc *twowaysql.Document, tc twowaysql.TestCase) { 54 | } 55 | 56 | func (dc dummyCallback) EndTest(doc *twowaysql.Document, tc twowaysql.TestCase, failure error, err error) { 57 | if failure != nil { 58 | dc.t.Logf("Failure: %v", failure) 59 | } 60 | if err != nil { 61 | dc.t.Logf("Error: %v", err) 62 | } 63 | } 64 | 65 | var _ Callback = &dummyCallback{} 66 | 67 | func TestRun(t *testing.T) { 68 | driver := "pgx" 69 | srcStr := testhelper.SourceStr(t) 70 | db, err := sqlx.Open(driver, srcStr) 71 | if err != nil { 72 | panic(err) 73 | } 74 | count := rowCount(t, db) 75 | type args struct { 76 | src string 77 | } 78 | tests := []struct { 79 | name string 80 | args args 81 | wantErr string 82 | wantFailureCount int 83 | wantErrorCount int 84 | wantTests int 85 | }{ 86 | { 87 | name: "query test", 88 | args: args{ 89 | src: testhelper.TrimIndent(t, ` 90 | # Select Query 91 | 92 | ~~~sql 93 | SELECT email, first_name, last_name FROM persons WHERE first_name=/*first_name*/'bob'; 94 | ~~~ 95 | 96 | ## Tests 97 | 98 | ### Case: Query Evan 99 | 100 | ~~~yaml 101 | params: { first_name: Evan } 102 | expect: 103 | - { email: evanmacmans@example.com, first_name: Evan, last_name: MacMans } 104 | ~~~ 105 | `), 106 | }, 107 | wantErr: "", 108 | wantTests: 1, 109 | }, 110 | { 111 | name: "exec test", 112 | args: args{ 113 | src: testhelper.TrimIndent(t, ` 114 | # Select Query 115 | 116 | ~~~sql 117 | INSERT INTO persons (employee_no, dept_no, email, first_name, last_name, created_at) VALUES (/*en*/1, /*dn*/10, /*em*/'a@examplecom', /*fn*/'a', /*ln*/'b', CURRENT_TIMESTAMP); 118 | ~~~ 119 | 120 | ## Tests 121 | 122 | ### Case: Query Evan 123 | 124 | ~~~yaml 125 | params: { en: 4, dn: 13, em: 'dan@example.com', fn: 'Dan', ln: 'Connor' } 126 | testQuery: SELECT count(employee_no) FROM persons; 127 | expect: 128 | - { count: 4 } 129 | ~~~ 130 | `), 131 | }, 132 | wantErr: "", 133 | wantTests: 1, 134 | }, 135 | { 136 | name: "query test with global SQL fixture", 137 | args: args{ 138 | src: testhelper.TrimIndent(t, ` 139 | # Select Query 140 | 141 | ~~~sql 142 | SELECT email, first_name, last_name FROM persons WHERE first_name=/*first_name*/'bob'; 143 | ~~~ 144 | 145 | ## Tests 146 | 147 | ~~~sql 148 | INSERT INTO persons (employee_no, dept_no, email, first_name, last_name, created_at) VALUES ( 149 | 4, 13, 'dan@example.com', 'Dan', 'Conner', CURRENT_TIMESTAMP 150 | ); 151 | ~~~ 152 | 153 | ### Case: Query Evan 154 | 155 | ~~~yaml 156 | params: { first_name: Dan } 157 | expect: 158 | - { email: dan@example.com, first_name: Dan, last_name: Conner } 159 | ~~~ 160 | `), 161 | }, 162 | wantErr: "", 163 | wantTests: 1, 164 | }, 165 | { 166 | name: "query test with global yaml fixture", 167 | args: args{ 168 | src: testhelper.TrimIndent(t, ` 169 | # Select Query 170 | 171 | ~~~sql 172 | SELECT email, first_name, last_name FROM persons WHERE first_name=/*first_name*/'bob'; 173 | ~~~ 174 | 175 | ## Tests 176 | 177 | ~~~yaml 178 | fixtures: 179 | persons: 180 | - { employee_no: 4, dept_no: 13, email: 'dan@example.com', first_name: 'Dan', last_name: 'Conner', created_at: '2022-09-13 10:30:15' } 181 | ~~~ 182 | 183 | ### Case: Query Dan 184 | 185 | ~~~yaml 186 | params: { first_name: Dan } 187 | expect: 188 | - { email: dan@example.com, first_name: Dan, last_name: Conner } 189 | ~~~ 190 | `), 191 | }, 192 | wantErr: "", 193 | wantTests: 1, 194 | }, 195 | { 196 | name: "query test with local yaml fixture (success)", 197 | args: args{ 198 | src: testhelper.TrimIndent(t, ` 199 | # Select Query 200 | 201 | ~~~sql 202 | SELECT email, first_name, last_name FROM persons WHERE first_name=/*first_name*/'bob'; 203 | ~~~ 204 | 205 | ## Tests 206 | 207 | ### Case: Query Dan 208 | 209 | ~~~yaml 210 | fixtures: 211 | persons: 212 | - { employee_no: 4, dept_no: 13, email: 'dan@example.com', first_name: 'Dan', last_name: 'Conner', created_at: '2022-09-13 10:30:15' } 213 | params: { first_name: Dan } 214 | expect: 215 | - { email: dan@example.com, first_name: Dan, last_name: Conner } 216 | ~~~ 217 | `), 218 | }, 219 | wantErr: "", 220 | wantTests: 1, 221 | }, 222 | { 223 | name: "query test with local yaml fixture (failure)", 224 | args: args{ 225 | src: testhelper.TrimIndent(t, ` 226 | # Select Query 227 | 228 | ~~~sql 229 | SELECT email, first_name, last_name FROM persons WHERE first_name=/*first_name*/'bob'; 230 | ~~~ 231 | 232 | ## Tests 233 | 234 | ### Case: Query Dan (fail) 235 | 236 | ~~~yaml 237 | fixtures: 238 | persons: 239 | - { employee_no: 4, dept_no: 13, email: 'dan@example.com', first_name: 'Dan', last_name: 'Conner', created_at: '2022-09-13 10:30:15' } 240 | params: { first_name: Dan } 241 | expect: 242 | - { email: dan@example.com, first_name: Dan, last_name: Evan } 243 | ~~~ 244 | `), 245 | }, 246 | wantErr: "", 247 | wantErrorCount: 0, 248 | wantFailureCount: 1, 249 | wantTests: 1, 250 | }, 251 | } 252 | for _, tt := range tests { 253 | t.Run(tt.name, func(t *testing.T) { 254 | doc, err := twowaysql.ParseMarkdownString(tt.args.src) 255 | assert.NoError(t, err) 256 | if err != nil { 257 | return 258 | } 259 | assert.Equal(t, tt.wantTests, len(doc.TestCases)) 260 | failureCount, errCount, err := Run(context.Background(), db, doc, &dummyCallback{t: t}) 261 | assert.Equal(t, tt.wantFailureCount, failureCount, "failure count") 262 | assert.Equal(t, tt.wantErrorCount, errCount, "err count") 263 | if tt.wantErr != "" { 264 | assert.Error(t, err, tt.wantErr) 265 | } else { 266 | assert.NoError(t, err) 267 | } 268 | // rollback keeps row count 269 | newCount := rowCount(t, db) 270 | assert.Equal(t, count, newCount) 271 | }) 272 | } 273 | } 274 | 275 | func TestRunInTest(t *testing.T) { 276 | driver := "pgx" 277 | srcStr := testhelper.SourceStr(t) 278 | db, err := sqlx.Open(driver, srcStr) 279 | if err != nil { 280 | panic(err) 281 | } 282 | 283 | src := testhelper.TrimIndent(t, ` 284 | # Select Query 285 | 286 | ~~~sql 287 | SELECT email, first_name, last_name FROM persons WHERE first_name=/*first_name*/'bob'; 288 | ~~~ 289 | 290 | ## Tests 291 | 292 | ### Case: Query Evan 293 | 294 | ~~~yaml 295 | params: { first_name: Evan } 296 | expect: 297 | - { email: evanmacmans@example.com, first_name: Evan, last_name: MacMans } 298 | ~~~ 299 | `) 300 | doc, err := twowaysql.ParseMarkdownString(src) 301 | assert.NoError(t, err) 302 | 303 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 304 | defer cancel() 305 | RunInTest(ctx, t, db, doc) 306 | } 307 | -------------------------------------------------------------------------------- /testdata/postgres/init/init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE persons ( 2 | employee_no INT PRIMARY KEY, 3 | dept_no INT, 4 | first_name VARCHAR(100), 5 | last_name VARCHAR(100), 6 | email VARCHAR(100), 7 | null_string VARCHAR(100), 8 | null_int INT, 9 | created_at timestamp with time zone NOT NULL, 10 | updated_at timestamp with time zone 11 | ); 12 | 13 | INSERT INTO persons(employee_no, dept_no, first_name, last_name, email, created_at) VALUES 14 | (1, 10, 'Evan', 'MacMans', 'evanmacmans@example.com', CURRENT_TIMESTAMP), 15 | (2, 11, 'Malvina', 'FitzSimons', 'malvinafitzsimons@example.com', CURRENT_TIMESTAMP), 16 | (3, 12, 'Jimmie', 'Bruce', 'jimmiebruce@example.com', CURRENT_TIMESTAMP) 17 | ; 18 | -------------------------------------------------------------------------------- /testdata/postgres/markdown/select_person.sql.md: -------------------------------------------------------------------------------- 1 | # Select Person 2 | 3 | This comment is ignored. 4 | 5 | ```sql 6 | SELECT email, first_name FROM persons WHERE first_name=/*first_name*/'bob'; 7 | ``` 8 | 9 | ## Parameter 10 | 11 | | Name | Type | Description | 12 | |------------|--------|-------------| 13 | | first_name | string | search key | 14 | 15 | ## CRUD Matrix 16 | 17 | | Table | C | R | U | D | Description | 18 | |------------|---|---|---|---|-------------| 19 | | persons | X | | | | | 20 | 21 | ## Tests 22 | 23 | ### Case: Query Evan Test 24 | 25 | ```yaml 26 | fixtures: 27 | persons: 28 | - [employee_no, dept_no, first_name, last_name, email, created_at] 29 | - [4, 13, Dan, Conner, dan@example.com, 2022-09-13 10:30:15] 30 | params: { first_name: Dan } 31 | expect: 32 | - { email: dan@example.com, first_name: Dan } 33 | ``` 34 | -------------------------------------------------------------------------------- /testdata/postgres/markdown/select_person_notest.sql.md: -------------------------------------------------------------------------------- 1 | # Select Person Without Test 2 | 3 | ```sql 4 | SELECT email, name FROM empty_persons WHERE first_name=/*first_name*/'bob'; 5 | ``` 6 | -------------------------------------------------------------------------------- /testdata/postgres/markdown/select_person_with_param.sql.md: -------------------------------------------------------------------------------- 1 | # Select Person With parameter Table 2 | 3 | ```sql 4 | SELECT email, first_name FROM persons WHERE first_name=/*first_name*/'bob'; 5 | ``` 6 | 7 | ## Parameter 8 | 9 | | Name | Type | Description | 10 | | ---------- | ------ | ----------- | 11 | | first_name | string | search key | 12 | 13 | ## Test 14 | 15 | ### Case: Query Empty Test 16 | 17 | ```yaml 18 | params: { first_name: Dan } 19 | expect: [] 20 | ``` 21 | 22 | ### Case: Query Test (Failure) 23 | 24 | ```yaml 25 | params: { first_name: Evan } 26 | expect: [] 27 | ``` 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /testdata/postgres/sql/delete_person.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM persons WHERE first_name=/*first_name*/'bob'; 2 | -------------------------------------------------------------------------------- /testdata/postgres/sql/init_database.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE persons ( 2 | employee_no INT PRIMARY KEY, 3 | dept_no INT, 4 | first_name VARCHAR(100), 5 | last_name VARCHAR(100), 6 | email VARCHAR(100), 7 | null_string VARCHAR(100), 8 | null_int INT, 9 | created_at timestamp with time zone NOT NULL, 10 | updated_at timestamp with time zone 11 | ); 12 | 13 | INSERT INTO persons(employee_no, dept_no, first_name, last_name, email, created_at) VALUES 14 | (1, 10, 'Evan', 'MacMans', 'evanmacmans@example.com', CURRENT_TIMESTAMP), 15 | (2, 11, 'Malvina', 'FitzSimons', 'malvinafitzsimons@example.com', CURRENT_TIMESTAMP), 16 | (3, 12, 'Jimmie', 'Bruce', 'jimmiebruce@example.com', CURRENT_TIMESTAMP) 17 | ; -------------------------------------------------------------------------------- /testdata/postgres/sql/insert_person.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO persons(employee_no, dept_no, first_name, last_name, email, created_at) VALUES 2 | (/*id*/1, /*dept*/10, /*fn*/'Evan', /*ln*/'MacMans', /*mail*/'evanmacmans@example.com', CURRENT_TIMESTAMP); -------------------------------------------------------------------------------- /testdata/postgres/sql/select_all.sql: -------------------------------------------------------------------------------- 1 | SELECT email, first_name, last_name FROM persons; 2 | -------------------------------------------------------------------------------- /testdata/postgres/sql/select_person.sql: -------------------------------------------------------------------------------- 1 | SELECT email, first_name, last_name FROM persons WHERE first_name=/*first_name*/'bob'; -------------------------------------------------------------------------------- /tokenizer.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | type tokenKind int 10 | 11 | const ( 12 | tkSQLStmt tokenKind = iota + 1 13 | tkIf 14 | tkElif 15 | tkElse 16 | tkEnd 17 | tkBind 18 | tkEndOfProgram 19 | ) 20 | 21 | type token struct { 22 | kind tokenKind 23 | str string 24 | value string /* for Bind */ 25 | condition string /* for IF/ELIF */ 26 | } 27 | 28 | // tokenizeは文字列を受け取ってトークンの列を返す 29 | func tokenize(str string) ([]token, error) { 30 | var tokens []token 31 | 32 | index := 0 33 | start := 0 34 | length := len(str) 35 | //index out of boundsを避けるため末尾に空白を追加する。 36 | str = str + " " 37 | 38 | for index < length { 39 | if str[index:index+2] == "/*" { 40 | if str[index:index+3] == "/*+" { 41 | // hint句の場合はskipする 42 | index++ 43 | continue 44 | } 45 | //コメントの直前の塊をTKSQLStmtとしてappend 46 | tokens = append(tokens, token{ 47 | kind: tkSQLStmt, 48 | str: str[start:index], 49 | }) 50 | start = index 51 | index += 2 52 | tok := token{} 53 | for index < length && str[index:index+2] != "*/" { 54 | if str[index:index+2] == "IF" { 55 | tok.kind = tkIf 56 | index += 2 57 | continue 58 | } 59 | if str[index:index+4] == "ELIF" { 60 | tok.kind = tkElif 61 | index += 4 62 | continue 63 | } 64 | if str[index:index+4] == "ELSE" { 65 | tok.kind = tkElse 66 | index += 4 67 | continue 68 | } 69 | if str[index:index+3] == "END" { 70 | tok.kind = tkEnd 71 | index += 3 72 | continue 73 | } 74 | index++ 75 | } 76 | // */がなければ不正なフォーマット 77 | if str[index:index+2] != "*/" { 78 | return []token{}, errors.New("Comment enclosing characters do not match") 79 | } 80 | index += 2 81 | if tok.kind == 0 { 82 | tok.kind = tkBind 83 | if quote := str[index]; quote == '(' { 84 | // /* ... */( ... ) or /* ... */( (...), (...) ) 85 | var quoteStack []interface{} 86 | index++ 87 | for index < length { 88 | if str[index] == '(' { 89 | quoteStack = append(quoteStack, struct{}{}) 90 | index++ 91 | continue 92 | } 93 | if str[index] == ')' { 94 | if len(quoteStack) == 0 { 95 | break 96 | } 97 | quoteStack = quoteStack[0 : len(quoteStack)-1] 98 | index++ 99 | continue 100 | } 101 | index++ 102 | } 103 | if str[index] != ')' { 104 | return nil, errors.New("Enclosing characters do not match") 105 | } 106 | index++ 107 | } else if quote := str[index]; quote == '\'' || quote == '"' { 108 | // /* ... */"..." 109 | // /* ... */'...' 110 | // 文字列が続いている。 111 | // 実装汚い... 112 | index++ 113 | for index < length && str[index] != quote { 114 | index++ 115 | } 116 | if str[index] != quote { 117 | return nil, errors.New("Enclosing characters do not match") 118 | } 119 | index++ 120 | } else { 121 | for index < length && str[index] != '\t' && str[index] != '\n' && str[index] != ' ' && str[index] != ',' && str[index] != ')' { 122 | index++ 123 | } 124 | } 125 | } 126 | 127 | tok.str = str[start:index] 128 | switch tok.kind { 129 | case tkIf, tkElif: 130 | tok.condition = retrieveCondition(tok.kind, tok.str) 131 | case tkBind: 132 | tok.str = bindLiteral(tok.str) 133 | tok.value = retrieveValue(tok.str) 134 | } 135 | start = index 136 | tokens = append(tokens, tok) 137 | } 138 | if index == length-1 { 139 | tokens = append(tokens, token{ 140 | kind: tkSQLStmt, 141 | str: str[start : index+1], 142 | }) 143 | } 144 | index++ 145 | } 146 | 147 | // 処理しやすいように終点Tokenを付与する 148 | tokens = append(tokens, token{ 149 | kind: tkEndOfProgram, 150 | }) 151 | 152 | return tokens, nil 153 | } 154 | 155 | // ?/*value*/から value1を取り出す 156 | func retrieveValue(str string) string { 157 | retStr := strings.Trim(str, " ") 158 | retStr = strings.TrimLeft(retStr, "?") 159 | retStr = removeCommentSymbol(retStr) 160 | return strings.Trim(retStr, " ") 161 | } 162 | 163 | // /*value*/1000 -> ?/*value*/ みたいに変換する 164 | func bindLiteral(str string) string { 165 | str = strings.TrimRightFunc(str, func(r rune) bool { 166 | return r != unicode.SimpleFold('/') 167 | }) 168 | return "?" + str 169 | } 170 | 171 | // /* (IF|ELIF) condition */ -> conditionを返す 172 | // kind must be tkIf or tkElif 173 | func retrieveCondition(kind tokenKind, str string) string { 174 | str = removeCommentSymbol(str) 175 | str = strings.Trim(str, " ") 176 | str = strings.Trim(str, "\n") 177 | str = strings.Trim(str, "\t") 178 | switch kind { 179 | case tkIf: 180 | str = strings.TrimPrefix(str, "IF") 181 | case tkElif: 182 | str = strings.TrimPrefix(str, "ELIF") 183 | default: 184 | panic("kind must be tKIF or tkElif") 185 | } 186 | return strings.TrimLeft(str, " ") 187 | } 188 | 189 | // input: /*value*/ -> output: value 190 | func removeCommentSymbol(str string) string { 191 | str = strings.TrimPrefix(str, "/*") 192 | return strings.TrimSuffix(str, "*/") 193 | } 194 | -------------------------------------------------------------------------------- /tokenizer_test.go: -------------------------------------------------------------------------------- 1 | package twowaysql 2 | 3 | import ( 4 | "testing" 5 | 6 | gocmp "github.com/google/go-cmp/cmp" 7 | "gotest.tools/v3/assert" 8 | "gotest.tools/v3/assert/cmp" 9 | ) 10 | 11 | func TestTokenize(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | input string 15 | want []token 16 | }{ 17 | { 18 | name: "empty", 19 | input: "", 20 | want: []token{ 21 | { 22 | kind: tkEndOfProgram, 23 | }, 24 | }, 25 | }, 26 | { 27 | name: "no comment", 28 | input: "SELECT * FROM person WHERE employee_no < 1000 AND dept_no = 1", 29 | want: []token{ 30 | { 31 | kind: tkSQLStmt, 32 | str: "SELECT * FROM person WHERE employee_no < 1000 AND dept_no = 1", 33 | }, 34 | { 35 | kind: tkEndOfProgram, 36 | }, 37 | }, 38 | }, 39 | { 40 | name: "bind space 1", 41 | input: `SELECT * FROM person WHERE first_name = /* firstName */"Jeff Dean"`, 42 | want: []token{ 43 | { 44 | kind: tkSQLStmt, 45 | str: `SELECT * FROM person WHERE first_name = `, 46 | }, 47 | { 48 | kind: tkBind, 49 | str: "?/* firstName */", 50 | value: "firstName", 51 | }, 52 | { 53 | kind: tkEndOfProgram, 54 | }, 55 | }, 56 | }, 57 | { 58 | name: "bind space 2", 59 | input: `SELECT * FROM person WHERE first_name = /* firstName */'Jeff Dean'`, 60 | want: []token{ 61 | { 62 | kind: tkSQLStmt, 63 | str: `SELECT * FROM person WHERE first_name = `, 64 | }, 65 | { 66 | kind: tkBind, 67 | str: "?/* firstName */", 68 | value: "firstName", 69 | }, 70 | { 71 | kind: tkEndOfProgram, 72 | }, 73 | }, 74 | }, 75 | { 76 | name: "bind space 3", 77 | input: `SELECT * FROM person WHERE first_name = /* firstName */"Jeff Dean" AND deptNo < 10`, 78 | want: []token{ 79 | { 80 | kind: tkSQLStmt, 81 | str: `SELECT * FROM person WHERE first_name = `, 82 | }, 83 | { 84 | kind: tkBind, 85 | str: "?/* firstName */", 86 | value: "firstName", 87 | }, 88 | { 89 | kind: tkSQLStmt, 90 | str: ` AND deptNo < 10`, 91 | }, 92 | { 93 | kind: tkEndOfProgram, 94 | }, 95 | }, 96 | }, 97 | { 98 | name: "insert", 99 | input: `INSERT INTO persons (employee_no, dept_no, first_name, last_name, email) VALUES(/*EmpNo*/1, /*deptNo*/1)`, 100 | want: []token{ 101 | { 102 | kind: tkSQLStmt, 103 | str: "INSERT INTO persons (employee_no, dept_no, first_name, last_name, email) VALUES(", 104 | }, 105 | { 106 | kind: tkBind, 107 | str: "?/*EmpNo*/", 108 | value: "EmpNo", 109 | }, 110 | { 111 | kind: tkSQLStmt, 112 | str: ", ", 113 | }, 114 | { 115 | kind: tkBind, 116 | str: "?/*deptNo*/", 117 | value: "deptNo", 118 | }, 119 | { 120 | kind: tkSQLStmt, 121 | str: ")", 122 | }, 123 | { 124 | kind: tkEndOfProgram, 125 | }, 126 | }, 127 | }, 128 | { 129 | name: "if", 130 | input: "SELECT * FROM person WHERE employee_no < 1000 /* IF true */ AND dept_no = 1/* END */", 131 | want: []token{ 132 | { 133 | kind: tkSQLStmt, 134 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 135 | }, 136 | { 137 | kind: tkIf, 138 | str: "/* IF true */", 139 | condition: "true", 140 | }, 141 | { 142 | kind: tkSQLStmt, 143 | str: " AND dept_no = 1", 144 | }, 145 | { 146 | kind: tkEnd, 147 | str: "/* END */", 148 | }, 149 | { 150 | kind: tkEndOfProgram, 151 | }, 152 | }, 153 | }, 154 | { 155 | name: "if and bind", 156 | input: "SELECT * FROM person WHERE employee_no < /*maxEmpNo*/1000 /* IF false */ AND dept_no = /*deptNo*/1 /* END */", 157 | want: []token{ 158 | { 159 | kind: tkSQLStmt, 160 | str: "SELECT * FROM person WHERE employee_no < ", 161 | }, 162 | { 163 | kind: tkBind, 164 | str: "?/*maxEmpNo*/", 165 | value: "maxEmpNo", 166 | }, 167 | { 168 | kind: tkSQLStmt, 169 | str: " ", 170 | }, 171 | { 172 | kind: tkIf, 173 | str: "/* IF false */", 174 | condition: "false", 175 | }, 176 | { 177 | kind: tkSQLStmt, 178 | str: " AND dept_no = ", 179 | }, 180 | { 181 | kind: tkBind, 182 | str: "?/*deptNo*/", 183 | value: "deptNo", 184 | }, 185 | { 186 | kind: tkSQLStmt, 187 | str: " ", 188 | }, 189 | { 190 | kind: tkEnd, 191 | str: "/* END */", 192 | }, 193 | { 194 | kind: tkEndOfProgram, 195 | }, 196 | }, 197 | }, 198 | { 199 | name: "if elif else", 200 | input: "SELECT * FROM person WHERE employee_no < 1000 /* IF true */AND dept_no =1/* ELIF true*/ AND boss_no = 2 /*ELSE */ AND id=3/* END */", 201 | want: []token{ 202 | { 203 | kind: tkSQLStmt, 204 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 205 | }, 206 | { 207 | kind: tkIf, 208 | str: "/* IF true */", 209 | condition: "true", 210 | }, 211 | { 212 | kind: tkSQLStmt, 213 | str: "AND dept_no =1", 214 | }, 215 | { 216 | kind: tkElif, 217 | str: "/* ELIF true*/", 218 | condition: "true", 219 | }, 220 | { 221 | kind: tkSQLStmt, 222 | str: " AND boss_no = 2 ", 223 | }, 224 | { 225 | kind: tkElse, 226 | str: "/*ELSE */", 227 | }, 228 | { 229 | kind: tkSQLStmt, 230 | str: " AND id=3", 231 | }, 232 | { 233 | kind: tkEnd, 234 | str: "/* END */", 235 | }, 236 | { 237 | kind: tkEndOfProgram, 238 | }, 239 | }, 240 | }, 241 | { 242 | name: "if nest", 243 | input: "SELECT * FROM person WHERE employee_no < 1000 /* IF true */ /* IF false */ AND dept_no =1 /* ELSE */ AND id=3 /* END */ /* ELSE*/ AND boss_id=4 /* END */", 244 | want: []token{ 245 | { 246 | kind: tkSQLStmt, 247 | str: "SELECT * FROM person WHERE employee_no < 1000 ", 248 | }, 249 | { 250 | kind: tkIf, 251 | str: "/* IF true */", 252 | condition: "true", 253 | }, 254 | { 255 | kind: tkSQLStmt, 256 | str: " ", 257 | }, 258 | { 259 | kind: tkIf, 260 | str: "/* IF false */", 261 | condition: "false", 262 | }, 263 | { 264 | kind: tkSQLStmt, 265 | str: " AND dept_no =1 ", 266 | }, 267 | { 268 | kind: tkElse, 269 | str: "/* ELSE */", 270 | }, 271 | { 272 | kind: tkSQLStmt, 273 | str: " AND id=3 ", 274 | }, 275 | { 276 | kind: tkEnd, 277 | str: "/* END */", 278 | }, 279 | { 280 | kind: tkSQLStmt, 281 | str: " ", 282 | }, 283 | { 284 | kind: tkElse, 285 | str: "/* ELSE*/", 286 | }, 287 | { 288 | kind: tkSQLStmt, 289 | str: " AND boss_id=4 ", 290 | }, 291 | { 292 | kind: tkEnd, 293 | str: "/* END */", 294 | }, 295 | { 296 | kind: tkEndOfProgram, 297 | }, 298 | }, 299 | }, 300 | { 301 | name: "in bind", 302 | input: `SELECT * FROM person /* IF gender_list !== null */ WHERE person.gender in /*gender_list*/('M') /* END */`, 303 | want: []token{ 304 | { 305 | kind: tkSQLStmt, 306 | str: "SELECT * FROM person ", 307 | }, 308 | { 309 | kind: tkIf, 310 | str: "/* IF gender_list !== null */", 311 | condition: "gender_list !== null", 312 | }, 313 | { 314 | kind: tkSQLStmt, 315 | str: " WHERE person.gender in ", 316 | }, 317 | { 318 | kind: tkBind, 319 | str: "?/*gender_list*/", 320 | value: "gender_list", 321 | }, 322 | { 323 | kind: tkSQLStmt, 324 | str: " ", 325 | }, 326 | { 327 | kind: tkEnd, 328 | str: "/* END */", 329 | }, 330 | { 331 | kind: tkEndOfProgram, 332 | }, 333 | }, 334 | }, 335 | { 336 | name: "multiple in bind", 337 | input: `SELECT * FROM person /* IF gender_list !== null */ WHERE (person.gender, person.firstName) in /*table*/('M', 'Jeff') /* END */`, 338 | want: []token{ 339 | { 340 | kind: tkSQLStmt, 341 | str: "SELECT * FROM person ", 342 | }, 343 | { 344 | kind: tkIf, 345 | str: "/* IF gender_list !== null */", 346 | condition: "gender_list !== null", 347 | }, 348 | { 349 | kind: tkSQLStmt, 350 | str: " WHERE (person.gender, person.firstName) in ", 351 | }, 352 | { 353 | kind: tkBind, 354 | str: "?/*table*/", 355 | value: "table", 356 | }, 357 | { 358 | kind: tkSQLStmt, 359 | str: " ", 360 | }, 361 | { 362 | kind: tkEnd, 363 | str: "/* END */", 364 | }, 365 | { 366 | kind: tkEndOfProgram, 367 | }, 368 | }, 369 | }, 370 | } 371 | 372 | for _, tt := range tests { 373 | t.Run(tt.name, func(t *testing.T) { 374 | if got, err := tokenize(tt.input); err != nil || !tokensEqual(tt.want, got) { 375 | if err != nil { 376 | t.Error(err) 377 | } 378 | t.Errorf("Doesn't Match expected: %v, but got: %v\n", tt.want, got) 379 | } 380 | }) 381 | } 382 | } 383 | func TestTokenizeShouldReturnError(t *testing.T) { 384 | tests := []struct { 385 | name string 386 | input string 387 | wantError string 388 | }{ 389 | { 390 | name: "bad comment format", 391 | input: "SELECT * FROM person WHERE employee_no < 1000 /* IF true / AND dept_no = 1", 392 | wantError: "Comment enclosing characters do not match", 393 | }, 394 | { 395 | name: "Enclosing characters not match 1", 396 | input: `SELECT * FROM person WHERE employee_no < /* firstName */"Jeff Dean AND dept_no = 1`, 397 | wantError: "Enclosing characters do not match", 398 | }, 399 | { 400 | name: "Enclosing characters not match 2", 401 | input: `SELECT * FROM person WHERE employee_no < /* firstName */"Jeff Dean' AND dept_no = 1`, 402 | wantError: "Enclosing characters do not match", 403 | }, 404 | } 405 | 406 | for _, tt := range tests { 407 | t.Run(tt.name, func(t *testing.T) { 408 | if _, err := tokenize(tt.input); err == nil || err.Error() != tt.wantError { 409 | if err == nil { 410 | t.Error("Should Error") 411 | } else if err.Error() != tt.wantError { 412 | t.Errorf("Doesn't Match expected: %v, but got: %v\n", tt.wantError, err.Error()) 413 | } 414 | } 415 | }) 416 | } 417 | } 418 | 419 | func TestTokenize_Multiline(t *testing.T) { 420 | tests := []struct { 421 | name string 422 | input string 423 | want []token 424 | }{ 425 | { 426 | name: "no comment", 427 | input: `SELECT 428 | * 429 | FROM 430 | person 431 | WHERE 432 | employee_no < 1000 433 | AND dept_no = 1`, 434 | want: []token{ 435 | { 436 | kind: tkSQLStmt, 437 | str: `SELECT 438 | * 439 | FROM 440 | person 441 | WHERE 442 | employee_no < 1000 443 | AND dept_no = 1`, 444 | }, 445 | { 446 | kind: tkEndOfProgram, 447 | }, 448 | }, 449 | }, 450 | { 451 | name: "bind space 1", 452 | input: `SELECT 453 | * 454 | FROM 455 | person 456 | WHERE 457 | first_name = /* firstName */"Jeff Dean"`, 458 | want: []token{ 459 | { 460 | kind: tkSQLStmt, 461 | str: `SELECT 462 | * 463 | FROM 464 | person 465 | WHERE 466 | first_name = `, 467 | }, 468 | { 469 | kind: tkBind, 470 | str: "?/* firstName */", 471 | value: "firstName", 472 | }, 473 | { 474 | kind: tkEndOfProgram, 475 | }, 476 | }, 477 | }, 478 | { 479 | name: "bind space 2", 480 | input: `SELECT 481 | * 482 | FROM 483 | person 484 | WHERE 485 | first_name = /* firstName */'Jeff Dean'`, 486 | want: []token{ 487 | { 488 | kind: tkSQLStmt, 489 | str: `SELECT 490 | * 491 | FROM 492 | person 493 | WHERE 494 | first_name = `, 495 | }, 496 | { 497 | kind: tkBind, 498 | str: "?/* firstName */", 499 | value: "firstName", 500 | }, 501 | { 502 | kind: tkEndOfProgram, 503 | }, 504 | }, 505 | }, 506 | { 507 | name: "bind space 3", 508 | input: `SELECT 509 | * 510 | FROM 511 | person 512 | WHERE 513 | first_name = /* firstName */"Jeff Dean" 514 | AND deptno < 10`, 515 | want: []token{ 516 | { 517 | kind: tkSQLStmt, 518 | str: `SELECT 519 | * 520 | FROM 521 | person 522 | WHERE 523 | first_name = `, 524 | }, 525 | { 526 | kind: tkBind, 527 | str: "?/* firstName */", 528 | value: "firstName", 529 | }, 530 | { 531 | kind: tkSQLStmt, 532 | str: ` 533 | AND deptno < 10`, 534 | }, 535 | { 536 | kind: tkEndOfProgram, 537 | }, 538 | }, 539 | }, 540 | { 541 | name: "insert", 542 | input: `INSERT 543 | INTO 544 | persons 545 | ( 546 | employee_no 547 | , dept_no 548 | , first_name 549 | , last_name 550 | , email 551 | ) VALUES ( 552 | /*EmpNo*/1 553 | , /*deptNo*/1 554 | )`, 555 | want: []token{ 556 | { 557 | kind: tkSQLStmt, 558 | str: `INSERT 559 | INTO 560 | persons 561 | ( 562 | employee_no 563 | , dept_no 564 | , first_name 565 | , last_name 566 | , email 567 | ) VALUES ( 568 | `, 569 | }, 570 | { 571 | kind: tkBind, 572 | str: "?/*EmpNo*/", 573 | value: "EmpNo", 574 | }, 575 | { 576 | kind: tkSQLStmt, 577 | str: "\n,\t", 578 | }, 579 | { 580 | kind: tkBind, 581 | str: "?/*deptNo*/", 582 | value: "deptNo", 583 | }, 584 | { 585 | kind: tkSQLStmt, 586 | str: "\n)", 587 | }, 588 | { 589 | kind: tkEndOfProgram, 590 | }, 591 | }, 592 | }, 593 | { 594 | name: "if", 595 | input: `SELECT 596 | * 597 | FROM 598 | person 599 | WHERE 600 | employee_no < 1000 601 | /* IF true */ 602 | AND dept_no = 1 603 | /* END */`, 604 | want: []token{ 605 | { 606 | kind: tkSQLStmt, 607 | str: `SELECT 608 | * 609 | FROM 610 | person 611 | WHERE 612 | employee_no < 1000 613 | `, 614 | }, 615 | { 616 | kind: tkIf, 617 | str: "/* IF true */", 618 | condition: "true", 619 | }, 620 | { 621 | kind: tkSQLStmt, 622 | str: ` 623 | AND dept_no = 1 624 | `, 625 | }, 626 | { 627 | kind: tkEnd, 628 | str: "/* END */", 629 | }, 630 | { 631 | kind: tkEndOfProgram, 632 | }, 633 | }, 634 | }, 635 | { 636 | name: "if and bind", 637 | input: `SELECT 638 | * 639 | FROM 640 | person 641 | WHERE 642 | employee_no < /*maxEmpNo*/1000 643 | /* IF false */ 644 | AND dept_no = /*deptNo*/1 645 | /* END */`, 646 | want: []token{ 647 | { 648 | kind: tkSQLStmt, 649 | str: `SELECT 650 | * 651 | FROM 652 | person 653 | WHERE 654 | employee_no < `, 655 | }, 656 | { 657 | kind: tkBind, 658 | str: "?/*maxEmpNo*/", 659 | value: "maxEmpNo", 660 | }, 661 | { 662 | kind: tkSQLStmt, 663 | str: "\n\t", 664 | }, 665 | { 666 | kind: tkIf, 667 | str: "/* IF false */", 668 | condition: "false", 669 | }, 670 | { 671 | kind: tkSQLStmt, 672 | str: ` 673 | AND dept_no = `, 674 | }, 675 | { 676 | kind: tkBind, 677 | str: "?/*deptNo*/", 678 | value: "deptNo", 679 | }, 680 | { 681 | kind: tkSQLStmt, 682 | str: "\n\t", 683 | }, 684 | { 685 | kind: tkEnd, 686 | str: "/* END */", 687 | }, 688 | { 689 | kind: tkEndOfProgram, 690 | }, 691 | }, 692 | }, 693 | { 694 | name: "if elif else", 695 | input: `SELECT 696 | * 697 | FROM 698 | person 699 | WHERE 700 | employee_no < 1000 701 | /* IF true */ 702 | AND dept_no = 1 703 | /* ELIF true*/ 704 | AND boss_no = 2 705 | /*ELSE */ 706 | AND ID = 3 707 | /* END */`, 708 | want: []token{ 709 | { 710 | kind: tkSQLStmt, 711 | str: `SELECT 712 | * 713 | FROM 714 | person 715 | WHERE 716 | employee_no < 1000 717 | `, 718 | }, 719 | { 720 | kind: tkIf, 721 | str: "/* IF true */", 722 | condition: "true", 723 | }, 724 | { 725 | kind: tkSQLStmt, 726 | str: ` 727 | AND dept_no = 1 728 | `, 729 | }, 730 | { 731 | kind: tkElif, 732 | str: "/* ELIF true*/", 733 | condition: "true", 734 | }, 735 | { 736 | kind: tkSQLStmt, 737 | str: ` 738 | AND boss_no = 2 739 | `, 740 | }, 741 | { 742 | kind: tkElse, 743 | str: "/*ELSE */", 744 | }, 745 | { 746 | kind: tkSQLStmt, 747 | str: ` 748 | AND ID = 3 749 | `, 750 | }, 751 | { 752 | kind: tkEnd, 753 | str: "/* END */", 754 | }, 755 | { 756 | kind: tkEndOfProgram, 757 | }, 758 | }, 759 | }, 760 | { 761 | name: "if nest", 762 | input: `SELECT 763 | * 764 | FROM 765 | person 766 | WHERE 767 | employee_no < 1000 768 | /* IF true */ 769 | /* IF false */ 770 | AND dept_no = 1 771 | /* ELSE */ 772 | AND ID = 3 773 | /* END */ 774 | /* ELSE*/ 775 | AND boss_id = 4 776 | /* END */`, 777 | want: []token{ 778 | { 779 | kind: tkSQLStmt, 780 | str: `SELECT 781 | * 782 | FROM 783 | person 784 | WHERE 785 | employee_no < 1000 786 | `, 787 | }, 788 | { 789 | kind: tkIf, 790 | str: "/* IF true */", 791 | condition: "true", 792 | }, 793 | { 794 | kind: tkSQLStmt, 795 | str: "\n\t\t", 796 | }, 797 | { 798 | kind: tkIf, 799 | str: "/* IF false */", 800 | condition: "false", 801 | }, 802 | { 803 | kind: tkSQLStmt, 804 | str: ` 805 | AND dept_no = 1 806 | `, 807 | }, 808 | { 809 | kind: tkElse, 810 | str: "/* ELSE */", 811 | }, 812 | { 813 | kind: tkSQLStmt, 814 | str: ` 815 | AND ID = 3 816 | `, 817 | }, 818 | { 819 | kind: tkEnd, 820 | str: "/* END */", 821 | }, 822 | { 823 | kind: tkSQLStmt, 824 | str: "\n\t", 825 | }, 826 | { 827 | kind: tkElse, 828 | str: "/* ELSE*/", 829 | }, 830 | { 831 | kind: tkSQLStmt, 832 | str: ` 833 | AND boss_id = 4 834 | `, 835 | }, 836 | { 837 | kind: tkEnd, 838 | str: "/* END */", 839 | }, 840 | { 841 | kind: tkEndOfProgram, 842 | }, 843 | }, 844 | }, 845 | { 846 | name: "in bind", 847 | input: `SELECT 848 | * 849 | FROM 850 | person 851 | /* IF gender_list !== null */ 852 | WHERE 853 | person.gender IN /*gender_list*/('M') 854 | /* END */`, 855 | want: []token{ 856 | { 857 | kind: tkSQLStmt, 858 | str: `SELECT 859 | * 860 | FROM 861 | person 862 | `, 863 | }, 864 | { 865 | kind: tkIf, 866 | str: "/* IF gender_list !== null */", 867 | condition: "gender_list !== null", 868 | }, 869 | { 870 | kind: tkSQLStmt, 871 | str: ` 872 | WHERE 873 | person.gender IN `, 874 | }, 875 | { 876 | kind: tkBind, 877 | str: "?/*gender_list*/", 878 | value: "gender_list", 879 | }, 880 | { 881 | kind: tkSQLStmt, 882 | str: "\n", 883 | }, 884 | { 885 | kind: tkEnd, 886 | str: "/* END */", 887 | }, 888 | { 889 | kind: tkEndOfProgram, 890 | }, 891 | }, 892 | }, 893 | { 894 | name: "multiple in bind", 895 | input: `SELECT 896 | * 897 | FROM 898 | person 899 | /* IF gender_list !== null */ 900 | WHERE 901 | (person.gender, person.firstname) IN /*table*/('M', 'Jeff') 902 | /* END */`, 903 | want: []token{ 904 | { 905 | kind: tkSQLStmt, 906 | str: `SELECT 907 | * 908 | FROM 909 | person 910 | `, 911 | }, 912 | { 913 | kind: tkIf, 914 | str: "/* IF gender_list !== null */", 915 | condition: "gender_list !== null", 916 | }, 917 | { 918 | kind: tkSQLStmt, 919 | str: ` 920 | WHERE 921 | (person.gender, person.firstname) IN `, 922 | }, 923 | { 924 | kind: tkBind, 925 | str: "?/*table*/", 926 | value: "table", 927 | }, 928 | { 929 | kind: tkSQLStmt, 930 | str: "\n", 931 | }, 932 | { 933 | kind: tkEnd, 934 | str: "/* END */", 935 | }, 936 | { 937 | kind: tkEndOfProgram, 938 | }, 939 | }, 940 | }, 941 | { 942 | name: "header comment", 943 | input: `-- header comment 944 | SELECT 945 | * 946 | FROM 947 | person 948 | WHERE 949 | employee_no < 1000`, 950 | want: []token{ 951 | { 952 | kind: tkSQLStmt, 953 | str: `-- header comment 954 | SELECT 955 | * 956 | FROM 957 | person 958 | WHERE 959 | employee_no < 1000`, 960 | }, 961 | { 962 | kind: tkEndOfProgram, 963 | }, 964 | }, 965 | }, 966 | { 967 | name: "inner comment", 968 | input: `SELECT 969 | * 970 | FROM 971 | person -- inner comment 972 | WHERE 973 | employee_no < 1000`, 974 | want: []token{ 975 | { 976 | kind: tkSQLStmt, 977 | str: `SELECT 978 | * 979 | FROM 980 | person -- inner comment 981 | WHERE 982 | employee_no < 1000`, 983 | }, 984 | { 985 | kind: tkEndOfProgram, 986 | }, 987 | }, 988 | }, 989 | { 990 | name: "footer comment", 991 | input: `SELECT 992 | * 993 | FROM 994 | person 995 | WHERE 996 | employee_no < 1000 997 | -- footer comment`, 998 | want: []token{ 999 | { 1000 | kind: tkSQLStmt, 1001 | str: `SELECT 1002 | * 1003 | FROM 1004 | person 1005 | WHERE 1006 | employee_no < 1000 1007 | -- footer comment`, 1008 | }, 1009 | { 1010 | kind: tkEndOfProgram, 1011 | }, 1012 | }, 1013 | }, 1014 | { 1015 | name: "if and bind with hints", 1016 | input: ` 1017 | /*+ 1018 | Leading((a (b c))) 1019 | HashJoin(b c) 1020 | */ 1021 | SELECT 1022 | * 1023 | FROM 1024 | person a 1025 | INNER JOIN 1026 | company b 1027 | ON a.interest = b.category 1028 | INNER JOIN 1029 | market c 1030 | ON b.industry = c.industry 1031 | WHERE 1032 | a.employee_no < /*maxEmpNo*/1000 1033 | /* IF false */ 1034 | AND a.dept_no = /*deptNo*/1 1035 | /* END */`, 1036 | want: []token{ 1037 | { 1038 | kind: tkSQLStmt, 1039 | str: ` 1040 | /*+ 1041 | Leading((a (b c))) 1042 | HashJoin(b c) 1043 | */ 1044 | SELECT 1045 | * 1046 | FROM 1047 | person a 1048 | INNER JOIN 1049 | company b 1050 | ON a.interest = b.category 1051 | INNER JOIN 1052 | market c 1053 | ON b.industry = c.industry 1054 | WHERE 1055 | a.employee_no < `, 1056 | }, 1057 | { 1058 | kind: tkBind, 1059 | str: "?/*maxEmpNo*/", 1060 | value: "maxEmpNo", 1061 | }, 1062 | { 1063 | kind: tkSQLStmt, 1064 | str: "\n\t", 1065 | }, 1066 | { 1067 | kind: tkIf, 1068 | str: "/* IF false */", 1069 | condition: "false", 1070 | }, 1071 | { 1072 | kind: tkSQLStmt, 1073 | str: ` 1074 | AND a.dept_no = `, 1075 | }, 1076 | { 1077 | kind: tkBind, 1078 | str: "?/*deptNo*/", 1079 | value: "deptNo", 1080 | }, 1081 | { 1082 | kind: tkSQLStmt, 1083 | str: "\n\t", 1084 | }, 1085 | { 1086 | kind: tkEnd, 1087 | str: "/* END */", 1088 | }, 1089 | { 1090 | kind: tkEndOfProgram, 1091 | }, 1092 | }, 1093 | }, 1094 | } 1095 | 1096 | for _, tt := range tests { 1097 | t.Run(tt.name, func(t *testing.T) { 1098 | got, err := tokenize(tt.input) 1099 | assert.NilError(t, err) 1100 | assert.Check(t, cmp.DeepEqual(tt.want, got, gocmp.AllowUnexported(token{}))) 1101 | }) 1102 | } 1103 | } 1104 | 1105 | func tokensEqual(want, got []token) bool { 1106 | if len(want) != len(got) { 1107 | return false 1108 | } 1109 | for i := 0; i < len(want); i++ { 1110 | if want[i] != got[i] { 1111 | return false 1112 | } 1113 | } 1114 | return true 1115 | } 1116 | -------------------------------------------------------------------------------- /twowaysql.go: -------------------------------------------------------------------------------- 1 | // Package twowaysql provides an implementation of 2WaySQL. 2 | package twowaysql 3 | 4 | import ( 5 | "context" 6 | "database/sql" 7 | "fmt" 8 | 9 | "github.com/jmoiron/sqlx" 10 | ) 11 | 12 | // Twowaysql is a struct for issuing 2WaySQL query 13 | type Twowaysql struct { 14 | db *sqlx.DB 15 | } 16 | 17 | // New returns instance of Twowaysql 18 | func New(db *sqlx.DB) *Twowaysql { 19 | return &Twowaysql{ 20 | db: db, 21 | } 22 | } 23 | 24 | // Select is a thin wrapper around db.Select in the sqlx package. 25 | // params takes a tagged struct. The tags format must be `twowaysql:"tag_name"`. 26 | // dest takes a pointer to a slice of a struct. The struct tag format must be `db:"tag_name"`. 27 | func (t *Twowaysql) Select(ctx context.Context, dest interface{}, query string, params interface{}) error { 28 | eval, bindParams, err := Eval(query, params) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | q := t.db.Rebind(eval) 34 | 35 | if destMap, ok := dest.(*[]map[string]interface{}); ok { 36 | rows, err := t.db.QueryxContext(ctx, q, bindParams...) 37 | if err != nil { 38 | return err 39 | } 40 | return convertResultToMap(destMap, rows) 41 | } 42 | 43 | return t.db.SelectContext(ctx, dest, q, bindParams...) 44 | 45 | } 46 | 47 | // Exec is a thin wrapper around db.Exec in the sqlx package. 48 | // params takes a tagged struct. The tags format must be `twowaysql:"tag_name"`. 49 | func (t *Twowaysql) Exec(ctx context.Context, query string, params interface{}) (sql.Result, error) { 50 | 51 | eval, bindParams, err := Eval(query, params) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | q := t.db.Rebind(eval) 57 | 58 | return t.db.ExecContext(ctx, q, bindParams...) 59 | } 60 | 61 | // Begin is a thin wrapper around db.BeginTxx in the sqlx package. 62 | func (t *Twowaysql) Begin(ctx context.Context) (*TwowaysqlTx, error) { 63 | 64 | tx, err := t.db.BeginTxx(ctx, nil) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return &TwowaysqlTx{tx: tx}, nil 70 | } 71 | 72 | // Close is a thin wrapper around db.Close in the sqlx package. 73 | func (t *Twowaysql) Close() error { 74 | 75 | if err := t.db.Close(); err != nil { 76 | return fmt.Errorf("close db: %w", err) 77 | } 78 | 79 | return nil 80 | } 81 | 82 | // DB returns `*sqlx.DB` 83 | func (t *Twowaysql) DB() *sqlx.DB { 84 | return t.db 85 | } 86 | 87 | // Transaction starts a transaction as a block. 88 | // arguments function is return error will rollback, otherwise to commit. 89 | func (t *Twowaysql) Transaction(ctx context.Context, fn func(tx *TwowaysqlTx) error) error { 90 | tx, err := t.Begin(ctx) 91 | if err != nil { 92 | return err 93 | } 94 | defer func() { 95 | if p := recover(); p != nil { 96 | if rerr := tx.Rollback(); rerr != nil { 97 | panic(fmt.Sprintf("panic occured %v and failed rollback %v", p, rerr)) 98 | } 99 | panic(p) 100 | } 101 | }() 102 | 103 | if err := fn(tx); err != nil { 104 | if rerr := tx.Rollback(); rerr != nil { 105 | return fmt.Errorf("failed rollback %v: %w", rerr, err) 106 | } 107 | return err 108 | } 109 | 110 | if err := tx.Commit(); err != nil { 111 | return err 112 | } 113 | 114 | return nil 115 | } 116 | 117 | // TwowaysqlTx is a structure for issuing 2WaySQL queries within a transaction. 118 | type TwowaysqlTx struct { 119 | tx *sqlx.Tx 120 | } 121 | 122 | // Commit is a thin wrapper around tx.Commit in the sqlx package. 123 | func (t *TwowaysqlTx) Commit() error { 124 | 125 | if err := t.tx.Commit(); err != nil { 126 | return err 127 | } 128 | 129 | return nil 130 | } 131 | 132 | // Rollback is a thin wrapper around tx.Rollback in the sqlx package. 133 | func (t *TwowaysqlTx) Rollback() error { 134 | 135 | if err := t.tx.Rollback(); err != nil { 136 | return err 137 | } 138 | 139 | return nil 140 | } 141 | 142 | // Select is a thin wrapper around db.Select in the sqlx package. 143 | // params takes a tagged struct. The tags format must be `twowaysql:"tag_name"`. 144 | // dest takes a pointer to a slice of a struct. The struct tag format must be `db:"tag_name"`. 145 | // It is an equivalent implementation of Twowaysql.Select 146 | func (t *TwowaysqlTx) Select(ctx context.Context, dest interface{}, query string, params interface{}) error { 147 | 148 | eval, bindParams, err := Eval(query, params) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | q := t.tx.Rebind(eval) 154 | 155 | if destMap, ok := dest.(*[]map[string]interface{}); ok { 156 | rows, err := t.tx.QueryxContext(ctx, q, bindParams...) 157 | if err != nil { 158 | return err 159 | } 160 | return convertResultToMap(destMap, rows) 161 | } 162 | 163 | return t.tx.SelectContext(ctx, dest, q, bindParams...) 164 | 165 | } 166 | 167 | // Exec is a thin wrapper around db.Exec in the sqlx package. 168 | // params takes a tagged struct. The tags format must be `twowaysql:"tag_name"`. 169 | // It is an equivalent implementation of Twowaysql.Exec 170 | func (t *TwowaysqlTx) Exec(ctx context.Context, query string, params interface{}) (sql.Result, error) { 171 | 172 | eval, bindParams, err := Eval(query, params) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | q := t.tx.Rebind(eval) 178 | 179 | return t.tx.ExecContext(ctx, q, bindParams...) 180 | } 181 | 182 | func convertResultToMap(dest *[]map[string]interface{}, rows *sqlx.Rows) error { 183 | defer rows.Close() 184 | for rows.Next() { 185 | row := map[string]interface{}{} 186 | if err := rows.MapScan(row); err != nil { 187 | return err 188 | } 189 | *dest = append(*dest, row) 190 | } 191 | return nil 192 | } 193 | 194 | // Tx returns `*sqlx.Tx` 195 | func (t *TwowaysqlTx) Tx() *sqlx.Tx { 196 | return t.tx 197 | } 198 | --------------------------------------------------------------------------------