Merge branch 'feat/wrapper-interface'

This commit is contained in:
Paul Trowbridge 2026-08-11 22:10:22 -04:00
commit b69fc6854e

View File

@ -4,6 +4,7 @@ import java.util.*;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path ; import java.nio.file.Path ;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermission;
import java.time.*; import java.time.*;
import java.io.IOException; import java.io.IOException;
import com.microsoft.sqlserver.jdbc.SQLServerBulkCopy; import com.microsoft.sqlserver.jdbc.SQLServerBulkCopy;
@ -40,6 +41,7 @@ public class jrunner {
String nl = "\n"; String nl = "\n";
Boolean queryMode = false; Boolean queryMode = false;
String outputFormat = "csv"; String outputFormat = "csv";
String passFilePath = "";
String msg = ""; String msg = "";
Connection scon = null; Connection scon = null;
Connection dcon = null; Connection dcon = null;
@ -66,10 +68,15 @@ public class jrunner {
msg = msg + nl + "-t trim text"; msg = msg + nl + "-t trim text";
msg = msg + nl + "-c clear target table"; msg = msg + nl + "-c clear target table";
msg = msg + nl + "-b bulk copy into destination (SQL Server dest only)"; msg = msg + nl + "-b bulk copy into destination (SQL Server dest only)";
msg = msg + nl + "-f output format (csv, tsv, table, json) - default: csv"; msg = msg + nl + "-f output format (csv, tsv, json) - default: csv";
msg = msg + nl + "--passfile path to the connection alias file - default: ~/.jrunnerpass";
msg = msg + nl + "--strict exit 1 on failure (default exits 0, for legacy callers)";
msg = msg + nl + "--help info"; msg = msg + nl + "--help info";
msg = msg + nl + ""; msg = msg + nl + "";
msg = msg + nl + "~/.jrunnerpass format:"; msg = msg + nl + "Prefer -sc/-dc aliases over -scp/-dcp: a password given on the";
msg = msg + nl + "command line is visible to any local user via 'ps'.";
msg = msg + nl + "";
msg = msg + nl + "passfile format:";
msg = msg + nl + " [alias]"; msg = msg + nl + " [alias]";
msg = msg + nl + " url=jdbc:..."; msg = msg + nl + " url=jdbc:...";
msg = msg + nl + " user=username"; msg = msg + nl + " user=username";
@ -77,6 +84,12 @@ public class jrunner {
//---------------------------------------parse args into variables------------------------------------------------- //---------------------------------------parse args into variables-------------------------------------------------
// Pre-scan: -sq can fail while the loop is still running, and it must
// honour --strict regardless of where the flag sits in argv.
for (String a : args) {
if (a.equals("--strict")) strictExit = true;
}
for (int i = 0; i < args.length; i = i +1 ){ for (int i = 0; i < args.length; i = i +1 ){
switch (args[i]) { switch (args[i]) {
//source connection alias //source connection alias
@ -120,7 +133,7 @@ public class jrunner {
catch (Exception e) { catch (Exception e) {
//System.out.println(nl + "error reasing source sql file: " + printStackTrace()); //System.out.println(nl + "error reasing source sql file: " + printStackTrace());
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
return; return;
} }
break; break;
@ -140,6 +153,13 @@ public class jrunner {
case "-f": case "-f":
outputFormat = args[i+1].toLowerCase(); outputFormat = args[i+1].toLowerCase();
break; break;
//alias file location (the service user has no home directory)
case "--passfile":
passFilePath = args[i+1];
break;
//exit non-zero on failure (handled in the pre-scan above)
case "--strict":
break;
case "-v": case "-v":
System.out.println(msg); System.out.println(msg);
return; return;
@ -163,13 +183,16 @@ public class jrunner {
} }
} }
// Resolve connection aliases from ~/.jrunnerpass // Resolve connection aliases from the passfile
if (!scAlias.isEmpty() || !dcAlias.isEmpty()) { if (!scAlias.isEmpty() || !dcAlias.isEmpty()) {
Map<String, String[]> connections = loadPassFile(); Path resolvedPassFile = passFilePath.isEmpty()
? Paths.get(System.getProperty("user.home"), ".jrunnerpass")
: Paths.get(passFilePath);
Map<String, String[]> connections = loadPassFile(resolvedPassFile);
if (!scAlias.isEmpty()) { if (!scAlias.isEmpty()) {
String[] sc = connections.get(scAlias); String[] sc = connections.get(scAlias);
if (sc == null) { if (sc == null) {
System.err.println("Error: source alias '" + scAlias + "' not found in ~/.jrunnerpass"); System.err.println("Error: source alias '" + scAlias + "' not found in " + resolvedPassFile);
System.exit(1); System.exit(1);
} }
if (scu.isEmpty()) scu = sc[0]; if (scu.isEmpty()) scu = sc[0];
@ -179,7 +202,7 @@ public class jrunner {
if (!dcAlias.isEmpty()) { if (!dcAlias.isEmpty()) {
String[] dc = connections.get(dcAlias); String[] dc = connections.get(dcAlias);
if (dc == null) { if (dc == null) {
System.err.println("Error: destination alias '" + dcAlias + "' not found in ~/.jrunnerpass"); System.err.println("Error: destination alias '" + dcAlias + "' not found in " + resolvedPassFile);
System.exit(1); System.exit(1);
} }
if (dcu.isEmpty()) dcu = dc[0]; if (dcu.isEmpty()) dcu = dc[0];
@ -210,7 +233,7 @@ public class jrunner {
Class.forName("com.ibm.as400.access.AS400JDBCDriver"); Class.forName("com.ibm.as400.access.AS400JDBCDriver");
} catch (ClassNotFoundException cnf) { } catch (ClassNotFoundException cnf) {
System.out.println("The AS400 JDBC driver did not load"); System.out.println("The AS400 JDBC driver did not load");
System.exit(0); die();
} }
//-------------------------------------------establish connections------------------------------------------------- //-------------------------------------------establish connections-------------------------------------------------
@ -232,7 +255,7 @@ public class jrunner {
} catch (SQLException e) { } catch (SQLException e) {
System.out.println("issue connecting to source:"); System.out.println("issue connecting to source:");
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
if (!queryMode) { if (!queryMode) {
System.out.println(" ✅ source database"); System.out.println(" ✅ source database");
@ -244,7 +267,7 @@ public class jrunner {
} catch (SQLException e) { } catch (SQLException e) {
System.out.println("issue connecting to destination:"); System.out.println("issue connecting to destination:");
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
System.out.println(" ✅ destination database"); System.out.println(" ✅ destination database");
} }
@ -253,17 +276,45 @@ public class jrunner {
stmt = scon.createStatement(); stmt = scon.createStatement();
stmt.setFetchSize(10000); stmt.setFetchSize(10000);
tsStart = Timestamp.from(Instant.now()); tsStart = Timestamp.from(Instant.now());
summaryStart = tsStart;
if (!queryMode) { if (!queryMode) {
System.out.println(tsStart); System.out.println(tsStart);
} }
rs = stmt.executeQuery(sq); // execute() rather than executeQuery(): a statement that returns no
//while (rs.next()) { // result set (DDL, INSERT, DELETE, TRUNCATE, MERGE) is a normal,
// System.out.println(rs.getString("x")); // successful outcome, but executeQuery() raises for it Postgres
//} // "No results were returned by the query", SQL Server "The
// statement did not return a result set". Callers had to allowlist
// those messages to tell a real failure from a working TRUNCATE,
// which made a non-zero exit code impossible to adopt.
boolean hasResultSet = stmt.execute(sq);
// A stored procedure may report update counts before opening its
// cursor, and /opt/sync uses CALL statements as migration sources
// (rlarp.QUOTE_REBUILD and friends). Walk past any update counts to
// the first real result set instead of concluding there is none
// executeQuery() would simply have thrown here. The last count is
// retained so a plain DML statement can still report rows affected.
int updated = -1;
while (!hasResultSet) {
int uc = stmt.getUpdateCount();
if (uc == -1) break; // no further results of any kind
updated = uc;
hasResultSet = stmt.getMoreResults();
}
if (!hasResultSet) {
if (!queryMode) {
System.out.println("------------no result set---------------------------------");
System.out.println("rows affected: " + updated);
}
emitSummary("ok", updated >= 0 ? updated : -1, summaryElapsedMs());
closeQuietly(scon, dcon, queryMode);
return;
}
rs = stmt.getResultSet();
} catch (SQLException e) { } catch (SQLException e) {
System.out.println("issue retrieving rows from source:"); System.out.println("issue retrieving rows from source:");
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
//---------------------------------------build meta--------------------------------------------------------------- //---------------------------------------build meta---------------------------------------------------------------
@ -277,7 +328,7 @@ public class jrunner {
dtn = new String[cols + 1]; dtn = new String[cols + 1];
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
try { try {
for (int i = 1; i <= cols; i++){ for (int i = 1; i <= cols; i++){
@ -288,7 +339,7 @@ public class jrunner {
} }
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
//-------------------------clear the target table if requeted---------------------------------------------------- //-------------------------clear the target table if requeted----------------------------------------------------
if (!queryMode && clear) { if (!queryMode && clear) {
@ -301,7 +352,7 @@ public class jrunner {
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
System.out.println(sql); System.out.println(sql);
System.exit(0); die();
} }
} }
if (queryMode) { if (queryMode) {
@ -310,7 +361,7 @@ public class jrunner {
outputQueryResults(rs, cols, dtn, outputFormat); outputQueryResults(rs, cols, dtn, outputFormat);
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
} else if (bulk && dcu.toLowerCase().startsWith("jdbc:sqlserver:")) { } else if (bulk && dcu.toLowerCase().startsWith("jdbc:sqlserver:")) {
//-------------------------------bulk copy (SQL Server dest)------------------------------------------------- //-------------------------------bulk copy (SQL Server dest)-------------------------------------------------
@ -332,9 +383,10 @@ public class jrunner {
// onto the last progress tick; the trailing " rows written" makes // onto the last progress tick; the trailing " rows written" makes
// it parseable. // it parseable.
System.out.print("\r" + src.rowsWritten()); System.out.print("\r" + src.rowsWritten());
summaryRows = src.rowsWritten();
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
} else if (bulk && dcu.toLowerCase().startsWith("jdbc:postgresql:")) { } else if (bulk && dcu.toLowerCase().startsWith("jdbc:postgresql:")) {
//-------------------------------bulk copy (COPY, Postgres dest)-------------------------------------------- //-------------------------------bulk copy (COPY, Postgres dest)--------------------------------------------
@ -374,9 +426,10 @@ public class jrunner {
} }
cin.endCopy(); cin.endCopy();
System.out.print("\r" + rows); System.out.print("\r" + rows);
summaryRows = rows;
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
} else { } else {
System.out.println("------------row count-------------------------------------"); System.out.println("------------row count-------------------------------------");
@ -510,7 +563,7 @@ public class jrunner {
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
System.out.println(sql); System.out.println(sql);
System.exit(0); die();
} }
sql = ""; sql = "";
} }
@ -522,15 +575,16 @@ public class jrunner {
stmtd = dcon.createStatement(); stmtd = dcon.createStatement();
stmtd.executeUpdate(sql); stmtd.executeUpdate(sql);
System.out.print("\r" + t); System.out.print("\r" + t);
summaryRows = t;
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
System.out.println(sql); System.out.println(sql);
System.exit(0); die();
} }
} }
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
System.exit(0); die();
} }
} }
//System.out.println(sql); //System.out.println(sql);
@ -550,6 +604,7 @@ public class jrunner {
tsEnd = Timestamp.from(Instant.now()); tsEnd = Timestamp.from(Instant.now());
System.out.println(tsStart); System.out.println(tsStart);
System.out.println(tsEnd); System.out.println(tsEnd);
emitSummary("ok", summaryRows, summaryElapsedMs());
} }
//long time = Duration.between(tsStart, tsEnd).toMillis(); //long time = Duration.between(tsStart, tsEnd).toMillis();
//System.out.println("time elapsed: " + time); //System.out.println("time elapsed: " + time);
@ -693,14 +748,85 @@ public class jrunner {
case "tsv": case "tsv":
outputTSV(rs, cols); outputTSV(rs, cols);
break; break;
case "table":
case "json": case "json":
outputJSON(rs, cols);
break;
default: default:
outputCSV(rs, cols); outputCSV(rs, cols);
break; break;
} }
} }
// JSON output. Two things CSV cannot express, both of which callers need:
//
// * NULL vs empty string. outputCSV writes an empty field for both, so
// everything downstream the merge, reconcile, the wizard sees them
// as identical. Here NULL is JSON null and '' is "".
// * Column types. Without them the wizard's SQL-entry mode has to default
// every destination column to text, because a CSV stream carries no
// type metadata for the caller to map.
//
// Values are emitted as JSON strings rather than JSON numbers: a DECIMAL
// rendered as a JSON number would go through a float and lose exactness,
// and callers already treat query output as text. null is the one non-string.
//
// Written incrementally rather than assembled in memory, so a large result
// set streams the same way CSV does while still being one valid document.
private static void outputJSON(ResultSet rs, int cols) throws SQLException {
ResultSetMetaData md = rs.getMetaData();
StringBuilder head = new StringBuilder("{\"columns\":[");
for (int i = 1; i <= cols; i++) {
if (i > 1) head.append(",");
head.append("{\"name\":\"").append(escapeJSON(md.getColumnName(i))).append("\"");
head.append(",\"type\":\"").append(escapeJSON(md.getColumnTypeName(i))).append("\"");
head.append(",\"precision\":").append(md.getPrecision(i));
head.append(",\"scale\":").append(md.getScale(i));
head.append("}");
}
head.append("],\"rows\":[");
System.out.print(head);
boolean firstRow = true;
while (rs.next()) {
if (!firstRow) System.out.print(",");
firstRow = false;
System.out.print("[");
for (int i = 1; i <= cols; i++) {
if (i > 1) System.out.print(",");
String value = rs.getString(i);
if (rs.wasNull() || value == null) {
System.out.print("null");
} else {
System.out.print("\"" + escapeJSON(value) + "\"");
}
}
System.out.print("]");
}
System.out.println("]}");
}
private static String escapeJSON(String value) {
if (value == null) return "";
StringBuilder sb = new StringBuilder(value.length() + 16);
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
case '\b': sb.append("\\b"); break;
case '\f': sb.append("\\f"); break;
default:
// Other control characters are illegal raw in JSON strings.
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
return sb.toString();
}
private static void outputCSV(ResultSet rs, int cols) throws SQLException { private static void outputCSV(ResultSet rs, int cols) throws SQLException {
// Print header row // Print header row
for (int i = 1; i <= cols; i++) { for (int i = 1; i <= cols; i++) {
@ -754,23 +880,80 @@ public class jrunner {
return value; return value;
} }
// Historically every error path did printStackTrace() then System.exit(0),
// so a failed run was indistinguishable from a successful one to any
// caller checking $?. 112 /opt/sync scripts run under `set -e` against
// that behaviour, so flipping it unconditionally would change them all at
// once; --strict makes the correct exit code opt-in until they're audited.
private static boolean strictExit = false;
private static void die() {
emitSummary("error", summaryRows, summaryElapsedMs());
System.exit(strictExit ? 1 : 0);
}
// Row count for the summary. Migration has three write paths (SQLServer
// bulk copy, Postgres COPY, batched INSERT) each with its own counter, so
// whichever runs records the total here.
private static long summaryRows = -1;
private static Timestamp summaryStart = null;
private static long summaryElapsedMs() {
if (summaryStart == null) return -1;
return Duration.between(summaryStart.toInstant(), Instant.now()).toMillis();
}
// A single machine-readable line for the calling process.
//
// Deliberately on stderr: migration-mode stdout carries human-readable
// progress that pipekit streams to its live log, and query-mode stdout must
// stay pure CSV, so anything structured on stdout would corrupt one or the
// other. stderr is already captured in full by the caller.
//
// Replaces having to regex a row count out of prose the count previously
// reached the caller only because a \r progress tick and a trailing
// " rows written" happened to render on the same line.
private static void emitSummary(String status, long rows, long ms) {
StringBuilder sb = new StringBuilder("@summary {\"status\":\"");
sb.append(status).append("\"");
if (rows >= 0) sb.append(",\"rows\":").append(rows);
if (ms >= 0) sb.append(",\"ms\":").append(ms);
sb.append("}");
System.err.println(sb);
}
// Best-effort connection close for the early return taken when a statement
// produced no result set. A failure here cannot invalidate work that the
// server already committed, so it is reported but not fatal.
private static void closeQuietly(Connection scon, Connection dcon, boolean queryMode) {
try {
if (scon != null) scon.close();
if (!queryMode && dcon != null) dcon.close();
} catch (SQLException e) {
System.err.println("issue closing connections: " + e.getMessage());
}
}
private static String escapeTSV(String value) { private static String escapeTSV(String value) {
return value.replaceAll("\t", " ").replaceAll("\n", " ").replaceAll("\r", " "); return value.replaceAll("\t", " ").replaceAll("\n", " ").replaceAll("\r", " ");
} }
// Loads ~/.jrunnerpass and returns a map of alias -> {url, user, pass} // Loads a passfile and returns a map of alias -> {url, user, pass}
// Format: // Format:
// [alias] // [alias]
// url=jdbc:... // url=jdbc:...
// user=username // user=username
// pass=password // pass=password
private static Map<String, String[]> loadPassFile() { //
// Defaults to ~/.jrunnerpass; --passfile overrides it, which is what lets a
// service account with no home directory keep its passwords off argv.
private static Map<String, String[]> loadPassFile(Path passFile) {
Map<String, String[]> connections = new LinkedHashMap<>(); Map<String, String[]> connections = new LinkedHashMap<>();
Path passFile = Paths.get(System.getProperty("user.home"), ".jrunnerpass");
if (!Files.exists(passFile)) { if (!Files.exists(passFile)) {
System.err.println("Error: ~/.jrunnerpass not found"); System.err.println("Error: passfile not found: " + passFile);
System.exit(1); System.exit(1);
} }
warnIfGroupOrWorldReadable(passFile);
try { try {
String currentAlias = null; String currentAlias = null;
String url = "", user = "", pass = ""; String url = "", user = "", pass = "";
@ -795,9 +978,23 @@ public class jrunner {
connections.put(currentAlias, new String[]{url, user, pass}); connections.put(currentAlias, new String[]{url, user, pass});
} }
} catch (IOException e) { } catch (IOException e) {
System.err.println("Error reading ~/.jrunnerpass: " + e.getMessage()); System.err.println("Error reading passfile " + passFile + ": " + e.getMessage());
System.exit(1); System.exit(1);
} }
return connections; return connections;
} }
// The passfile holds plaintext passwords, so a too-permissive mode defeats
// the point of moving them off argv. Warn rather than refuse the file may
// be deliberately group-readable by a service group.
private static void warnIfGroupOrWorldReadable(Path passFile) {
try {
Set<PosixFilePermission> perms = Files.getPosixFilePermissions(passFile);
if (perms.contains(PosixFilePermission.OTHERS_READ)) {
System.err.println("Warning: " + passFile + " is world-readable; chmod 600 it");
}
} catch (Exception e) {
// Non-POSIX filesystem, or permissions unreadable not worth failing over.
}
}
} }