Created
October 16, 2025 13:53
-
-
Save agentgt/d836a22e7f5875b5d5c73c5fbfd7205c to your computer and use it in GitHub Desktop.
Flyway prepropcessor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.snaphop.flyway; | |
| import static java.lang.System.out; | |
| import static java.util.Objects.requireNonNull; | |
| import java.io.BufferedReader; | |
| import java.io.File; | |
| import java.io.FileInputStream; | |
| import java.io.IOException; | |
| import java.io.InputStreamReader; | |
| import java.io.PrintStream; | |
| import java.io.Reader; | |
| import java.net.URI; | |
| import java.net.URLDecoder; | |
| import java.nio.charset.StandardCharsets; | |
| import java.nio.file.Files; | |
| import java.nio.file.Path; | |
| import java.nio.file.Paths; | |
| import java.security.MessageDigest; | |
| import java.security.NoSuchAlgorithmException; | |
| import java.util.ArrayList; | |
| import java.util.Collection; | |
| import java.util.Collections; | |
| import java.util.LinkedHashMap; | |
| import java.util.LinkedHashSet; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.Map.Entry; | |
| import java.util.Set; | |
| import java.util.SortedMap; | |
| import java.util.TreeMap; | |
| import java.util.TreeSet; | |
| import java.util.stream.Collectors; | |
| import org.eclipse.jdt.annotation.NonNull; | |
| import org.eclipse.jdt.annotation.Nullable; | |
| import com.snaphop.flyway.FlywayPreprocessor.Error.ErrorCode; | |
| import com.snaphop.flyway.FlywayPreprocessor.MigrationManifest.Version; | |
| public class FlywayPreprocessor { | |
| // The api is command line driven | |
| private FlywayPreprocessor() {} | |
| private static final String META_TAG = "----?"; | |
| @SuppressWarnings("null") | |
| private static PrintStream out() { | |
| return out; | |
| } | |
| private static class Log { | |
| public static void info( | |
| String message) { | |
| out().println(message); | |
| } | |
| public static void error( | |
| String message) { | |
| out().println(message); | |
| } | |
| } | |
| private enum MetaParameter { | |
| version("v", "version"), | |
| sources("s", "source", "sources"), | |
| description("d", "description"); | |
| private final String[] names; | |
| private MetaParameter( | |
| String... ns) { | |
| this.names = ns; | |
| } | |
| @Nullable | |
| String getParameter( | |
| Map<String, String> m) { | |
| for (String n : names) { | |
| String s = m.get(n); | |
| if (s != null) | |
| return s; | |
| s = m.get(n.toUpperCase()); | |
| if (s != null) | |
| return s; | |
| } | |
| return null; | |
| } | |
| } | |
| public static class Error { | |
| private final ErrorCode errorCode; | |
| private final Exception exception; | |
| public static enum ErrorCode { | |
| MANIFEST_VERSION_MISSING_CHUNKS, | |
| CHUNKS_VERSION_NOT_DECLARED, | |
| CHUNK_NOT_DECLARED, | |
| MANIFEST_VERSION_MISSING_CHUNK, | |
| MANIFEST_VERSION_ORDER | |
| } | |
| private Error( | |
| ErrorCode code, | |
| Exception exception) { | |
| super(); | |
| this.errorCode = code; | |
| this.exception = exception; | |
| } | |
| public static Error create( | |
| ErrorCode code, | |
| String message) { | |
| try { | |
| throw new IllegalStateException(message); | |
| } | |
| catch (Exception e) { | |
| return new Error(code, e); | |
| } | |
| } | |
| public RuntimeException newException() { | |
| throw new RuntimeException(this.exception); | |
| } | |
| @Override | |
| public String toString() { | |
| ErrorCode _code = errorCode; | |
| return _code.name() + " " + exception.getMessage(); | |
| } | |
| } | |
| public static void main( | |
| String[] args) { | |
| if (args.length < 2) { | |
| out().println("CMD workspaceDirectory outputDirectory [options]"); | |
| System.exit(64); | |
| return; | |
| } | |
| Path workspaceDirectory = Paths.get(".") | |
| .resolve(args[0]); | |
| Path outputDirectory = Paths.get(".") | |
| .resolve(args[1]); | |
| boolean dryRun = false; | |
| boolean validate = false; | |
| if (args.length > 2) { | |
| for (int i = 2; i < args.length; i++) { | |
| var arg = args[i]; | |
| if (arg.equals("-n")) { | |
| dryRun = true; | |
| } | |
| else if (arg.equals("-v")) { | |
| validate = true; | |
| dryRun = true; | |
| } | |
| else { | |
| Log.error("Argument not recognized, only -n and -v allowed. arg: " + arg); | |
| System.exit(64); | |
| } | |
| } | |
| } | |
| try { | |
| // TODO move validation to run command. | |
| if (validate) { | |
| Log.info("Validating"); | |
| } | |
| var result = run(workspaceDirectory.toFile(), outputDirectory.toFile(), dryRun); | |
| if (!result.errors() | |
| .isEmpty()) { | |
| System.exit(1); | |
| } | |
| if (validate) { | |
| var changes = result.updates() | |
| .values() | |
| .stream() | |
| .filter(mfc -> mfc.getFlag() != MigrationFileAndContent.Flag.SKIP) | |
| .toList(); | |
| if (!changes.isEmpty()) { | |
| Log.error("Validation failed as there were changes! changes: " + changes); | |
| System.exit(2); | |
| } | |
| } | |
| } | |
| catch (IOException e) { | |
| e.printStackTrace(); | |
| System.exit(65); | |
| } | |
| } | |
| record RunResult( | |
| Map<MigrationVersion, MigrationFileAndContent> updates, | |
| MigrationWorkspace workspace, | |
| List<Error> errors) {} | |
| static RunResult run( | |
| File workspaceDirectory, | |
| File outputDirectory, | |
| boolean dryRun) | |
| throws IOException { | |
| if (dryRun) { | |
| Log.info("Dry Run"); | |
| } | |
| File manifestFile = new File(workspaceDirectory, "VERSIONS"); | |
| Log.info( | |
| "Running" // | |
| + "\n\tworkspaceDirectory: " | |
| + workspaceDirectory // | |
| + "\n\toutputDirectory: " | |
| + outputDirectory // | |
| + "\n\tmanifestFile: " | |
| + manifestFile // | |
| + "\n" // | |
| ); | |
| if (!workspaceDirectory.isDirectory()) { | |
| throw new IOException( | |
| "Workspace directory is not a directory or does not exist. workspaceDir: " + workspaceDirectory); | |
| } | |
| if (!outputDirectory.isDirectory()) { | |
| throw new IOException( | |
| "Output directory is not a directory or does not exist. outputDir: " + outputDirectory); | |
| } | |
| List<Error> errors = new ArrayList<>(); | |
| MigrationWorkspace workspace = MigrationWorkspace.create(manifestFile, workspaceDirectory); | |
| Map<MigrationVersion, Map<Path, Chunk>> chunks = workspace.chunks(); | |
| SortedMap<MigrationVersion, Version> manifestVersions = workspace.manifest() | |
| .versions(); | |
| Map<MigrationVersion, MigrationFile> migrations = MigrationFile.readFromDirectory(outputDirectory); | |
| Map<MigrationVersion, MigrationFileAndContent> updates = new TreeMap<>(); | |
| Version prev = null; | |
| for (Entry<MigrationVersion, Version> e : manifestVersions.entrySet()) { | |
| Version v = e.getValue(); | |
| MigrationVersion mv = e.getKey(); | |
| if (prev == null) { | |
| prev = v; | |
| } | |
| else if (prev.lineNumber() >= v.lineNumber()) { | |
| Error er = Error.create( | |
| ErrorCode.MANIFEST_VERSION_ORDER, | |
| "Manifest declared version out of order. version: " + v + "\nprevious version:" + prev); | |
| errors.add(er); | |
| } | |
| if (!chunks.containsKey(mv)) { | |
| Error er = Error.create( | |
| ErrorCode.MANIFEST_VERSION_MISSING_CHUNKS, | |
| "Manifest declared version but no chunks found for version. version: " + v); | |
| errors.add(er); | |
| MigrationFile _file = MigrationFile.create(outputDirectory, v, ""); | |
| Error _error = er; | |
| MigrationFileAndContent.Flag _flag = MigrationFileAndContent.Flag.MISSING; | |
| MigrationFileAndContent mfc = new MigrationFileAndContent(_file, _error, _flag); | |
| updates.put(mv, mfc); | |
| } | |
| } | |
| for (Entry<MigrationVersion, Map<Path, Chunk>> e : chunks.entrySet()) { | |
| MigrationVersion v = e.getKey(); | |
| Map<Path, Chunk> cs = e.getValue(); | |
| // boolean manifestContains = manifestVersions.containsKey(v); | |
| Version version = manifestVersions.get(v); | |
| if (version == null) { | |
| StringBuilder hint = | |
| new StringBuilder("\n\nHint add something like (the source order maybe incorrect!) to '"); | |
| hint.append(manifestFile) | |
| .append("':\n\n"); | |
| hint.append(v.getVersion()) | |
| .append("?s=\\\n"); | |
| hint.append( | |
| cs.keySet() | |
| .stream() | |
| .map(p -> "\t," + p.toString()) | |
| .collect(Collectors.joining("\\\n"))); | |
| hint.append("\n"); | |
| errors.add( | |
| Error.create( | |
| ErrorCode.CHUNKS_VERSION_NOT_DECLARED, | |
| "Chunks declared for version but version not in manifest. version: " | |
| + v | |
| + " chunks:\n" | |
| + cs | |
| + hint)); | |
| continue; | |
| } | |
| Collection<Path> paths = version.sourcesAsFiles(workspaceDirectory); | |
| for (Entry<Path, Chunk> ep : cs.entrySet()) { | |
| if (!paths.contains(ep.getKey())) { | |
| errors.add( | |
| Error.create( | |
| ErrorCode.CHUNK_NOT_DECLARED, | |
| "Chunks declared for version but chunk not in manifest. verions: " | |
| + v | |
| + " chunk: " | |
| + ep.getValue())); | |
| continue; | |
| } | |
| } | |
| if (paths.isEmpty()) { | |
| errors.add( | |
| Error.create( | |
| ErrorCode.MANIFEST_VERSION_MISSING_CHUNKS, | |
| "Manifest Version declares no sources. version: " + version)); | |
| continue; | |
| } | |
| StringBuilder sb = new StringBuilder(); | |
| sb.append("-- VERSION: ") | |
| .append(v) | |
| .append("\n"); | |
| for (Path p : paths) { | |
| Chunk c = cs.get(p); | |
| if (c == null) { | |
| errors.add( | |
| Error.create( | |
| ErrorCode.MANIFEST_VERSION_MISSING_CHUNK, | |
| "Path declared for version but no chunks found." | |
| + " path: " | |
| + p | |
| + " version: " | |
| + version)); | |
| continue; | |
| } | |
| sb.append("\n-- START FROM: ") | |
| .append( | |
| c.file() | |
| .getName()) | |
| .append("\n"); | |
| sb.append( | |
| c.rawSQL() | |
| .trim()); | |
| sb.append("\n-- END FROM: ") | |
| .append( | |
| c.file() | |
| .getName()) | |
| .append("\n"); | |
| } | |
| String content = sb.toString();; | |
| MigrationFile file = MigrationFile.create(outputDirectory, version, content); | |
| MigrationFileAndContent.Flag flag = MigrationFileAndContent.Flag.CREATE; | |
| MigrationFileAndContent mfc = new MigrationFileAndContent(file, flag, content); | |
| updates.put(v, mfc); | |
| } | |
| for (Entry<MigrationVersion, MigrationFile> e : migrations.entrySet()) { | |
| MigrationVersion v = e.getKey(); | |
| MigrationFile f = e.getValue(); | |
| MigrationFileAndContent mfc = updates.get(v); | |
| if (mfc != null) { | |
| mfc.originalFile = f; | |
| if (mfc.flag == MigrationFileAndContent.Flag.MISSING) { | |
| // noop | |
| } | |
| else if (!mfc.file.description() | |
| .equals(f.description())) { | |
| mfc.flag = MigrationFileAndContent.Flag.RENAME; | |
| } | |
| else if (mfc.file.checksum() | |
| .equals(f.checksum())) { | |
| mfc.flag = MigrationFileAndContent.Flag.SKIP; | |
| mfc.file = f; | |
| } | |
| else { | |
| mfc.flag = MigrationFileAndContent.Flag.UPDATE; | |
| mfc.file = f; | |
| } | |
| } | |
| else { | |
| Log.info("Manifest has no entry for version: " + v); | |
| } | |
| } | |
| for (Error e : errors) { | |
| Log.error("Error: " + e); | |
| } | |
| if (!errors.isEmpty()) { | |
| Log.info("Because of errors setting dryRun = true"); | |
| dryRun = true; | |
| } | |
| for (Entry<MigrationVersion, MigrationFileAndContent> e : updates.entrySet()) { | |
| // MigrationVersion v = e.getKey(); | |
| MigrationFileAndContent mfc = e.getValue(); | |
| process(mfc, workspaceDirectory, outputDirectory, dryRun); | |
| } | |
| return new RunResult(updates, workspace, errors); | |
| } | |
| private static void process( | |
| MigrationFileAndContent mfc, | |
| File workspaceDirectory, | |
| File outputDirectory, | |
| boolean dryRun) | |
| throws IOException { | |
| switch (mfc.flag) { | |
| case CREATE: | |
| Log.info("Creating " + mfc.file.file()); | |
| if (!dryRun) { | |
| mfc.writeFile(); | |
| } | |
| break; | |
| case SKIP: | |
| Log.info("Skipping " + mfc.file.file()); | |
| break; | |
| case UPDATE: | |
| Log.info("Updating " + mfc.file.file()); | |
| if (!dryRun) { | |
| // mfc.backupOriginal(); | |
| mfc.writeFile(); | |
| } | |
| break; | |
| case RENAME: | |
| Log.info("Renaming \n\t" + mfc.originalFile.file() + "\n\tto\n\t" + mfc.file.file()); | |
| if (!dryRun) { | |
| // mfc.backupOriginal(); | |
| mfc.writeFile(); | |
| } | |
| break; | |
| case MISSING: | |
| Log.info( | |
| "Missing chunks for " | |
| + mfc.file.file() | |
| .getName()); | |
| break; | |
| default: | |
| break; | |
| } | |
| } | |
| record MigrationWorkspace( | |
| File workspaceDirectory, | |
| MigrationManifest manifest, | |
| Map<MigrationVersion, Map<Path, Chunk>> chunks) { | |
| static MigrationWorkspace create( | |
| final File manifestFile, | |
| final File workspaceDirectory) | |
| throws IOException { | |
| Log.info("Reading manifest: " + manifestFile); | |
| MigrationManifest manifest = MigrationManifest.parseFile(manifestFile.getCanonicalPath()); | |
| Log.info("Reading workspace directory: " + workspaceDirectory); | |
| Map<MigrationVersion, Map<Path, Chunk>> chunks = parseFromDirectory(workspaceDirectory); | |
| return new MigrationWorkspace(workspaceDirectory, manifest, chunks); | |
| } | |
| public static Map<MigrationVersion, Map<Path, Chunk>> parseFromDirectory( | |
| File directory) | |
| throws IOException { | |
| Set<String> resources = findResourceNamesFromFileSystem(directory); | |
| TreeMap<MigrationVersion, Map<Path, Chunk>> m = new TreeMap<>(); | |
| for (String r : resources) { | |
| if (r.endsWith(".sql")) { | |
| List<Chunk> chunks = Chunk.parseFile(r); | |
| for (Chunk c : chunks) { | |
| MigrationVersion version = MigrationVersion.fromVersion(c.getVersion()); | |
| Map<Path, Chunk> tmp = m.get(version); | |
| if (tmp == null) { | |
| tmp = new TreeMap<>(); | |
| m.put(version, tmp); | |
| } | |
| Path relativePath = directory.toPath() | |
| .relativize( | |
| c.file() | |
| .toPath()); | |
| if (tmp.containsKey(relativePath)) { | |
| throw new IllegalStateException( | |
| "Multiple chunks registered for same version. bad chunk: " + c); | |
| } | |
| tmp.put(relativePath, c); | |
| } | |
| } | |
| } | |
| return m; | |
| } | |
| } | |
| static String md5( | |
| String contents) { | |
| try { | |
| return printHexBinary( | |
| MessageDigest.getInstance("MD5") | |
| .digest(contents.getBytes(StandardCharsets.UTF_8))); | |
| } | |
| catch (NoSuchAlgorithmException e) { | |
| throw new RuntimeException(e); | |
| } | |
| } | |
| private static final char[] hexCode = "0123456789ABCDEF".toCharArray(); | |
| private static String printHexBinary( | |
| byte[] data) { | |
| StringBuilder r = new StringBuilder(data.length * 2); | |
| for (byte b : data) { | |
| r.append(hexCode[(b >> 4) & 0xF]); | |
| r.append(hexCode[(b & 0xF)]); | |
| } | |
| return r.toString(); | |
| } | |
| // Mutable class! | |
| static class MigrationFileAndContent { | |
| private MigrationFile file; | |
| private MigrationFile originalFile; | |
| private @Nullable String content; | |
| private Flag flag; | |
| @SuppressWarnings("unused") // for later enhancements | |
| private @Nullable Error error; | |
| public static enum Flag { | |
| CREATE, | |
| UPDATE, | |
| RENAME, | |
| SKIP, | |
| MISSING; | |
| } | |
| public MigrationFileAndContent( | |
| MigrationFile file, | |
| Flag flag, | |
| String content) { | |
| this.file = file; | |
| this.flag = flag; | |
| this.content = content; | |
| this.originalFile = file; | |
| } | |
| public MigrationFileAndContent( | |
| MigrationFile file, | |
| Error error, | |
| Flag flag) { | |
| this.file = file; | |
| this.error = error; | |
| this.flag = flag; | |
| this.originalFile = file; | |
| } | |
| public void writeFile() | |
| throws IOException { | |
| String _content = content; | |
| if (_content == null || _content.isEmpty()) { | |
| throw new IOException("This file is not to be written as it has errors or is empty"); | |
| } | |
| Files.write( | |
| file.file() | |
| .toPath(), | |
| _content.getBytes(StandardCharsets.UTF_8)); | |
| } | |
| public void backupOriginal() | |
| throws IOException { | |
| MigrationFile of = requireNonNull(originalFile); | |
| Path p = of.file() | |
| .toPath(); | |
| String name = p.toFile() | |
| .getName(); | |
| String nn = name + ".orig"; | |
| Path np = p.getParent() | |
| .resolve(nn); | |
| Files.move( | |
| of.file() | |
| .toPath(), | |
| np); | |
| } | |
| Flag getFlag() { | |
| return flag; | |
| } | |
| @Nullable | |
| Error getError() { | |
| return error; | |
| } | |
| @Override | |
| public String toString() { | |
| return "MigrationFileAndContent [file=" | |
| + file.file() | |
| + ", originalFile=" | |
| + originalFile.file() | |
| + ", flag=" | |
| + flag | |
| + ", error=" | |
| + error | |
| + "]"; | |
| } | |
| } | |
| record MigrationFile( | |
| File file, | |
| String rawName, | |
| String rawVersion, | |
| MigrationVersion version, | |
| String description, | |
| String checksum, | |
| Source source) { | |
| enum Source { | |
| FILE, | |
| SOURCE | |
| } | |
| static MigrationFile create( | |
| File outputDirectory, | |
| Version version, | |
| String content) { | |
| File file = new File(outputDirectory, version.toFileName()); | |
| String rawVersion = version.version() | |
| .toString(); | |
| MigrationVersion v = version.version(); | |
| String description = version.description(); | |
| String checksum = md5(content); | |
| return new MigrationFile(file, content, rawVersion, v, description, checksum, Source.SOURCE); | |
| } | |
| static String stripSqlExt( | |
| String filename) { | |
| if (filename.endsWith(".sql")) { | |
| return filename.substring(0, filename.lastIndexOf(".sql")); | |
| } | |
| return filename; | |
| } | |
| static MigrationFile create( | |
| final File f) | |
| throws IOException { | |
| if (!isValidFile(f)) { | |
| throw new IllegalArgumentException("bad migration file: " + f); | |
| } | |
| String md5 = md5(readFile(f.getCanonicalPath())); | |
| return create(f, md5); | |
| } | |
| static MigrationFile create( | |
| final File f, | |
| String checksum) | |
| throws IOException { | |
| if (!isValidFile(f)) { | |
| throw new IllegalArgumentException("bad migration file: " + f); | |
| } | |
| final String rawName = f.getName(); | |
| @NonNull | |
| String[] pair = rawName.split("__", 2); | |
| String p = pair[0]; | |
| String description = pair.length == 2 ? pair[1] : ""; | |
| description = stripSqlExt(description); | |
| if (description.equals("")) { | |
| p = stripSqlExt(p); | |
| } | |
| String rawVersion = p.substring(1); | |
| MigrationVersion version = MigrationVersion.fromVersion(rawVersion); | |
| return new MigrationFile(f, rawName, rawVersion, version, description, checksum, Source.FILE); | |
| } | |
| static String readFile( | |
| String path) | |
| throws IOException { | |
| byte[] encoded = Files.readAllBytes(Paths.get(path)); | |
| return new String(encoded, StandardCharsets.UTF_8); | |
| } | |
| static Map<MigrationVersion, MigrationFile> readFromDirectory( | |
| File outputDirectory) | |
| throws IOException { | |
| Set<String> paths = findResourceNamesFromFileSystem(outputDirectory); | |
| TreeMap<MigrationVersion, MigrationFile> files = new TreeMap<>(); | |
| for (String p : paths) { | |
| File f = new File(p); | |
| if (MigrationFile.isValidFile(f)) { | |
| MigrationFile mf = MigrationFile.create(f); | |
| files.put(mf.version(), mf); | |
| } | |
| } | |
| return files; | |
| } | |
| static boolean isValidFile( | |
| File f) { | |
| final String rawName = f.getName(); | |
| if (f.isDirectory()) { | |
| return false; | |
| } | |
| if (!rawName.startsWith("V")) | |
| return false; | |
| if (!rawName.endsWith(".sql")) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| @Override | |
| public String toString() { | |
| return "MigrationFile [file=" | |
| + file | |
| + ", rawName=" | |
| + rawName | |
| + ", rawVersion=" | |
| + rawVersion | |
| + ", version=" | |
| + version | |
| + ", description=" | |
| + description | |
| + ", checksum=" | |
| + checksum | |
| + "]"; | |
| } | |
| } | |
| private static Set<String> findResourceNamesFromFileSystem( | |
| File folder) { | |
| Set<String> resourceNames = new TreeSet<>(); | |
| File[] files = folder.listFiles(); | |
| for (File file : files) { | |
| if (file.canRead()) { | |
| if (file.isDirectory()) { | |
| if (file.isHidden()) { | |
| // #1807: Skip hidden directories to avoid issues with | |
| // Kubernetes | |
| // LOG.debug("Skipping hidden directory: " + | |
| // file.getAbsolutePath()); | |
| } | |
| else { | |
| resourceNames.addAll(findResourceNamesFromFileSystem(file)); | |
| } | |
| } | |
| else { | |
| resourceNames.add(file.getPath()); | |
| } | |
| } | |
| } | |
| return resourceNames; | |
| } | |
| record MigrationManifest( | |
| String file, | |
| SortedMap<MigrationVersion, Version> versions) { | |
| record Version( | |
| String raw, | |
| int lineNumber, | |
| String rawVersion, | |
| MigrationVersion version, | |
| String rawParameters, | |
| Map<String, String> parameters, | |
| List<String> sources, | |
| String description) { | |
| String toFileName() { | |
| final String prefix = "V" + version.toString(); | |
| if (requireNonNull(description).isEmpty()) { | |
| return prefix + ".sql"; | |
| } | |
| return prefix + "__" + description + ".sql"; | |
| } | |
| static List<String> commaSplit( | |
| @Nullable String source) { | |
| if (source == null) { | |
| return Collections.<String> emptyList(); | |
| } | |
| String[] ar = source.split(","); | |
| List<String> list = new ArrayList<>(ar.length); | |
| for (String a : ar) { | |
| if (a == null) | |
| continue; | |
| String s = a.trim(); | |
| if (s.isEmpty()) | |
| continue; | |
| list.add(s); | |
| } | |
| return list; | |
| } | |
| static Version parseVersion( | |
| final String raw, | |
| final int lineNumber) { | |
| URI uri = URI.create(raw); | |
| final String rawVersion = uri.getPath(); | |
| requireNonNull(rawVersion, () -> "version missing at line: " + lineNumber); | |
| final MigrationVersion version = MigrationVersion.fromVersion(rawVersion); | |
| final String rawParameters = uri.getRawQuery(); | |
| requireNonNull(rawParameters, () -> "parameters missing at line: " + lineNumber); | |
| final Map<String, String> parameters = parseUriLikeQuery(rawParameters, true); | |
| String s = MetaParameter.sources.getParameter(parameters); | |
| final List<String> sources = commaSplit(s); | |
| String description = MetaParameter.description.getParameter(parameters); | |
| description = description == null ? "" : description; | |
| return new Version( | |
| raw, | |
| lineNumber, | |
| rawVersion, | |
| version, | |
| rawParameters, | |
| parameters, | |
| sources, | |
| description); | |
| } | |
| Collection<Path> sourcesAsFiles( | |
| File workspaceDirectory) { | |
| Path ws = workspaceDirectory.toPath(); | |
| LinkedHashSet<Path> files = new LinkedHashSet<>(); | |
| for (String s : sources()) { | |
| final Path p; | |
| if (!s.endsWith(".sql")) { | |
| p = new File(workspaceDirectory, s + ".sql").toPath(); | |
| } | |
| else { | |
| p = new File(workspaceDirectory, s).toPath(); | |
| } | |
| files.add(ws.relativize(p)); | |
| } | |
| return files; | |
| } | |
| @Override | |
| public String toString() { | |
| return toString("\n", ",\n\t", "\n"); | |
| } | |
| public String toString( | |
| String start, | |
| String sep, | |
| String end) { | |
| return start | |
| + "Version [raw=" | |
| + raw | |
| + sep | |
| + "lineNumber=" | |
| + lineNumber | |
| + sep | |
| + "version=" | |
| + version | |
| + sep | |
| + "description=" | |
| + description | |
| + sep | |
| + "sources=" | |
| + sources | |
| + "]" | |
| + end; | |
| } | |
| } | |
| static MigrationManifest parseFile( | |
| String file) | |
| throws IOException { | |
| File f = new File(file); | |
| try (InputStreamReader fr = new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8)) { | |
| return parseFile(file, fr); | |
| } | |
| } | |
| static MigrationManifest parseFile( | |
| final String file, | |
| Reader reader) | |
| throws IOException { | |
| BufferedReader br = new BufferedReader(reader); | |
| String line; | |
| List<Version> rawVersions = new ArrayList<>(); | |
| int i = 0; | |
| int lineStart = 0; | |
| StringBuilder current = new StringBuilder(); | |
| boolean wrap = false; | |
| while ((line = br.readLine()) != null) { | |
| line = line.trim(); | |
| if (!wrap) { | |
| lineStart = i; | |
| } | |
| if (line.endsWith("\\")) { | |
| wrap = true; | |
| current.append(line.substring(0, line.length() - 1)); | |
| } | |
| else { | |
| wrap = false; | |
| current.append(line); | |
| String code = current.toString() | |
| .trim(); | |
| if (!code.isEmpty()) { | |
| Version v; | |
| try { | |
| v = Version.parseVersion(code, lineStart); | |
| } | |
| catch (RuntimeException e) { | |
| String error = String.format( | |
| "Version parse error." + "\n\tfile=%s, line=%s, entry=%s, reason=\n\t\t%s", | |
| file, | |
| (lineStart + 1), | |
| code, | |
| e.getMessage()); | |
| throw new IllegalStateException(error, e); | |
| } | |
| rawVersions.add(v); | |
| } | |
| current.setLength(0); | |
| } | |
| i++; | |
| } | |
| TreeMap<MigrationVersion, Version> versions = new TreeMap<>(); | |
| for (Version v : rawVersions) { | |
| versions.put(v.version(), v); | |
| } | |
| MigrationManifest vm = new MigrationManifest(file, Collections.unmodifiableSortedMap(versions)); | |
| return vm; | |
| } | |
| @Override | |
| public String toString() { | |
| return "VersionsManifest [file=" + file + ", versions=" + versions + "]"; | |
| } | |
| } | |
| record Chunk( | |
| File file, | |
| int lineNumberStart, | |
| int lineNumberEnd, | |
| String rawMeta, | |
| String rawSQL, | |
| Map<String, String> meta) { | |
| static Chunk of( | |
| File file, | |
| int lineNumberStart, | |
| int lineNumberEnd, | |
| String rawMeta, | |
| String rawSQL) { | |
| try { | |
| Map<String, String> meta = Collections.unmodifiableMap(parseMeta(rawMeta)); | |
| return new Chunk(file, lineNumberStart, lineNumberEnd, rawMeta, rawSQL, meta); | |
| } | |
| catch (Exception e) { | |
| String error = String.format( | |
| "Chunk parse error." + "\n\tfile=%s, lineStart=%s, lineEnd=%s", | |
| file, | |
| lineNumberStart, | |
| lineNumberEnd); | |
| throw new IllegalStateException(error, e); | |
| } | |
| } | |
| @Override | |
| public String toString() { | |
| return toString("\n", ",\n\t", "\n"); | |
| } | |
| String toString( | |
| String start, | |
| String sep, | |
| String end) { | |
| return start | |
| + "Chunk [file=" | |
| + file | |
| + sep | |
| + "lineNumberStart=" | |
| + lineNumberStart | |
| + sep | |
| + "lineNumberEnd=" | |
| + lineNumberEnd | |
| + "]" | |
| + end; | |
| } | |
| public @Nullable String getVersion() { | |
| return MetaParameter.version.getParameter(meta); | |
| } | |
| public Map<String, String> parseMeta() { | |
| return parseMeta(rawMeta); | |
| } | |
| static Map<String, String> parseMeta( | |
| String rawMeta) { | |
| String query = rawMeta.substring(META_TAG.length()) | |
| .trim(); | |
| return parseUriLikeQuery(query, true); | |
| } | |
| // ----?V=2019_01_02 | |
| static List<Chunk> parseFile( | |
| final String file, | |
| Reader reader) | |
| throws IOException { | |
| BufferedReader br = new BufferedReader(reader); | |
| String line = null; | |
| List<Chunk> chunks = new ArrayList<FlywayPreprocessor.Chunk>(); | |
| int lineNumberStart = 0; | |
| int lineNumberEnd = 0; | |
| String rawMeta = null; | |
| StringBuilder rawSQL = new StringBuilder(); | |
| boolean started = false; | |
| int i = 0; | |
| while ((line = br.readLine()) != null) { | |
| if (line.startsWith(META_TAG)) { | |
| if (started) { | |
| lineNumberEnd = i; | |
| chunks.add( | |
| Chunk.of( | |
| new File(file), | |
| lineNumberStart, | |
| lineNumberEnd, | |
| requireNonNull(rawMeta, "bug"), | |
| rawSQL.toString())); | |
| rawSQL.setLength(0); | |
| rawMeta = line; | |
| lineNumberStart = i; | |
| } | |
| else { | |
| started = true; | |
| rawMeta = line; | |
| lineNumberStart = i; | |
| } | |
| } | |
| else { | |
| if (started) { | |
| rawSQL.append(line) | |
| .append("\n"); | |
| } | |
| } | |
| i++; | |
| } | |
| if (started) { | |
| lineNumberEnd = i; | |
| chunks.add( | |
| Chunk.of( | |
| new File(file), | |
| lineNumberStart, | |
| lineNumberEnd, | |
| requireNonNull(rawMeta, "bug"), | |
| rawSQL.toString())); | |
| } | |
| return chunks; | |
| } | |
| static List<Chunk> parseFile( | |
| final String file) | |
| throws IOException { | |
| File f = new File(file); | |
| try (InputStreamReader fr = new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8)) { | |
| return parseFile(file, fr); | |
| } | |
| } | |
| } | |
| static Map<String, String> parseUriLikeQuery( | |
| @Nullable String query, | |
| boolean decode) { | |
| Map<String, String> queryPairs = new LinkedHashMap<String, String>(); | |
| if (query == null) | |
| return queryPairs; | |
| String[] pairs = query.split("[& ]"); | |
| for (String pair : pairs) { | |
| int idx = pair.indexOf("="); | |
| if (decode) { | |
| queryPairs.put( | |
| URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8), | |
| URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8)); | |
| } | |
| else { | |
| queryPairs.put(pair.substring(0, idx), pair.substring(idx + 1)); | |
| } | |
| } | |
| return queryPairs; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Copyright 2010-2019 Boxfuse GmbH | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| package com.snaphop.flyway; | |
| import static java.util.Objects.requireNonNull; | |
| import java.math.BigInteger; | |
| import java.util.ArrayList; | |
| import java.util.List; | |
| import java.util.regex.Pattern; | |
| import org.eclipse.jdt.annotation.Nullable; | |
| /** | |
| * A version of a migration. | |
| * | |
| * @author Axel Fontaine | |
| */ | |
| public final class MigrationVersion implements Comparable<MigrationVersion> { | |
| /** | |
| * Version for an empty schema. | |
| */ | |
| public static final MigrationVersion EMPTY = new MigrationVersion(null, "<< Empty Schema >>"); | |
| /** | |
| * Latest version. | |
| */ | |
| public static final MigrationVersion LATEST = new MigrationVersion(BigInteger.valueOf(-1), "<< Latest Version >>"); | |
| /** | |
| * Current version. Only a marker. For the real version use | |
| * Flyway.info().current() instead. | |
| */ | |
| public static final MigrationVersion CURRENT = | |
| new MigrationVersion(BigInteger.valueOf(-2), "<< Current Version >>"); | |
| /** | |
| * Compiled pattern for matching proper version format | |
| */ | |
| private static Pattern splitPattern = Pattern.compile("\\.(?=\\d)"); | |
| /** | |
| * The individual parts this version string is composed of. Ex. 1.2.3.4.0 -> | |
| * [1, 2, 3, 4, 0] | |
| */ | |
| private final List<BigInteger> versionParts; | |
| /** | |
| * The printable text to represent the version. | |
| */ | |
| private final String displayText; | |
| /** | |
| * Factory for creating a MigrationVersion from a version String | |
| * | |
| * @param version | |
| * The version String. The value {@code current} will be | |
| * interpreted as MigrationVersion.CURRENT, a marker for the | |
| * latest version that has been applied to the database. | |
| * @return The MigrationVersion | |
| */ | |
| @SuppressWarnings("null") | |
| public static MigrationVersion fromVersion( | |
| @Nullable String version) { | |
| if ("current".equalsIgnoreCase(version)) | |
| return CURRENT; | |
| if (LATEST.getVersion() | |
| .equals(version)) | |
| return LATEST; | |
| if (version == null) | |
| return EMPTY; | |
| return new MigrationVersion(version); | |
| } | |
| /** | |
| * Creates a Version using this version string. | |
| * | |
| * @param version | |
| * The version in one of the following formats: 6, 6.0, 005, | |
| * 1.2.3.4, 201004200021. <br/> | |
| * {@code null} means that this version refers to an empty | |
| * schema. | |
| */ | |
| private MigrationVersion( | |
| String version) { | |
| String normalizedVersion = version.replace('_', '.'); | |
| this.versionParts = tokenize(normalizedVersion); | |
| this.displayText = normalizedVersion; | |
| } | |
| /** | |
| * Creates a Version using this version string. | |
| * | |
| * @param version | |
| * The version in one of the following formats: 6, 6.0, 005, | |
| * 1.2.3.4, 201004200021. <br/> | |
| * {@code null} means that this version refers to an empty | |
| * schema. | |
| * @param displayText | |
| * The alternative text to display instead of the version number. | |
| */ | |
| private MigrationVersion( | |
| @Nullable BigInteger version, | |
| String displayText) { | |
| this.versionParts = new ArrayList<>(); | |
| if (version != null) { | |
| this.versionParts.add(version); | |
| } | |
| this.displayText = displayText; | |
| } | |
| /** | |
| * @return The textual representation of the version. | |
| */ | |
| @Override | |
| public String toString() { | |
| return displayText; | |
| } | |
| /** | |
| * @return Numeric version as String | |
| */ | |
| public @Nullable String getVersion() { | |
| if (this.equals(EMPTY)) | |
| return null; | |
| if (this.equals(LATEST)) | |
| return Long.toString(Long.MAX_VALUE); | |
| return displayText; | |
| } | |
| @Override | |
| public boolean equals( | |
| @Nullable Object o) { | |
| if (this == o) | |
| return true; | |
| if (o == null || getClass() != o.getClass()) | |
| return false; | |
| MigrationVersion version1 = (MigrationVersion) o; | |
| return compareTo(version1) == 0; | |
| } | |
| @Override | |
| public int hashCode() { | |
| return versionParts.hashCode(); | |
| } | |
| /** | |
| * Convenience method for quickly checking whether this version is at least | |
| * as new as this other version. | |
| * | |
| * @param otherVersion | |
| * The other version. | |
| * @return {@code true} if this version is equal or newer, {@code false} if | |
| * it is older. | |
| */ | |
| public boolean isAtLeast( | |
| String otherVersion) { | |
| return compareTo(MigrationVersion.fromVersion(otherVersion)) >= 0; | |
| } | |
| /** | |
| * Convenience method for quickly checking whether this version is newer | |
| * than this other version. | |
| * | |
| * @param otherVersion | |
| * The other version. | |
| * @return {@code true} if this version is newer, {@code false} if it is | |
| * not. | |
| */ | |
| public boolean isNewerThan( | |
| String otherVersion) { | |
| return compareTo(MigrationVersion.fromVersion(otherVersion)) > 0; | |
| } | |
| /** | |
| * Convenience method for quickly checking whether this major version is | |
| * newer than this other major version. | |
| * | |
| * @param otherVersion | |
| * The other version. | |
| * @return {@code true} if this major version is newer, {@code false} if it | |
| * is not. | |
| */ | |
| public boolean isMajorNewerThan( | |
| String otherVersion) { | |
| return getMajor().compareTo( | |
| MigrationVersion.fromVersion(otherVersion) | |
| .getMajor()) > 0; | |
| } | |
| /** | |
| * @return The major version. | |
| */ | |
| public BigInteger getMajor() { | |
| return versionParts.get(0); | |
| } | |
| /** | |
| * @return The major version as a string. | |
| */ | |
| public String getMajorAsString() { | |
| return versionParts.get(0) | |
| .toString(); | |
| } | |
| /** | |
| * @return The minor version as a string. | |
| */ | |
| public String getMinorAsString() { | |
| if (versionParts.size() == 1) { | |
| return "0"; | |
| } | |
| return versionParts.get(1) | |
| .toString(); | |
| } | |
| public int compareTo( | |
| MigrationVersion o) { | |
| requireNonNull(o); | |
| // if (o == null) { | |
| // return 1; | |
| // } | |
| if (this == EMPTY) { | |
| return o == EMPTY ? 0 : Integer.MIN_VALUE; | |
| } | |
| if (this == CURRENT) { | |
| return o == CURRENT ? 0 : Integer.MIN_VALUE; | |
| } | |
| if (this == LATEST) { | |
| return o == LATEST ? 0 : Integer.MAX_VALUE; | |
| } | |
| if (o == EMPTY) { | |
| return Integer.MAX_VALUE; | |
| } | |
| if (o == CURRENT) { | |
| return Integer.MAX_VALUE; | |
| } | |
| if (o == LATEST) { | |
| return Integer.MIN_VALUE; | |
| } | |
| final List<BigInteger> parts1 = versionParts; | |
| final List<BigInteger> parts2 = o.versionParts; | |
| int largestNumberOfParts = Math.max(parts1.size(), parts2.size()); | |
| for (int i = 0; i < largestNumberOfParts; i++) { | |
| final int compared = getOrZero(parts1, i).compareTo(getOrZero(parts2, i)); | |
| if (compared != 0) { | |
| return compared; | |
| } | |
| } | |
| return 0; | |
| } | |
| private BigInteger getOrZero( | |
| List<BigInteger> elements, | |
| int i) { | |
| return i < elements.size() ? elements.get(i) : BigInteger.ZERO; | |
| } | |
| /** | |
| * Splits this string into list of Long | |
| * | |
| * @param str | |
| * The string to split. | |
| * @return The resulting array. | |
| */ | |
| private List<BigInteger> tokenize( | |
| String str) { | |
| List<BigInteger> parts = new ArrayList<>(); | |
| try { | |
| for (String part : splitPattern.split(str)) { | |
| parts.add(new BigInteger(part)); | |
| } | |
| } | |
| catch (NumberFormatException e) { | |
| throw new IllegalArgumentException( | |
| "Invalid version containing non-numeric characters. Only 0..9 and . are allowed. Invalid version: " | |
| + str); | |
| } | |
| for (int i = parts.size() - 1; i > 0; i--) { | |
| if (!parts.get(i) | |
| .equals(BigInteger.ZERO)) { | |
| break; | |
| } | |
| parts.remove(i); | |
| } | |
| return parts; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment