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;
}
}