diff --git a/jrunner/src/main/java/jrunner/jrunner.java b/jrunner/src/main/java/jrunner/jrunner.java index 259c32f..080a22a 100644 --- a/jrunner/src/main/java/jrunner/jrunner.java +++ b/jrunner/src/main/java/jrunner/jrunner.java @@ -68,7 +68,7 @@ public class jrunner { msg = msg + nl + "-t trim text"; msg = msg + nl + "-c clear target table"; 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"; @@ -736,14 +736,85 @@ public class jrunner { case "tsv": outputTSV(rs, cols); break; - case "table": case "json": + outputJSON(rs, cols); + break; default: outputCSV(rs, cols); 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 { // Print header row for (int i = 1; i <= cols; i++) {