import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.List; /** * Executes a .sql file statement by statement, so a schema change can be applied to a database whose * structure Flyway does not maintain (the shared dev database runs with {@code flyway.enabled=false}). * *

Run through {@code scripts/apply-sql-file.ps1}; this program exists because the checkout has no * MySQL client and no Docker, while the JDBC driver is already required by the application. * *

It understands the client-side {@code DELIMITER} directive, so the very same * {@code db/migration/V*.sql} file can be replayed by hand when a deployment skipped it. */ public final class ApplySqlFile { public static void main(String[] args) throws Exception { if (args.length < 4) { System.err.println("usage: ApplySqlFile [--dry-run]"); System.exit(2); } boolean dryRun = args.length > 4 && "--dry-run".equals(args[4].trim()); String url = args[0]; String user = args[1]; String password = args[2]; Path file = Path.of(args[3]); if (!Files.isReadable(file)) { System.err.println("Cannot read " + file.toAbsolutePath()); System.exit(2); } List statements = split(Files.readString(file, StandardCharsets.UTF_8)); if (statements.isEmpty()) { System.out.println("No statements in " + file.getFileName() + "; nothing to do."); return; } if (dryRun) { for (String sql : statements) System.out.println("would execute: " + firstLine(sql)); System.out.println("dry run: " + statements.size() + " statement(s) in " + file.getFileName() + " were not executed"); return; } int executed = 0; try (Connection connection = DriverManager.getConnection(url, user, password); Statement statement = connection.createStatement()) { for (String sql : statements) { System.out.println("executing: " + firstLine(sql)); statement.execute(sql); executed++; } } catch (SQLException exception) { System.err.println("failed after " + executed + " of " + statements.size() + " statements: " + exception.getMessage()); throw exception; } System.out.println("applied " + executed + " statement(s) from " + file.getFileName()); } /** * Splits a script into statements, honouring {@code DELIMITER} and skipping line comments. * *

Statements are cut at the active terminator only when it is outside quotes, because dollar * quoted procedure bodies and Chinese descriptions legitimately contain semicolons. */ static List split(String script) { List statements = new ArrayList<>(); StringBuilder current = new StringBuilder(); String delimiter = ";"; char quote = 0; for (String rawLine : script.split("\r?\n", -1)) { String line = rawLine; String trimmed = line.trim(); if (quote == 0 && (trimmed.startsWith("--") || trimmed.startsWith("#"))) continue; if (quote == 0 && trimmed.regionMatches(true, 0, "DELIMITER", 0, "DELIMITER".length())) { delimiter = trimmed.substring("DELIMITER".length()).trim(); continue; } for (int index = 0; index < line.length(); index++) { char character = line.charAt(index); if (quote != 0) { if (character == quote) quote = 0; } else if (character == '\'' || character == '"' || character == '`') { quote = character; } current.append(character); if (quote == 0 && endsWith(current, delimiter)) { current.setLength(current.length() - delimiter.length()); String sql = current.toString().trim(); if (!sql.isEmpty()) statements.add(sql); current.setLength(0); } } current.append('\n'); } String tail = current.toString().trim(); if (!tail.isEmpty()) statements.add(tail); return statements; } private static boolean endsWith(CharSequence value, String suffix) { return suffix.length() > 0 && value.length() >= suffix.length() && value.toString().endsWith(suffix); } private static String firstLine(String sql) { int newline = sql.indexOf('\n'); String head = (newline < 0 ? sql : sql.substring(0, newline)).trim(); return head.length() > 120 ? head.substring(0, 120) + " ..." : head; } }