From b63b9666211cdb78d1de08622d825cf355459be0 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Mon, 10 Aug 2026 17:39:42 -0400 Subject: [PATCH] Implement -f json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json and table were advertised in --help but both fell through to outputCSV, so -f json silently produced CSV. table is now dropped from the help text rather than advertised and unimplemented; json is real. Two things CSV cannot express, both of which callers need: * NULL vs empty string. outputCSV writes an empty field for both, so the merge, reconcile and the wizard all see them as identical. JSON emits null and "" distinctly. * Column types. The wizard's SQL-entry mode defaults every dest column to text purely because CSV carries no type metadata; the header now reports name, type, precision and scale (verified against DB2 for i: CHAR precision=5, DECIMAL precision=7 scale=2). Values are JSON strings, never JSON numbers — a DECIMAL rendered as a number would pass through a float and lose exactness. Output is written incrementally so a large result set streams as CSV does while remaining one valid document. Zero-row results still report their columns, which is what query-column introspection relies on. Co-Authored-By: Claude Opus 5 --- jrunner/src/main/java/jrunner/jrunner.java | 75 +++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) 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++) {