github.com/amacneil/dbmate@v1.16.3-0.20230225174651-ca89b10d75d7/pkg/dbmate/migration.go (about) 1 package dbmate 2 3 import ( 4 "errors" 5 "io/fs" 6 "regexp" 7 "strings" 8 ) 9 10 // Migration represents an available migration and status 11 type Migration struct { 12 Applied bool 13 FileName string 14 FilePath string 15 FS fs.FS 16 Version string 17 } 18 19 // Parse a migration 20 func (m *Migration) Parse() (*ParsedMigration, error) { 21 bytes, err := fs.ReadFile(m.FS, m.FilePath) 22 if err != nil { 23 return nil, err 24 } 25 26 return parseMigrationContents(string(bytes)) 27 } 28 29 // ParsedMigration contains the migration contents and options 30 type ParsedMigration struct { 31 Up string 32 UpOptions ParsedMigrationOptions 33 Down string 34 DownOptions ParsedMigrationOptions 35 } 36 37 // ParsedMigrationOptions is an interface for accessing migration options 38 type ParsedMigrationOptions interface { 39 Transaction() bool 40 } 41 42 type migrationOptions map[string]string 43 44 // Transaction returns whether or not this migration should run in a transaction 45 // Defaults to true. 46 func (m migrationOptions) Transaction() bool { 47 return m["transaction"] != "false" 48 } 49 50 var ( 51 upRegExp = regexp.MustCompile(`(?m)^--\s*migrate:up(\s*$|\s+\S+)`) 52 downRegExp = regexp.MustCompile(`(?m)^--\s*migrate:down(\s*$|\s+\S+)$`) 53 emptyLineRegExp = regexp.MustCompile(`^\s*$`) 54 commentLineRegExp = regexp.MustCompile(`^\s*--`) 55 whitespaceRegExp = regexp.MustCompile(`\s+`) 56 optionSeparatorRegExp = regexp.MustCompile(`:`) 57 blockDirectiveRegExp = regexp.MustCompile(`^--\s*migrate:[up|down]]`) 58 ) 59 60 // Error codes 61 var ( 62 ErrParseMissingUp = errors.New("dbmate requires each migration to define an up block with '-- migrate:up'") 63 ErrParseUnexpectedStmt = errors.New("dbmate does not support statements defined outside of the '-- migrate:up' or '-- migrate:down' blocks") 64 ) 65 66 // parseMigrationContents parses the string contents of a migration. 67 // It will return two Migration objects, the first representing the "up" 68 // block and the second representing the "down" block. This function 69 // requires that at least an up block was defined and will otherwise 70 // return an error. 71 func parseMigrationContents(contents string) (*ParsedMigration, error) { 72 upDirectiveStart, upDirectiveEnd, hasDefinedUpBlock := getMatchPositions(contents, upRegExp) 73 downDirectiveStart, downDirectiveEnd, hasDefinedDownBlock := getMatchPositions(contents, downRegExp) 74 75 if !hasDefinedUpBlock { 76 return nil, ErrParseMissingUp 77 } else if statementsPrecedeMigrateBlocks(contents, upDirectiveStart, downDirectiveStart) { 78 return nil, ErrParseUnexpectedStmt 79 } 80 81 upEnd := len(contents) 82 downEnd := len(contents) 83 84 if hasDefinedDownBlock && upDirectiveStart < downDirectiveStart { 85 upEnd = downDirectiveStart 86 } else if hasDefinedDownBlock && upDirectiveStart > downDirectiveStart { 87 downEnd = upDirectiveStart 88 } else { 89 downEnd = -1 90 } 91 92 upDirective := substring(contents, upDirectiveStart, upDirectiveEnd) 93 downDirective := substring(contents, downDirectiveStart, downDirectiveEnd) 94 95 parsed := ParsedMigration{ 96 Up: substring(contents, upDirectiveStart, upEnd), 97 UpOptions: parseMigrationOptions(upDirective), 98 Down: substring(contents, downDirectiveStart, downEnd), 99 DownOptions: parseMigrationOptions(downDirective), 100 } 101 return &parsed, nil 102 } 103 104 // parseMigrationOptions parses the migration options out of a block 105 // directive into an object that implements the MigrationOptions interface. 106 // 107 // For example: 108 // 109 // fmt.Printf("%#v", parseMigrationOptions("-- migrate:up transaction:false")) 110 // // migrationOptions{"transaction": "false"} 111 func parseMigrationOptions(contents string) ParsedMigrationOptions { 112 options := make(migrationOptions) 113 114 // strip away the -- migrate:[up|down] part 115 contents = blockDirectiveRegExp.ReplaceAllString(contents, "") 116 117 // remove leading and trailing whitespace 118 contents = strings.TrimSpace(contents) 119 120 // return empty options if nothing is left to parse 121 if contents == "" { 122 return options 123 } 124 125 // split the options string into pairs, e.g. "transaction:false foo:bar" -> []string{"transaction:false", "foo:bar"} 126 stringPairs := whitespaceRegExp.Split(contents, -1) 127 128 for _, stringPair := range stringPairs { 129 // split stringified pair into key and value pairs, e.g. "transaction:false" -> []string{"transaction", "false"} 130 pair := optionSeparatorRegExp.Split(stringPair, -1) 131 132 // if the syntax is well-formed, then store the key and value pair in options 133 if len(pair) == 2 { 134 options[pair[0]] = pair[1] 135 } 136 } 137 138 return options 139 } 140 141 // statementsPrecedeMigrateBlocks inspects the contents between the first character 142 // of a string and the index of the first block directive to see if there are any statements 143 // defined outside of the block directive. It'll return true if it finds any such statements. 144 // 145 // For example: 146 // 147 // This will return false: 148 // 149 // statementsPrecedeMigrateBlocks(`-- migrate:up 150 // create table users (id serial); 151 // `, 0, -1) 152 // 153 // This will return true: 154 // 155 // statementsPrecedeMigrateBlocks(`create type status_type as enum('active', 'inactive'); 156 // -- migrate:up 157 // create table users (id serial, status status_type); 158 // `, 54, -1) 159 func statementsPrecedeMigrateBlocks(contents string, upDirectiveStart, downDirectiveStart int) bool { 160 until := upDirectiveStart 161 162 if downDirectiveStart > -1 { 163 until = min(upDirectiveStart, downDirectiveStart) 164 } 165 166 lines := strings.Split(contents[0:until], "\n") 167 168 for _, line := range lines { 169 if isEmptyLine(line) || isCommentLine(line) { 170 continue 171 } 172 return true 173 } 174 175 return false 176 } 177 178 // isEmptyLine will return true if the line has no 179 // characters or if all the characters are whitespace characters 180 func isEmptyLine(s string) bool { 181 return emptyLineRegExp.MatchString(s) 182 } 183 184 // isCommentLine will return true if the line is a SQL comment 185 func isCommentLine(s string) bool { 186 return commentLineRegExp.MatchString(s) 187 } 188 189 func getMatchPositions(s string, re *regexp.Regexp) (int, int, bool) { 190 match := re.FindStringIndex(s) 191 if match == nil { 192 return -1, -1, false 193 } 194 return match[0], match[1], true 195 } 196 197 func substring(s string, begin, end int) string { 198 if begin == -1 || end == -1 { 199 return "" 200 } 201 return s[begin:end] 202 } 203 204 func min(a, b int) int { 205 if a < b { 206 return a 207 } 208 return b 209 }