Implement -f json

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 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-08-10 17:39:42 -04:00
parent 94cd89899a
commit b63b966621

View File

@ -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++) {