├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── _example ├── create_index │ └── create_index.go ├── create_table │ └── create_table.go ├── drop_index │ └── drop_index.go ├── drop_table │ └── drop_table.go ├── example.go ├── readme │ ├── main.go │ └── user │ │ └── user.go └── sql │ ├── create_index.sql │ ├── create_table.sql │ ├── drop_index.sql │ └── drop_table.sql ├── behavior.go ├── client.go ├── client_test.go ├── column.go ├── column_test.go ├── go.mod ├── go.sum ├── index.go ├── index_test.go ├── key_part.go ├── lib.go ├── lib_test.go ├── option.go ├── parser.go ├── primary_key.go ├── primary_key_test.go └── table.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 pi9min. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST_LIST = $(shell go list ./...) 2 | 3 | test: dep lint 4 | go test -v $(TEST_LIST) -count=1 5 | 6 | lint: dep 7 | go vet ./... 8 | 9 | dep: 10 | go mod download 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spoon 2 | 3 | `spoon` is a library to generate [Google Cloud Spanner](https://cloud.google.com/spanner/) table schema. 4 | 5 | `spoon` parses the structure and generates the table schema converted to the appropriate type. 6 | 7 | ## How to use 8 | 9 | ### 1. Define the table structure 10 | 11 | Please implement the structure to satisfy the following `spoon.EntityBehavior` interface. 12 | 13 | ```go 14 | type EntityBehavior interface { 15 | TableName() string 16 | PrimaryKey() spoon.PrimaryKey 17 | Indexes() spoon.Indexes 18 | } 19 | ``` 20 | 21 | `_example/readme/user/user.go` See the source code below. 22 | 23 | ```go 24 | package user 25 | 26 | import ( 27 | "time" 28 | 29 | "github.com/pi9min/spoon" 30 | ) 31 | 32 | // validation 33 | var _ spoon.EntityBehavior = (*User)(nil) 34 | 35 | type Sex int8 36 | 37 | const ( 38 | Male Sex = iota 39 | Famale 40 | ) 41 | 42 | type User struct { 43 | ID int64 44 | FirstName string 45 | LastName string 46 | Sex Sex 47 | Age int64 48 | FriendIDs []int64 49 | CreatedAt time.Time 50 | } 51 | 52 | func (u *User) TableName() string { 53 | return "User" 54 | } 55 | 56 | func (u *User) PrimaryKey() *spoon.PrimaryKey { 57 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}) 58 | } 59 | 60 | func (u *User) Indexes() spoon.Indexes { 61 | return spoon.Indexes{ 62 | spoon.AddIndex( 63 | "UserByLastFirstName", 64 | "User", 65 | false, 66 | spoon.KeyPart{ColumnName: "LastName"}, 67 | spoon.KeyPart{ColumnName: "FirstName"}, 68 | ), 69 | } 70 | } 71 | ``` 72 | 73 | 74 | ### 2. Generate spoon client, load structure and output table schema 75 | 76 | `_example/readme/main.go` See the source code below. 77 | 78 | ```go 79 | package main 80 | 81 | import ( 82 | "fmt" 83 | "os" 84 | 85 | "github.com/pi9min/spoon" 86 | "github.com/pi9min/spoon/_example/readme/user" 87 | ) 88 | 89 | func main() { 90 | cli, err := spoon.New() 91 | if err != nil { 92 | panic(err) 93 | } 94 | 95 | schema, err := cli.GenerateCreateTable(&user.User{}) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | // output to stdout 101 | fmt.Fprint(os.Stdout, schema) 102 | } 103 | ``` 104 | 105 | The execution result is as follows. 106 | 107 | ```bash 108 | $ go run ./_example/readme/main.go 109 | CREATE TABLE `User` ( 110 | `ID` INT64 NOT NULL, 111 | `FirstName` STRING(MAX) NOT NULL, 112 | `LastName` STRING(MAX) NOT NULL, 113 | `Sex` INT64 NOT NULL, 114 | `Age` INT64 NOT NULL, 115 | `FriendIDs` ARRAY NOT NULL, 116 | `CreatedAt` TIMESTAMP NOT NULL, 117 | ) PRIMARY KEY (`ID`) 118 | ``` 119 | 120 | ## A type correspondence table between spanner and golang 121 | 122 | | Golang Type | Spanner Type | 123 | | :----------------------: | :----------: | 124 | | int8 | `INT64` | 125 | | int16 | `INT64` | 126 | | int32 | `INT64` | 127 | | int64, spanner.NullInt64 | `INT64` | 128 | | uint8 | `INT64` | 129 | | uint16 | `INT64` | 130 | | uint32 | `INT64` | 131 | | uint64 | `INT64` | 132 | | float32 | `FLOAT64` | 133 | | []byte,[]uint8 | `BYTES(n or MAX) (1 <= n <= 10485760)` | 134 | | float64, spanner.NullFloat64 | `FLOAT64` | 135 | | string, spanner.NullString | `STRING(n or MAX) (1 <= n <= 2621440)` | 136 | | bool, spanner.NullBool | `BOOL` | 137 | | civil.Date, spanner.NullDate | `DATE` | 138 | | time.Time, spanner.NullTime | `TIMESTAMP` | 139 | | json.RawMessage | `BYTES(n)` | 140 | | Primitive type slices | `ARRAY` | 141 | 142 | ## Structure tag prefix 143 | 144 | Default tag prefix is `db`. 145 | 146 | You can set your favorite prefix by specifying TagPrefix option. 147 | 148 | e.g. Set `spanner` to prefix. 149 | 150 | ```go 151 | cli, err := spoon.New(TagPrefix("spanner")) 152 | if err != nil { 153 | panic(err) 154 | } 155 | ``` 156 | 157 | Values that can be specified with the tag are as follows. 158 | 159 | | Tag Value | VALUE | 160 | | :-----------: | :-----------------------------------------------: | 161 | | `nullable` | Remove the `NOT NULL` constraint (Allow NULL) | 162 | | `size=` | When it is strings or bytes, set the length | 163 | | `-` | Ignore fields | 164 | 165 | It's used as follows. 166 | 167 | ```go 168 | type User struct { 169 | ID string `db:"size=64"` 170 | Name string 171 | TemporaryMemo string `db:"-"` 172 | CreatedAt time.Time 173 | DeletedAt spanner.NullTime `db:"nullable"` 174 | } 175 | ``` 176 | 177 | ## How to set the PrimaryKey 178 | 179 | Uses `spoon.AddPrimaryKey()` method. 180 | 181 | If you also want to set interleaving, use `spoon.AddPrimaryKeyWithInterleave()` method. 182 | 183 | For example, 184 | ```go 185 | // Set ID as PrimaryKey 186 | func (u *User) PrimaryKey() *spoon.PrimaryKey { 187 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName:"ID"}) 188 | } 189 | 190 | ---> PRIMARY KEY (`ID`) 191 | 192 | 193 | // Set ID and CreatedAt DESC to PrimaryKey 194 | func (u *User) PrimaryKey() *spoon.PrimaryKey { 195 | return spoon.AddPrimaryKey( 196 | spoon.KeyPart{ColumnName: "ID"}, 197 | spoon.KeyPart{ColumnName: "CreatedAt", IsOrderDesc: true}, 198 | ) 199 | } 200 | 201 | --> PRIMARY KEY (`ID`, `CreatedAt` DESC) 202 | 203 | 204 | // Interleave to Company table and set ID to PrimaryKey 205 | func (u *User) PrimaryKey() *spoon.PrimaryKey { 206 | return spoon.AddPrimaryKeyWithInterleave("Company", spoon.KeyPart{ColumnName: "ID"}) 207 | } 208 | 209 | --> PRIMARY KEY (`ID`), INTERLEAVE IN PARENT `Company` 210 | ``` 211 | 212 | ## How to set the Index 213 | 214 | Uses `spoon.AddIndex()` method. 215 | 216 | If you also want to add a unique constraint, use `spoon.AddUniqueIndex()` method. 217 | 218 | For example, 219 | ```go 220 | // Add index to Lastname, FirstName 221 | func (u *User) Indexes() spoon.Indexes { 222 | return spoon.Indexes{ 223 | spoon.AddIndex( 224 | "UserByLastFirstName", 225 | "User", 226 | false, 227 | spoon.KeyPart{ColumnName: "LastName"}, 228 | spoon.KeyPart{ColumnName: "FirstName"}, 229 | ), 230 | } 231 | } 232 | 233 | --> CREATE INDEX `UserByLastFirstName` ON `User` (`LastName`, `FirstName`) 234 | 235 | // Add a unique index to Lastname, FirstName 236 | func (u *User) Indexes() spoon.Indexes { 237 | return spoon.Indexes{ 238 | spoon.AddUniqueIndex( 239 | "UserByUniqueLastFirstName", 240 | "User", 241 | false, 242 | spoon.KeyPart{ColumnName: "LastName"}, 243 | spoon.KeyPart{ColumnName: "FirstName"}, 244 | ), 245 | } 246 | } 247 | 248 | --> CREATE UNIQUE INDEX `UserByUniqueLastFirstName` ON `User` (`LastName`, `FirstName`) 249 | 250 | // Add an index to CreatedAt DESC without including NULL 251 | func (u *User) Indexes() spoon.Indexes { 252 | return spoon.Indexes{ 253 | spoon.AddIndex( 254 | "UserByNullFilteredCreatedAtDesc", 255 | "User", 256 | true, 257 | spoon.KeyPart{ColumnName: "CreatedAt", IsOrderDesc: true}, 258 | ), 259 | } 260 | } 261 | 262 | --> CREATE NULL_FILTERED INDEX `UserByNullFilteredCreatedAtDesc` ON `User` (`CreatedAt` DESC) 263 | ``` 264 | 265 | ## License 266 | 267 | See [LICENSE.md](/LICENSE.md) 268 | -------------------------------------------------------------------------------- /_example/create_index/create_index.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/pi9min/spoon" 10 | ex "github.com/pi9min/spoon/_example" 11 | ) 12 | 13 | func main() { 14 | var ( 15 | outFilePath string 16 | ) 17 | flag.StringVar(&outFilePath, "o", "./_example/sql/create_index.sql", "set ddl output file path") 18 | flag.StringVar(&outFilePath, "outfile", "./_example/sql/create_index.sql", "set ddl output file path") 19 | flag.Parse() 20 | 21 | if outFilePath == "" { 22 | log.Println("Please set outFilePath. -o or -outfile") 23 | return 24 | } 25 | 26 | cli, err := spoon.New() 27 | if err != nil { 28 | log.Println(err.Error()) 29 | return 30 | } 31 | 32 | ebs := []spoon.EntityBehavior{ 33 | &ex.User{}, 34 | ex.Entry{}, 35 | ex.PlayerComment{}, 36 | ex.Bookmark{}, 37 | ex.Balance{}, 38 | ex.NestParent{ 39 | NestChild1: &ex.NestChild1{}, 40 | NestChild2: &ex.NestChild2{}, 41 | }, 42 | } 43 | 44 | indexes := make([]string, 0, len(ebs)) 45 | for i := range ebs { 46 | schemas, err := cli.GenerateCreateIndexes(ebs[i]) 47 | if err != nil { 48 | log.Println(err.Error()) 49 | return 50 | } 51 | indexes = append(indexes, schemas...) 52 | } 53 | 54 | f, err := os.Create(outFilePath) 55 | if err != nil { 56 | log.Println(err.Error()) 57 | return 58 | } 59 | defer f.Close() 60 | 61 | if _, err := f.WriteString(strings.Join(indexes, "\n\n")); err != nil { 62 | log.Println(err.Error()) 63 | return 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /_example/create_table/create_table.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/pi9min/spoon" 10 | ex "github.com/pi9min/spoon/_example" 11 | ) 12 | 13 | func main() { 14 | var ( 15 | outFilePath string 16 | ) 17 | flag.StringVar(&outFilePath, "o", "./_example/sql/create_table.sql", "set ddl output file path") 18 | flag.StringVar(&outFilePath, "outfile", "./_example/sql/create_table.sql", "set ddl output file path") 19 | flag.Parse() 20 | 21 | if outFilePath == "" { 22 | log.Println("Please set outFilePath. -o or -outfile") 23 | return 24 | } 25 | 26 | cli, err := spoon.New() 27 | if err != nil { 28 | log.Println(err.Error()) 29 | return 30 | } 31 | 32 | ebs := []spoon.EntityBehavior{ 33 | &ex.User{}, 34 | ex.Entry{}, 35 | ex.PlayerComment{}, 36 | ex.Bookmark{}, 37 | ex.Balance{}, 38 | ex.NestParent{ 39 | NestChild1: &ex.NestChild1{}, 40 | NestChild2: &ex.NestChild2{}, 41 | }, 42 | } 43 | 44 | schemas, err := cli.GenerateCreateTables(ebs) 45 | if err != nil { 46 | log.Println(err.Error()) 47 | return 48 | } 49 | 50 | f, err := os.Create(outFilePath) 51 | if err != nil { 52 | log.Println(err.Error()) 53 | return 54 | } 55 | defer f.Close() 56 | 57 | if _, err := f.WriteString(strings.Join(schemas, "\n\n")); err != nil { 58 | log.Println(err.Error()) 59 | return 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /_example/drop_index/drop_index.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/pi9min/spoon" 10 | ex "github.com/pi9min/spoon/_example" 11 | ) 12 | 13 | func main() { 14 | var ( 15 | outFilePath string 16 | ) 17 | flag.StringVar(&outFilePath, "o", "./_example/sql/drop_index.sql", "set ddl output file path") 18 | flag.StringVar(&outFilePath, "outfile", "./_example/sql/drop_index.sql", "set ddl output file path") 19 | flag.Parse() 20 | 21 | if outFilePath == "" { 22 | log.Println("Please set outFilePath. -o or -outfile") 23 | return 24 | } 25 | 26 | cli, err := spoon.New() 27 | if err != nil { 28 | log.Println(err.Error()) 29 | return 30 | } 31 | 32 | ebs := []spoon.EntityBehavior{ 33 | &ex.User{}, 34 | ex.Entry{}, 35 | ex.PlayerComment{}, 36 | ex.Bookmark{}, 37 | ex.Balance{}, 38 | ex.NestParent{ 39 | NestChild1: &ex.NestChild1{}, 40 | NestChild2: &ex.NestChild2{}, 41 | }, 42 | } 43 | 44 | indexes := make([]string, 0, len(ebs)) 45 | for i := range ebs { 46 | schemas, err := cli.GenerateDropIndexes(ebs[i]) 47 | if err != nil { 48 | log.Println(err.Error()) 49 | return 50 | } 51 | indexes = append(indexes, schemas...) 52 | } 53 | 54 | f, err := os.Create(outFilePath) 55 | if err != nil { 56 | log.Println(err.Error()) 57 | return 58 | } 59 | defer f.Close() 60 | 61 | if _, err := f.WriteString(strings.Join(indexes, "\n\n")); err != nil { 62 | log.Println(err.Error()) 63 | return 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /_example/drop_table/drop_table.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/pi9min/spoon" 10 | ex "github.com/pi9min/spoon/_example" 11 | ) 12 | 13 | func main() { 14 | var ( 15 | outFilePath string 16 | ) 17 | flag.StringVar(&outFilePath, "o", "./_example/sql/drop_table.sql", "set ddl output file path") 18 | flag.StringVar(&outFilePath, "outfile", "./_example/sql/drop_table.sql", "set ddl output file path") 19 | flag.Parse() 20 | 21 | if outFilePath == "" { 22 | log.Println("Please set outFilePath. -o or -outfile") 23 | return 24 | } 25 | 26 | cli, err := spoon.New() 27 | if err != nil { 28 | log.Println(err.Error()) 29 | return 30 | } 31 | 32 | ebs := []spoon.EntityBehavior{ 33 | &ex.User{}, 34 | ex.Entry{}, 35 | ex.PlayerComment{}, 36 | ex.Bookmark{}, 37 | ex.Balance{}, 38 | ex.NestParent{ 39 | NestChild1: &ex.NestChild1{}, 40 | NestChild2: &ex.NestChild2{}, 41 | }, 42 | } 43 | 44 | schemas, err := cli.GenerateDropTables(ebs) 45 | if err != nil { 46 | log.Println(err.Error()) 47 | return 48 | } 49 | 50 | f, err := os.Create(outFilePath) 51 | if err != nil { 52 | log.Println(err.Error()) 53 | return 54 | } 55 | defer f.Close() 56 | 57 | if _, err := f.WriteString(strings.Join(schemas, "\n\n")); err != nil { 58 | log.Println(err.Error()) 59 | return 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /_example/example.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "time" 5 | 6 | "cloud.google.com/go/civil" 7 | "cloud.google.com/go/spanner" 8 | "github.com/pi9min/spoon" 9 | ) 10 | 11 | var ( 12 | _ spoon.EntityBehavior = (*User)(nil) 13 | _ spoon.EntityBehavior = Entry{} 14 | _ spoon.EntityBehavior = PlayerComment{} 15 | _ spoon.EntityBehavior = Bookmark{} 16 | _ spoon.EntityBehavior = Balance{} 17 | ) 18 | 19 | type User struct { 20 | ID uint64 21 | Name string 22 | Token string 23 | BornedDate spanner.NullDate `db:"nullable"` 24 | CreatedAt time.Time 25 | UpdatedAt time.Time 26 | } 27 | 28 | func (u *User) TableName() string { 29 | return "User" 30 | } 31 | 32 | func (u *User) PrimaryKey() *spoon.PrimaryKey { 33 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}) 34 | } 35 | 36 | func (u *User) Indexes() spoon.Indexes { 37 | return spoon.Indexes{} 38 | } 39 | 40 | type Entry struct { 41 | ID int32 42 | Title string 43 | Public bool 44 | Content *string `db:"size=1048576"` 45 | CreatedAt time.Time 46 | UpdatedAt time.Time 47 | } 48 | 49 | func (e Entry) TableName() string { 50 | return "Entry" 51 | } 52 | 53 | func (e Entry) PrimaryKey() *spoon.PrimaryKey { 54 | return spoon.AddPrimaryKeyWithInterleave("User", spoon.KeyPart{ColumnName: "ID"}, spoon.KeyPart{ColumnName: "CreatedAt", IsOrderDesc: true}) 55 | } 56 | 57 | func (e Entry) Indexes() spoon.Indexes { 58 | return spoon.Indexes{ 59 | spoon.AddIndex("EntryByTitle", "Entry", false, spoon.KeyPart{ColumnName: "Title"}), 60 | } 61 | } 62 | 63 | type PlayerComment struct { 64 | ID int32 `json:"id"` 65 | PlayerID int32 `json:"player_id"` 66 | EntryID int32 `json:"entry_id"` 67 | Comment spanner.NullString `db:"size:99, nullable" json:"comment"` 68 | CreatedAt time.Time `json:"created_at"` 69 | updatedAt time.Time 70 | } 71 | 72 | func (pc PlayerComment) TableName() string { 73 | return "PlayerComment" 74 | } 75 | 76 | func (pc PlayerComment) PrimaryKey() *spoon.PrimaryKey { 77 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}) 78 | } 79 | 80 | func (pc PlayerComment) Indexes() spoon.Indexes { 81 | return spoon.Indexes{ 82 | spoon.AddIndex("PlayerCommentByPlayerIDCommentNullFiltered", "PlayerComment", true, spoon.KeyPart{ColumnName: "PlayerID"}, spoon.KeyPart{ColumnName: "Comment"}), 83 | } 84 | } 85 | 86 | type Bookmark struct { 87 | ID string 88 | UserID int32 89 | EntryID int32 90 | Ignore string `db:"-"` 91 | Comments []string 92 | CreatedAt time.Time 93 | UpdatedAt time.Time 94 | } 95 | 96 | func (b Bookmark) TableName() string { 97 | return "Bookmark" 98 | } 99 | 100 | func (b Bookmark) PrimaryKey() *spoon.PrimaryKey { 101 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}) 102 | } 103 | 104 | func (b Bookmark) Indexes() spoon.Indexes { 105 | return spoon.Indexes{ 106 | spoon.AddUniqueIndex("BookmarkByUserIDEntryID", "BookMark", false, spoon.KeyPart{ColumnName: "UserID"}, spoon.KeyPart{ColumnName: "EntryID", IsOrderDesc: true}), 107 | } 108 | } 109 | 110 | // protobufのenum扱いのパターン 111 | type CurrencyID int32 112 | type Balance struct { 113 | ID string 114 | UserID string 115 | CurrencyID CurrencyID 116 | Amount float64 117 | } 118 | 119 | func (b Balance) TableName() string { 120 | return "Balance" 121 | } 122 | 123 | func (b Balance) PrimaryKey() *spoon.PrimaryKey { 124 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}) 125 | } 126 | 127 | func (b Balance) Indexes() spoon.Indexes { 128 | return spoon.Indexes{ 129 | spoon.AddUniqueIndex("BalanceByUserIDCurrencyID", "Balance", false, spoon.KeyPart{ColumnName: "UserID"}, spoon.KeyPart{ColumnName: "CurrencyID"}), 130 | } 131 | } 132 | 133 | type NestChild1 struct { 134 | NC1ID string 135 | NestedAt time.Time 136 | } 137 | 138 | type NestChild2 struct { 139 | NC2ID string 140 | Birthdate civil.Date 141 | IgnoreField []byte `db:"-"` 142 | Nested2At spanner.NullTime 143 | } 144 | 145 | type NestParent struct { 146 | *NestChild1 147 | *NestChild2 148 | } 149 | 150 | func (n NestParent) TableName() string { 151 | return "NestParent" 152 | } 153 | 154 | func (n NestParent) PrimaryKey() *spoon.PrimaryKey { 155 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "NC1ID"}) 156 | } 157 | 158 | func (n NestParent) Indexes() spoon.Indexes { 159 | return spoon.Indexes{} 160 | } 161 | -------------------------------------------------------------------------------- /_example/readme/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pi9min/spoon" 8 | "github.com/pi9min/spoon/_example/readme/user" 9 | ) 10 | 11 | func main() { 12 | cli, err := spoon.New() 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | schema, err := cli.GenerateCreateTable(&user.User{}) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | // output to stdout 23 | fmt.Fprint(os.Stdout, schema) 24 | } 25 | -------------------------------------------------------------------------------- /_example/readme/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/pi9min/spoon" 7 | ) 8 | 9 | var _ spoon.EntityBehavior = (*User)(nil) 10 | 11 | type Sex int8 12 | 13 | const ( 14 | Male Sex = iota 15 | Famale 16 | ) 17 | 18 | type User struct { 19 | ID int64 20 | FirstName string 21 | LastName string 22 | Sex Sex 23 | Age int64 24 | FriendIDs []int64 25 | CreatedAt time.Time 26 | } 27 | 28 | func (u *User) TableName() string { 29 | return "User" 30 | } 31 | 32 | func (u *User) PrimaryKey() *spoon.PrimaryKey { 33 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}) 34 | } 35 | 36 | func (u *User) Indexes() spoon.Indexes { 37 | return spoon.Indexes{ 38 | spoon.AddIndex( 39 | "UserByLastFirstName", 40 | "User", 41 | false, 42 | spoon.KeyPart{ColumnName: "LastName"}, 43 | spoon.KeyPart{ColumnName: "FirstName"}, 44 | ), 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /_example/sql/create_index.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX `EntryByTitle` ON `Entry` (`Title`) 2 | 3 | CREATE NULL_FILTERED INDEX `PlayerCommentByPlayerIDCommentNullFiltered` ON `PlayerComment` (`PlayerID`, `Comment`) 4 | 5 | CREATE UNIQUE INDEX `BookmarkByUserIDEntryID` ON `BookMark` (`UserID`, `EntryID` DESC) 6 | 7 | CREATE UNIQUE INDEX `BalanceByUserIDCurrencyID` ON `Balance` (`UserID`, `CurrencyID`) -------------------------------------------------------------------------------- /_example/sql/create_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `User` ( 2 | `ID` INT64 NOT NULL, 3 | `Name` STRING(MAX) NOT NULL, 4 | `Token` STRING(MAX) NOT NULL, 5 | `BornedDate` DATE, 6 | `CreatedAt` TIMESTAMP NOT NULL, 7 | `UpdatedAt` TIMESTAMP NOT NULL, 8 | ) PRIMARY KEY (`ID`) 9 | 10 | CREATE TABLE `Entry` ( 11 | `ID` INT64 NOT NULL, 12 | `Title` STRING(MAX) NOT NULL, 13 | `Public` BOOL NOT NULL, 14 | `Content` STRING(1048576) NOT NULL, 15 | `CreatedAt` TIMESTAMP NOT NULL, 16 | `UpdatedAt` TIMESTAMP NOT NULL, 17 | ) PRIMARY KEY (`ID`, `CreatedAt` DESC), INTERLEAVE IN PARENT `User` 18 | 19 | CREATE TABLE `PlayerComment` ( 20 | `ID` INT64 NOT NULL, 21 | `PlayerID` INT64 NOT NULL, 22 | `EntryID` INT64 NOT NULL, 23 | `Comment` STRING(MAX), 24 | `CreatedAt` TIMESTAMP NOT NULL, 25 | `updatedAt` TIMESTAMP NOT NULL, 26 | ) PRIMARY KEY (`ID`) 27 | 28 | CREATE TABLE `Bookmark` ( 29 | `ID` STRING(MAX) NOT NULL, 30 | `UserID` INT64 NOT NULL, 31 | `EntryID` INT64 NOT NULL, 32 | `Comments` ARRAY NOT NULL, 33 | `CreatedAt` TIMESTAMP NOT NULL, 34 | `UpdatedAt` TIMESTAMP NOT NULL, 35 | ) PRIMARY KEY (`ID`) 36 | 37 | CREATE TABLE `Balance` ( 38 | `ID` STRING(MAX) NOT NULL, 39 | `UserID` STRING(MAX) NOT NULL, 40 | `CurrencyID` INT64 NOT NULL, 41 | `Amount` FLOAT64 NOT NULL, 42 | ) PRIMARY KEY (`ID`) 43 | 44 | CREATE TABLE `NestParent` ( 45 | `NC1ID` STRING(MAX) NOT NULL, 46 | `NestedAt` TIMESTAMP NOT NULL, 47 | `NC2ID` STRING(MAX) NOT NULL, 48 | `Birthdate` DATE, 49 | `Nested2At` TIMESTAMP, 50 | ) PRIMARY KEY (`NC1ID`) -------------------------------------------------------------------------------- /_example/sql/drop_index.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX `EntryByTitle` 2 | 3 | DROP INDEX `PlayerCommentByPlayerIDCommentNullFiltered` 4 | 5 | DROP INDEX `BookmarkByUserIDEntryID` 6 | 7 | DROP INDEX `BalanceByUserIDCurrencyID` -------------------------------------------------------------------------------- /_example/sql/drop_table.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `User` 2 | 3 | DROP TABLE `Entry` 4 | 5 | DROP TABLE `PlayerComment` 6 | 7 | DROP TABLE `Bookmark` 8 | 9 | DROP TABLE `Balance` 10 | 11 | DROP TABLE `NestParent` -------------------------------------------------------------------------------- /behavior.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | // EntityBehavior defines the interface that needs to be satisfied as an Entity. 4 | type EntityBehavior interface { 5 | TableName() string 6 | PrimaryKey() *PrimaryKey 7 | Indexes() Indexes 8 | } 9 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | ) 6 | 7 | var ( 8 | errIgnoreField = errors.New("error ignore this field") 9 | ) 10 | 11 | const ( 12 | defaultTagPrefix = "db" 13 | defaultIgnoreTag = "-" 14 | ) 15 | 16 | type optionParam struct { 17 | tagPrefix string 18 | ignoreTag string 19 | } 20 | 21 | // Client is Google Cloud Spanner schema generator 22 | type Client struct { 23 | param *optionParam 24 | parser *parser 25 | } 26 | 27 | // New creates a Client and returns it. 28 | func New(opts ...Option) (*Client, error) { 29 | op := &optionParam{ 30 | tagPrefix: defaultTagPrefix, 31 | ignoreTag: defaultIgnoreTag, 32 | } 33 | 34 | for _, opt := range opts { 35 | if err := opt(op); err != nil { 36 | return nil, err 37 | } 38 | } 39 | 40 | c := &Client{ 41 | parser: newParser(op.tagPrefix, op.ignoreTag), 42 | } 43 | 44 | return c, nil 45 | } 46 | 47 | // GenerateCreateTable outputs the `CREATE TABLE` schema of the specified Entity as a string. 48 | func (c *Client) GenerateCreateTable(eb EntityBehavior) (string, error) { 49 | t, err := c.parser.Parse(eb) 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | return t.CreateTableSchema(), nil 55 | } 56 | 57 | // GenerateCreateTables outputs the `CREATE TABLE` schema of the specified Entity as a string slices. 58 | func (c *Client) GenerateCreateTables(ebs []EntityBehavior) ([]string, error) { 59 | tables, err := c.parser.ParseMulti(ebs) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | ss := make([]string, 0, len(ebs)) 65 | for i := range tables { 66 | t := tables[i] 67 | ss = append(ss, t.CreateTableSchema()) 68 | } 69 | 70 | return ss, nil 71 | } 72 | 73 | // GenerateDropTable outputs the `DROP TABLE` schema of the specified Entity as a string. 74 | func (c *Client) GenerateDropTable(eb EntityBehavior) (string, error) { 75 | t, err := c.parser.Parse(eb) 76 | if err != nil { 77 | return "", err 78 | } 79 | 80 | return t.DropTableSchema(), nil 81 | } 82 | 83 | // GenerateDropTables outputs the `DROP TABLE` schema of the specified Entity as a string slices. 84 | func (c *Client) GenerateDropTables(ebs []EntityBehavior) ([]string, error) { 85 | tables, err := c.parser.ParseMulti(ebs) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | ss := make([]string, 0, len(ebs)) 91 | for i := range tables { 92 | t := tables[i] 93 | ss = append(ss, t.DropTableSchema()) 94 | } 95 | 96 | return ss, nil 97 | } 98 | 99 | // GenerateCreateIndexes outputs the `CREATE INDEX` schema of the specified Entity as a string slices. 100 | func (c *Client) GenerateCreateIndexes(eb EntityBehavior) ([]string, error) { 101 | t, err := c.parser.Parse(eb) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | indexes := t.Indexes() 107 | ss := make([]string, 0, len(indexes)) 108 | for i := range indexes { 109 | idx := indexes[i] 110 | ss = append(ss, idx.CreateIndexSchema()) 111 | } 112 | 113 | return ss, nil 114 | } 115 | 116 | // GenerateDropIndexes outputs the `DROP INDEX` schema of the specified Entity as a string slices. 117 | func (c *Client) GenerateDropIndexes(eb EntityBehavior) ([]string, error) { 118 | t, err := c.parser.Parse(eb) 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | indexes := t.Indexes() 124 | ss := make([]string, 0, len(indexes)) 125 | for i := range indexes { 126 | idx := indexes[i] 127 | ss = append(ss, idx.DropIndexSchema()) 128 | } 129 | 130 | return ss, nil 131 | } 132 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package spoon_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "cloud.google.com/go/spanner" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/pi9min/spoon" 11 | ) 12 | 13 | var ( 14 | _ spoon.EntityBehavior = Test1{} 15 | _ spoon.EntityBehavior = (*Test2)(nil) 16 | ) 17 | 18 | type Test1 struct { 19 | ID uint64 20 | Name string 21 | CreatedAt time.Time 22 | UpdatedAt time.Time 23 | } 24 | 25 | func (t1 Test1) TableName() string { 26 | return "Test1" 27 | } 28 | 29 | func (t1 Test1) Indexes() spoon.Indexes { 30 | return spoon.Indexes{ 31 | spoon.AddIndex("Test1ByCreatedAtDesc", "Test1", false, spoon.KeyPart{ColumnName: "CreatedAt", IsOrderDesc: true}), 32 | } 33 | } 34 | 35 | func (t1 Test1) PrimaryKey() *spoon.PrimaryKey { 36 | return spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}) 37 | } 38 | 39 | type Test2 struct { 40 | ID uint64 41 | Test1ID uint64 42 | Comment spanner.NullString `db:"size=1024"` 43 | CreatedAt time.Time 44 | UpdatedAt time.Time 45 | } 46 | 47 | func (t2 *Test2) TableName() string { 48 | return "Test2" 49 | } 50 | 51 | func (t2 *Test2) Indexes() spoon.Indexes { 52 | return spoon.Indexes{} 53 | } 54 | 55 | func (t2 *Test2) PrimaryKey() *spoon.PrimaryKey { 56 | return spoon.AddPrimaryKeyWithInterleave("Test1", spoon.KeyPart{ColumnName: "ID"}, spoon.KeyPart{ColumnName: "CreatedAt", IsOrderDesc: true}) 57 | } 58 | 59 | func TestGenerateCreateTable(t *testing.T) { 60 | tests := []struct { 61 | name string 62 | entity spoon.EntityBehavior 63 | expect string 64 | }{ 65 | { 66 | name: "1 all not null", 67 | entity: &Test1{}, 68 | expect: fmt.Sprintf(`CREATE TABLE %s ( 69 | %s INT64 NOT NULL, 70 | %s STRING(MAX) NOT NULL, 71 | %s TIMESTAMP NOT NULL, 72 | %s TIMESTAMP NOT NULL, 73 | ) PRIMARY KEY (%s)`, 74 | spoon.Quote("Test1"), 75 | spoon.Quote("ID"), 76 | spoon.Quote("Name"), 77 | spoon.Quote("CreatedAt"), 78 | spoon.Quote("UpdatedAt"), 79 | spoon.Quote("ID"), 80 | ), 81 | }, 82 | { 83 | name: "2 use nullable, size, with interleave", 84 | entity: &Test2{}, 85 | expect: fmt.Sprintf(`CREATE TABLE %s ( 86 | %s INT64 NOT NULL, 87 | %s INT64 NOT NULL, 88 | %s STRING(1024), 89 | %s TIMESTAMP NOT NULL, 90 | %s TIMESTAMP NOT NULL, 91 | ) PRIMARY KEY (%s, %s), INTERLEAVE IN PARENT %s`, 92 | spoon.Quote("Test2"), 93 | spoon.Quote("ID"), 94 | spoon.Quote("Test1ID"), 95 | spoon.Quote("Comment"), 96 | spoon.Quote("CreatedAt"), 97 | spoon.Quote("UpdatedAt"), 98 | spoon.Quote("ID"), spoon.Quote("CreatedAt")+" DESC", spoon.Quote("Test1"), 99 | ), 100 | }, 101 | } 102 | 103 | cli, err := spoon.New() 104 | if err != nil { 105 | t.Fatalf("error new Client") 106 | } 107 | 108 | for _, tt := range tests { 109 | t.Run(tt.name, func(t *testing.T) { 110 | actual, err := cli.GenerateCreateTable(tt.entity) 111 | if err != nil { 112 | t.Fatalf("error generate create table schema %#v", err) 113 | } 114 | 115 | if diff := cmp.Diff(tt.expect, actual); diff != "" { 116 | t.Errorf("GenerateCreateTable Diff:\n%s", diff) 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /column.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | ) 8 | 9 | const ( 10 | maxStringLength = 2621440 11 | maxByteLength = 10485760 12 | ) 13 | 14 | // Column is mapping struct field value. 15 | type Column struct { 16 | name string 17 | isNull bool 18 | size int 19 | reflectType reflect.Type 20 | } 21 | 22 | func newColumn(name string, tags map[string]string, rt reflect.Type) *Column { 23 | isNull, size, err := parseTags(tags) 24 | if err != nil { 25 | panic(err) 26 | } 27 | 28 | return &Column{ 29 | name: name, 30 | isNull: isNull, 31 | size: size, 32 | reflectType: rt, 33 | } 34 | } 35 | 36 | // ToSQL is convert struct value to sql. 37 | // ToSQL convert spanner type from reflect.Type and size 38 | func (c *Column) ToSQL() string { 39 | tStr, tNull := parseTypeToString(c.reflectType, c.size) 40 | // Always NOT NULL if both nulls are not satisfied 41 | if !(c.isNull || tNull) { 42 | tStr += " NOT NULL" 43 | } 44 | return fmt.Sprintf("%s %s", Quote(c.name), tStr) 45 | } 46 | 47 | func parseTypeToString(t reflect.Type, size int) (string, bool) { 48 | switch t.Kind() { 49 | // Recursive 50 | case reflect.Ptr: 51 | return parseTypeToString(t.Elem(), size) 52 | case reflect.Bool: 53 | return "BOOL", false 54 | case reflect.Int8, reflect.Uint8, reflect.Int16, reflect.Uint16, reflect.Int, reflect.Uint, reflect.Int32, reflect.Uint32, reflect.Int64, reflect.Uint64: 55 | return "INT64", false 56 | case reflect.Float32, reflect.Float64: 57 | return "FLOAT64", false 58 | case reflect.Slice: 59 | switch t.Elem().Kind() { 60 | case reflect.Uint8: // []byte 61 | if size < 1 || maxByteLength < size { 62 | return "BYTES(MAX)", false // MAX=10485760(10MiB) 63 | } 64 | return fmt.Sprintf("BYTES(%d)", size), false 65 | default: 66 | typeStr, isNull := parseTypeToString(t.Elem(), size) 67 | return array(typeStr), isNull 68 | } 69 | } 70 | 71 | switch t.Name() { 72 | case "Time": 73 | return "TIMESTAMP", false 74 | case "NullBool": // https://godoc.org/cloud.google.com/go/spanner#NullBool 75 | return "BOOL", true 76 | case "NullDate", "Date": // https://godoc.org/cloud.google.com/go/spanner#NullDate, https://godoc.org/cloud.google.com/go/civil#Date 77 | return "DATE", true 78 | case "NullFloat64": // https://godoc.org/cloud.google.com/go/spanner#NullFloat64 79 | return "FLOAT64", true 80 | case "NullInt64": // https://godoc.org/cloud.google.com/go/spanner#NullInt64 81 | return "INT64", true 82 | case "NullTime": // https://godoc.org/cloud.google.com/go/spanner#NullTime 83 | return "TIMESTAMP", true 84 | } 85 | 86 | // Process the following as a character string. 87 | var isNull bool 88 | // https://godoc.org/cloud.google.com/go/spanner#NullString 89 | if t.Name() == "NullString" { 90 | isNull = true 91 | } 92 | 93 | if size < 1 || maxStringLength < size { 94 | return "STRING(MAX)", isNull // MAX=2621440(2.5mebichars) 95 | } 96 | 97 | return fmt.Sprintf("STRING(%d)", size), isNull 98 | } 99 | 100 | func array(s string) string { 101 | return "ARRAY<" + s + ">" 102 | } 103 | 104 | func parseTags(tags map[string]string) (bool, int, error) { 105 | var isNull bool 106 | 107 | if _, ok := tags["nullable"]; ok { 108 | isNull = true 109 | } 110 | 111 | // size tag 112 | sizeStr, ok := tags["size"] 113 | if !ok { 114 | return isNull, 0, nil 115 | } 116 | 117 | s, err := strconv.Atoi(sizeStr) 118 | if err != nil { 119 | return false, 0, err 120 | } 121 | 122 | return isNull, s, nil 123 | 124 | } 125 | -------------------------------------------------------------------------------- /column_test.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | 11 | "cloud.google.com/go/spanner" 12 | ) 13 | 14 | func TestColumn_ParseTypeToString(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | inputType interface{} 18 | size int 19 | expectTypeStr string 20 | expectIsNull bool 21 | }{ 22 | {name: "bool", inputType: true, size: 0, expectTypeStr: "BOOL", expectIsNull: false}, 23 | {name: "*bool pointer case", inputType: (*bool)(nil), size: 0, expectTypeStr: "BOOL", expectIsNull: false}, 24 | {name: "int8", inputType: int8(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 25 | {name: "int16", inputType: int16(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 26 | {name: "int32", inputType: int32(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 27 | {name: "int", inputType: int(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 28 | {name: "int64", inputType: int64(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 29 | {name: "spanner.NullInt64", inputType: spanner.NullInt64{}, size: 0, expectTypeStr: "INT64", expectIsNull: true}, 30 | {name: "uint8", inputType: uint8(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 31 | {name: "uint16", inputType: uint16(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 32 | {name: "uint32", inputType: uint32(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 33 | {name: "uint", inputType: uint(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 34 | {name: "uint64", inputType: uint64(0), size: 0, expectTypeStr: "INT64", expectIsNull: false}, 35 | {name: "float32", inputType: float32(0), size: 0, expectTypeStr: "FLOAT64", expectIsNull: false}, 36 | {name: "float64", inputType: float64(0), size: 0, expectTypeStr: "FLOAT64", expectIsNull: false}, 37 | {name: "spanner.NullFloat64", inputType: spanner.NullFloat64{}, size: 0, expectTypeStr: "FLOAT64", expectIsNull: true}, 38 | {name: "*spanner.NullFloat64", inputType: &spanner.NullFloat64{}, size: 0, expectTypeStr: "FLOAT64", expectIsNull: true}, 39 | {name: "[]byte size:0", inputType: []byte{}, size: 0, expectTypeStr: "BYTES(MAX)", expectIsNull: false}, 40 | {name: "[]byte size:1", inputType: []byte{}, size: 1, expectTypeStr: "BYTES(1)", expectIsNull: false}, 41 | {name: "[]byte size:1048576", inputType: []byte{}, size: 1048576, expectTypeStr: "BYTES(1048576)", expectIsNull: false}, 42 | {name: "[]uint8", inputType: []uint8{}, size: 0, expectTypeStr: "BYTES(MAX)", expectIsNull: false}, 43 | {name: "[]uint8 size:1", inputType: []uint8{}, size: 1, expectTypeStr: "BYTES(1)", expectIsNull: false}, 44 | {name: "[]uint8 size:1048576", inputType: []uint8{}, size: 1048576, expectTypeStr: "BYTES(1048576)", expectIsNull: false}, 45 | {name: "time.Time", inputType: time.Time{}, size: 0, expectTypeStr: "TIMESTAMP", expectIsNull: false}, 46 | {name: "spanner.NullTime", inputType: spanner.NullTime{}, size: 0, expectTypeStr: "TIMESTAMP", expectIsNull: true}, 47 | {name: "json.RawMessage", inputType: json.RawMessage{}, size: 0, expectTypeStr: "BYTES(MAX)", expectIsNull: false}, 48 | {name: "string size:0", inputType: "", size: 0, expectTypeStr: "STRING(MAX)", expectIsNull: false}, 49 | {name: "string size:1", inputType: "", size: 1, expectTypeStr: "STRING(1)", expectIsNull: false}, 50 | {name: "string size:2621440", inputType: "", size: 2621440, expectTypeStr: "STRING(2621440)", expectIsNull: false}, 51 | {name: "spanner.NullString size:0", inputType: spanner.NullString{}, size: 0, expectTypeStr: "STRING(MAX)", expectIsNull: true}, 52 | {name: "spanner.NullString size:1", inputType: spanner.NullString{}, size: 1, expectTypeStr: "STRING(1)", expectIsNull: true}, 53 | {name: "spanner.NullString size:2621440", inputType: spanner.NullString{}, size: 2621440, expectTypeStr: "STRING(2621440)", expectIsNull: true}, 54 | {name: "[]bool", inputType: []bool{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 55 | {name: "[]int8", inputType: []int8{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 56 | {name: "[]int16", inputType: []int16{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 57 | {name: "[]int32", inputType: []int32{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 58 | {name: "[]int", inputType: []int{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 59 | {name: "[]int64", inputType: []int64{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 60 | {name: "[]spanner.NullInt64", inputType: []spanner.NullInt64{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: true}, 61 | {name: "[]*spanner.NullInt64", inputType: []*spanner.NullInt64{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: true}, 62 | {name: "[]uint16", inputType: []uint16{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 63 | {name: "[]uint32", inputType: []uint32{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 64 | {name: "[]uint", inputType: []uint{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 65 | {name: "[]uint64", inputType: []uint64{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 66 | {name: "[]float32", inputType: []float32{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 67 | {name: "[]float64", inputType: []float64{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 68 | {name: "[]spanner.NullFloat64", inputType: []spanner.NullFloat64{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: true}, 69 | {name: "[][]byte", inputType: [][]byte{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 70 | {name: "[][]uint8", inputType: [][]uint8{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 71 | {name: "[]time.Time", inputType: []time.Time{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 72 | {name: "[]spanner.NullTime", inputType: []spanner.NullTime{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: true}, 73 | {name: "[]json.RawMessage", inputType: []json.RawMessage{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 74 | {name: "[]string size:0", inputType: []string{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: false}, 75 | {name: "[]string size:1", inputType: []string{}, size: 1, expectTypeStr: "ARRAY", expectIsNull: false}, 76 | {name: "[]string size:2621440", inputType: []string{}, size: 2621440, expectTypeStr: "ARRAY", expectIsNull: false}, 77 | {name: "[]spanner.NullString size:0", inputType: []spanner.NullString{}, size: 0, expectTypeStr: "ARRAY", expectIsNull: true}, 78 | {name: "[]spanner.NullString size:1", inputType: []spanner.NullString{}, size: 1, expectTypeStr: "ARRAY", expectIsNull: true}, 79 | {name: "[]spanner.NullString size:2621440", inputType: []spanner.NullString{}, size: 2621440, expectTypeStr: "ARRAY", expectIsNull: true}, 80 | } 81 | 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | actualTypeStr, actualIsNull := parseTypeToString(reflect.TypeOf(tt.inputType), tt.size) 85 | if diff := cmp.Diff(tt.expectTypeStr, actualTypeStr); diff != "" { 86 | t.Errorf("TypeString Diff:\n%s", diff) 87 | } 88 | if diff := cmp.Diff(tt.expectIsNull, actualIsNull); diff != "" { 89 | t.Errorf("IsNull Diff:\n%s", diff) 90 | } 91 | }) 92 | } 93 | } 94 | 95 | func TestColumn_ToSQL(t *testing.T) { 96 | type fields struct { 97 | name string 98 | isNull bool 99 | size int 100 | reflectType reflect.Type 101 | } 102 | tests := []struct { 103 | name string 104 | fields fields 105 | expect string 106 | }{ 107 | { 108 | name: "int, not null, no size", 109 | fields: fields{ 110 | name: "ID", 111 | isNull: false, 112 | size: 0, 113 | reflectType: reflect.TypeOf(int64(0)), 114 | }, 115 | expect: "`ID` INT64 NOT NULL", 116 | }, 117 | { 118 | name: "int, nullable, no size", 119 | fields: fields{ 120 | name: "ID", 121 | isNull: true, 122 | size: 0, 123 | reflectType: reflect.TypeOf(int64(0)), 124 | }, 125 | expect: "`ID` INT64", 126 | }, 127 | { 128 | name: "string, not null, size=20", 129 | fields: fields{ 130 | name: "Description", 131 | isNull: false, 132 | size: 20, 133 | reflectType: reflect.TypeOf(""), 134 | }, 135 | expect: "`Description` STRING(20) NOT NULL", 136 | }, 137 | { 138 | name: "string, nullable, size=1024", 139 | fields: fields{ 140 | name: "Description", 141 | isNull: true, 142 | size: 1024, 143 | reflectType: reflect.TypeOf(""), 144 | }, 145 | expect: "`Description` STRING(1024)", 146 | }, 147 | { 148 | name: "byte, not null, size=-1", 149 | fields: fields{ 150 | name: "Description", 151 | isNull: true, 152 | size: -1, 153 | reflectType: reflect.TypeOf([]byte{}), 154 | }, 155 | expect: "`Description` BYTES(MAX)", 156 | }, 157 | } 158 | for _, tt := range tests { 159 | t.Run(tt.name, func(t *testing.T) { 160 | c := &Column{ 161 | name: tt.fields.name, 162 | isNull: tt.fields.isNull, 163 | size: tt.fields.size, 164 | reflectType: tt.fields.reflectType, 165 | } 166 | if diff := cmp.Diff(tt.expect, c.ToSQL()); diff != "" { 167 | t.Errorf("Column.ToSQL() Diff:\n%s", diff) 168 | } 169 | }) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pi9min/spoon 2 | 3 | require ( 4 | cloud.google.com/go v0.31.0 5 | github.com/google/go-cmp v0.2.0 6 | github.com/googleapis/gax-go v2.0.2+incompatible // indirect 7 | github.com/pkg/errors v0.8.0 8 | go.opencensus.io v0.18.0 // indirect 9 | golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519 // indirect 10 | golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4 // indirect 11 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f 12 | golang.org/x/sys v0.0.0-20181023152157-44b849a8bc13 // indirect 13 | google.golang.org/api v0.0.0-20181021000519-a2651947f503 // indirect 14 | google.golang.org/appengine v1.2.0 // indirect 15 | google.golang.org/genproto v0.0.0-20181016170114-94acd270e44e // indirect 16 | google.golang.org/grpc v1.16.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.31.0 h1:o9K5MWWt2wk+d9jkGn2DAZ7Q9nUdnFLOpK9eIkDwONQ= 3 | cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 4 | git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= 5 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 6 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 7 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 8 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= 9 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 10 | github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= 11 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 12 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 13 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 14 | github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= 15 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 16 | github.com/googleapis/gax-go v2.0.2+incompatible h1:silFMLAnr330+NRuag/VjIGF7TLp/LBrV2CJKFLWEww= 17 | github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= 18 | github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= 19 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 20 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 21 | github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= 22 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 23 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 24 | github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 25 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 26 | github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 27 | github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 28 | go.opencensus.io v0.18.0 h1:Mk5rgZcggtbvtAun5aJzAtjKKN/t0R3jJPlWILlv938= 29 | go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= 30 | golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 31 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 32 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 33 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 34 | golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519 h1:x6rhz8Y9CjbgQkccRGmELH6K+LJj7tOoh3XWeC1yaQM= 35 | golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 36 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 37 | golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4 h1:99CA0JJbUX4ozCnLon680Jc9e0T1i8HCaLVJMwtI8Hc= 38 | golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 39 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= 40 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 42 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 43 | golang.org/x/sys v0.0.0-20181023152157-44b849a8bc13 h1:ICvJQ9FL9kAAfwGwpoAmcE1O51M0zE++iVRxQ3xyiGE= 44 | golang.org/x/sys v0.0.0-20181023152157-44b849a8bc13/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 45 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 46 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 47 | golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 48 | google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 49 | google.golang.org/api v0.0.0-20181021000519-a2651947f503 h1:UK7/bFlIoP9xre0fwSiXFaZZSpzmaen5MKp1sppNJ9U= 50 | google.golang.org/api v0.0.0-20181021000519-a2651947f503/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 51 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 52 | google.golang.org/appengine v1.2.0 h1:S0iUepdCWODXRvtE+gcRDd15L+k+k1AiHlMiMjefH24= 53 | google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 54 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 55 | google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 56 | google.golang.org/genproto v0.0.0-20181016170114-94acd270e44e h1:I5s8aUkxqPjgAssfOv+dVr+4/7BC40WV6JhcVoORltI= 57 | google.golang.org/genproto v0.0.0-20181016170114-94acd270e44e/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 58 | google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 59 | google.golang.org/grpc v1.16.0 h1:dz5IJGuC2BB7qXR5AyHNwAUBhZscK2xVez7mznh72sY= 60 | google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 63 | honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 64 | -------------------------------------------------------------------------------- /index.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Indexes are alias of index slices. 9 | type Indexes []*Index 10 | 11 | // Index holds the necessary information to construct Index. 12 | type Index struct { 13 | name string 14 | tableName string 15 | isUnique bool 16 | nullFiltered bool 17 | keyParts []KeyPart 18 | } 19 | 20 | // CreateIndexSchema return `CREATE INDEX` schema. 21 | func (i *Index) CreateIndexSchema() string { 22 | var keyPartsStr []string 23 | 24 | for _, kp := range i.keyParts { 25 | kps := Quote(kp.ColumnName) 26 | if kp.IsOrderDesc { 27 | kps += " DESC" 28 | } 29 | keyPartsStr = append(keyPartsStr, kps) 30 | } 31 | 32 | words := make([]string, 0, 4) 33 | words = append(words, "CREATE") 34 | if i.isUnique { 35 | words = append(words, "UNIQUE") 36 | } 37 | if i.nullFiltered { 38 | words = append(words, "NULL_FILTERED") 39 | } 40 | words = append(words, "INDEX") 41 | 42 | schema := fmt.Sprintf( 43 | "%s %s ON %s (%s)", 44 | strings.Join(words, " "), 45 | Quote(i.name), 46 | Quote(i.tableName), 47 | strings.Join(keyPartsStr, ", "), 48 | ) 49 | 50 | return schema 51 | } 52 | 53 | // DropIndexSchema return `DROP INDEX` schema. 54 | func (i *Index) DropIndexSchema() string { 55 | schema := fmt.Sprintf("DROP INDEX %s", Quote(i.name)) 56 | return schema 57 | } 58 | 59 | // AddIndex creates Index. 60 | func AddIndex(idxName, tableName string, nullFiltered bool, keyParts ...KeyPart) *Index { 61 | return &Index{ 62 | name: idxName, 63 | keyParts: keyParts, 64 | tableName: tableName, 65 | isUnique: false, 66 | nullFiltered: nullFiltered, 67 | } 68 | } 69 | 70 | // AddUniqueIndex creates Unique Index. 71 | func AddUniqueIndex(idxName, tableName string, nullFiltered bool, keyParts ...KeyPart) *Index { 72 | return &Index{ 73 | name: idxName, 74 | keyParts: keyParts, 75 | tableName: tableName, 76 | isUnique: true, 77 | nullFiltered: nullFiltered, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /index_test.go: -------------------------------------------------------------------------------- 1 | package spoon_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/pi9min/spoon" 8 | ) 9 | 10 | func TestAddIndex(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | index *spoon.Index 14 | expect string 15 | }{ 16 | { 17 | name: "Player.PlayerID, nullFiltered=false", 18 | index: spoon.AddIndex("PlayerByPlayerID", "Player", false, spoon.KeyPart{ColumnName: "PlayerID"}), 19 | expect: "CREATE INDEX `PlayerByPlayerID` ON `Player` (`PlayerID`)", 20 | }, 21 | { 22 | name: "Player.PlayerID, nullFiltered=false", 23 | index: spoon.AddIndex("PlayerByPlayerIDEntryID", "Player", true, spoon.KeyPart{ColumnName: "PlayerID", IsOrderDesc: true}, spoon.KeyPart{ColumnName: "EntryID"}), 24 | expect: "CREATE NULL_FILTERED INDEX `PlayerByPlayerIDEntryID` ON `Player` (`PlayerID` DESC, `EntryID`)", 25 | }, 26 | { 27 | name: "Player.PlayerID, nullFiltered=false", 28 | index: spoon.AddUniqueIndex("PlayerByUniquePlayerID", "Player", false, spoon.KeyPart{ColumnName: "PlayerID"}), 29 | expect: "CREATE UNIQUE INDEX `PlayerByUniquePlayerID` ON `Player` (`PlayerID`)", 30 | }, 31 | { 32 | name: "Player.PlayerID, nullFiltered=false", 33 | index: spoon.AddUniqueIndex("PlayerByUniquePlayerIDEntryID", "Player", true, spoon.KeyPart{ColumnName: "PlayerID", IsOrderDesc: true}, spoon.KeyPart{ColumnName: "EntryID"}), 34 | expect: "CREATE UNIQUE NULL_FILTERED INDEX `PlayerByUniquePlayerIDEntryID` ON `Player` (`PlayerID` DESC, `EntryID`)", 35 | }, 36 | } 37 | 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | actual := tt.index.CreateIndexSchema() 41 | if diff := cmp.Diff(tt.expect, actual); diff != "" { 42 | t.Errorf("%s", diff) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /key_part.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | // KeyPart holds the columns and the order of arrangement that constitute the key to the index. 4 | type KeyPart struct { 5 | ColumnName string 6 | IsOrderDesc bool 7 | } 8 | -------------------------------------------------------------------------------- /lib.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | // Quote quotes the string. 4 | func Quote(unquoted string) string { 5 | return "`" + unquoted + "`" 6 | } 7 | 8 | // Semicolon adds a semicolon at the end. 9 | func Semicolon(schema string) string { 10 | return schema + ";" 11 | } 12 | -------------------------------------------------------------------------------- /lib_test.go: -------------------------------------------------------------------------------- 1 | package spoon_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/pi9min/spoon" 7 | ) 8 | 9 | func TestQuote(t *testing.T) { 10 | type args struct { 11 | unquoted string 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | want string 17 | }{ 18 | { 19 | name: "a-z0-9", 20 | args: args{ 21 | unquoted: "abc123", 22 | }, 23 | want: "`abc123`", 24 | }, 25 | } 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | if got := spoon.Quote(tt.args.unquoted); got != tt.want { 29 | t.Errorf("Quote() = %v, expect %v", got, tt.want) 30 | } 31 | }) 32 | } 33 | } 34 | 35 | func TestSemicolon(t *testing.T) { 36 | type args struct { 37 | schema string 38 | } 39 | tests := []struct { 40 | name string 41 | args args 42 | want string 43 | }{ 44 | { 45 | name: "a-z0-9", 46 | args: args{ 47 | schema: "abc123", 48 | }, 49 | want: "abc123;", 50 | }, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | if got := spoon.Semicolon(tt.args.schema); got != tt.want { 55 | t.Errorf("Semicolon() = %v, expect %v", got, tt.want) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | type Option func(*optionParam) error 4 | 5 | func TagPrefix(tp string) Option { 6 | return func(p *optionParam) error { 7 | p.tagPrefix = tp 8 | return nil 9 | } 10 | } 11 | 12 | func IgnoreTag(ig string) Option { 13 | return func(p *optionParam) error { 14 | p.ignoreTag = ig 15 | return nil 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "golang.org/x/sync/errgroup" 8 | ) 9 | 10 | // parser is 11 | type parser struct { 12 | tagPrefix string 13 | ignoreTag string 14 | } 15 | 16 | func newParser(tagPrefix, ignoreTag string) *parser { 17 | return &parser{ 18 | tagPrefix: tagPrefix, 19 | ignoreTag: ignoreTag, 20 | } 21 | } 22 | 23 | func (p *parser) Parse(eb EntityBehavior) (*Table, error) { 24 | columns, err := p.parseStruct(eb, p.tagPrefix) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return newTable(eb.TableName(), columns, eb.PrimaryKey(), eb.Indexes()), nil 30 | } 31 | 32 | func (p *parser) ParseMulti(ebs []EntityBehavior) ([]*Table, error) { 33 | tables := make([]*Table, len(ebs)) 34 | eg := errgroup.Group{} 35 | for i := range ebs { 36 | i := i 37 | eg.Go(func() error { 38 | columns, err := p.parseStruct(ebs[i], p.tagPrefix) 39 | if err != nil { 40 | return err 41 | } 42 | tables[i] = newTable(ebs[i].TableName(), columns, ebs[i].PrimaryKey(), ebs[i].Indexes()) 43 | 44 | return nil 45 | }) 46 | } 47 | 48 | if err := eg.Wait(); err != nil { 49 | return nil, err 50 | } 51 | 52 | return tables, nil 53 | } 54 | 55 | func (p *parser) parseStruct(in interface{}, tp string) ([]*Column, error) { 56 | v := reflect.Indirect(reflect.ValueOf(in)) 57 | t := v.Type() 58 | 59 | columns := make([]*Column, 0, t.NumField()) 60 | for i := 0; i < t.NumField(); i++ { 61 | sf := t.Field(i) 62 | if sf.Type.Kind() == reflect.Ptr && sf.Type.Elem().Kind() == reflect.Struct { 63 | cols, err := p.parseStruct(v.Field(i).Interface(), tp) 64 | if err != nil { 65 | return nil, err 66 | } 67 | columns = append(columns, cols...) 68 | } else { 69 | col, err := p.parseField(sf, tp) 70 | if err != nil { 71 | if err == errIgnoreField { 72 | continue 73 | } 74 | 75 | return nil, err 76 | } 77 | 78 | columns = append(columns, col) 79 | } 80 | } 81 | 82 | return columns, nil 83 | } 84 | 85 | func (p *parser) parseField(field reflect.StructField, tp string) (*Column, error) { 86 | t := field.Tag.Get(tp) 87 | if t == "" { 88 | return newColumn(field.Name, map[string]string{}, field.Type), nil 89 | } 90 | 91 | tags := strings.Split(t, ",") 92 | ks := make(map[string]bool) 93 | ts := make([]string, 0, len(tags)) 94 | for _, t := range tags { 95 | if t == p.ignoreTag { 96 | return nil, errIgnoreField 97 | } 98 | 99 | tag := strings.TrimSpace(t) 100 | // 重複してるものは入れない。先勝ち 101 | if _, ok := ks[tag]; !ok { 102 | ks[tag] = true 103 | ts = append(ts, tag) 104 | } 105 | } 106 | 107 | mts := p.mappingTag(ts) 108 | 109 | return newColumn(field.Name, mts, field.Type), nil 110 | } 111 | 112 | func (p *parser) mappingTag(tags []string) map[string]string { 113 | m := make(map[string]string) 114 | for _, elem := range tags { 115 | ss := strings.Split(elem, "=") 116 | switch len(ss) { 117 | case 1: 118 | m[ss[0]] = "" 119 | case 2: 120 | m[ss[0]] = ss[1] 121 | } 122 | } 123 | 124 | return m 125 | 126 | } 127 | -------------------------------------------------------------------------------- /primary_key.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // PrimaryKey XXX 9 | type PrimaryKey struct { 10 | keyParts []KeyPart 11 | interleavedTableName string 12 | } 13 | 14 | /*// Columns XXX 15 | func (pk *PrimaryKey) Columns() []string { 16 | cols := make([]string, 0, len(pk.keyParts)) 17 | for _, kp := range pk.keyParts { 18 | cols = append(cols, kp.ColumnName) 19 | } 20 | 21 | return cols 22 | } 23 | */ 24 | // ToSQL return primary key sql string 25 | func (pk *PrimaryKey) ToSQL() string { 26 | var keyPartsStr []string 27 | for _, kp := range pk.keyParts { 28 | kps := Quote(kp.ColumnName) 29 | if kp.IsOrderDesc { 30 | kps += " DESC" 31 | } 32 | keyPartsStr = append(keyPartsStr, kps) 33 | } 34 | 35 | if pk.interleavedTableName != "" { 36 | return fmt.Sprintf(`PRIMARY KEY (%s), INTERLEAVE IN PARENT %s`, strings.Join(keyPartsStr, ", "), Quote(pk.interleavedTableName)) 37 | } 38 | 39 | return fmt.Sprintf("PRIMARY KEY (%s)", strings.Join(keyPartsStr, ", ")) 40 | } 41 | 42 | // AddPrimaryKey XXX 43 | func AddPrimaryKey(keyParts ...KeyPart) *PrimaryKey { 44 | return &PrimaryKey{ 45 | keyParts: keyParts, 46 | interleavedTableName: "", 47 | } 48 | } 49 | 50 | // AddPrimaryKey XXX 51 | func AddPrimaryKeyWithInterleave(interleaveTableName string, keyParts ...KeyPart) *PrimaryKey { 52 | return &PrimaryKey{ 53 | keyParts: keyParts, 54 | interleavedTableName: interleaveTableName, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /primary_key_test.go: -------------------------------------------------------------------------------- 1 | package spoon_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/pi9min/spoon" 8 | ) 9 | 10 | func TestAddPrimaryKey(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | pk *spoon.PrimaryKey 14 | expect string 15 | }{ 16 | { 17 | name: "1 single key part", 18 | pk: spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}), 19 | expect: "PRIMARY KEY (`ID`)", 20 | }, 21 | { 22 | name: "2 multiple key part", 23 | pk: spoon.AddPrimaryKey(spoon.KeyPart{ColumnName: "ID"}, spoon.KeyPart{ColumnName: "CreatedAt", IsOrderDesc: true}), 24 | expect: "PRIMARY KEY (`ID`, `CreatedAt` DESC)", 25 | }, 26 | { 27 | name: "3 multiple key part with interleave", 28 | pk: spoon.AddPrimaryKeyWithInterleave("Balance", spoon.KeyPart{ColumnName: "CurrencyID"}, spoon.KeyPart{ColumnName: "UserID", IsOrderDesc: true}), 29 | expect: "PRIMARY KEY (`CurrencyID`, `UserID` DESC), INTERLEAVE IN PARENT `Balance`", 30 | }, 31 | } 32 | 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | actual := tt.pk.ToSQL() 36 | if diff := cmp.Diff(tt.expect, actual); diff != "" { 37 | t.Errorf("ToSQL Diff:\n%s", diff) 38 | } 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /table.go: -------------------------------------------------------------------------------- 1 | package spoon 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Table is mapping struct info 9 | type Table struct { 10 | name string 11 | columns []*Column 12 | primaryKey *PrimaryKey 13 | indexes Indexes 14 | } 15 | 16 | func newTable(name string, columns []*Column, pk *PrimaryKey, indexes Indexes) *Table { 17 | return &Table{ 18 | name: name, 19 | columns: columns, 20 | primaryKey: pk, 21 | indexes: indexes, 22 | } 23 | } 24 | 25 | func (t *Table) Indexes() Indexes { 26 | return t.indexes 27 | } 28 | 29 | func (t *Table) CreateTableSchema() string { 30 | ss := make([]string, 0, len(t.columns)+2) 31 | ss = append(ss, fmt.Sprintf("CREATE TABLE %s (", Quote(t.name))) 32 | for i := range t.columns { 33 | c := t.columns[i] 34 | ss = append(ss, fmt.Sprintf(" %s,", c.ToSQL())) 35 | } 36 | ss = append(ss, fmt.Sprintf(") %s", t.primaryKey.ToSQL())) 37 | return strings.Join(ss, "\n") 38 | } 39 | 40 | func (t *Table) DropTableSchema() string { 41 | return fmt.Sprintf("DROP TABLE %s", Quote(t.name)) 42 | } 43 | --------------------------------------------------------------------------------