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  }