I noticed psql was lacking JSON formatting of query results which I
need for a follow-up patch. It also seems useful generally, so here's
a patch:
postgres=# \pset format json
Output format is json.
postgres=# select * from (values ('one', 2, 'three'), ('four', 5, 'six')) as
sub(a, b, c);
[
{ "a": "one", "b": "2", "c": "three" },
{ "a": "four", "b": "5", "c": "six" }
]
postgres=# \x
Expanded display is on.
postgres=# select * from (values ('one', 2, 'three'), ('four', 5, 'six')) as
sub(a, b, c);
[{
"a": "one",
"b": "2",
"c": "three"
},{
"a": "four",
"b": "5",
"c": "six"
}]
postgres=#
Both normal and expanded output format are optimized for readability
while still saving screen space.
Both formats output the same JSON structure, an array of objects.
Other variants like array-of-arrays or line-separated objects
("jsonline") might be possible, but I didn't want to overengineer it.
On the command line, the format is selected by `psql --json` and `psql -J`.
(I'm not attached to the short option, but -J was free and it's in
line with `psql -H` to select HTML.)
Christoph
>From 5de0629e3664b981b2a246c480a90636ddfc8dd7 Mon Sep 17 00:00:00 2001
From: Christoph Berg <[email protected]>
Date: Fri, 8 Sep 2023 15:59:29 +0200
Subject: [PATCH] Add JSON output format to psql
Query results are formatted as an array of JSON objects. In non-expanded
mode, one object per line is printed, and in expanded mode, one
key-value pair. Use `\pset format json` to enable, or run `psql --json`
or `psql -J`. NULL values are printed as `null`, independently from the
psql null setting, all other values are printed as quoted strings.
---
doc/src/sgml/ref/psql-ref.sgml | 26 +++++-
src/bin/psql/command.c | 6 +-
src/bin/psql/help.c | 1 +
src/bin/psql/startup.c | 6 +-
src/bin/psql/tab-complete.c | 2 +-
src/fe_utils/print.c | 128 +++++++++++++++++++++++++++--
src/include/fe_utils/print.h | 1 +
src/test/regress/expected/psql.out | 67 +++++++++++++++
src/test/regress/sql/psql.sql | 25 ++++++
9 files changed, 253 insertions(+), 9 deletions(-)
diff --git a/doc/src/sgml/ref/psql-ref.sgml b/doc/src/sgml/ref/psql-ref.sgml
index cc7d797159..f1f7eda082 100644
--- a/doc/src/sgml/ref/psql-ref.sgml
+++ b/doc/src/sgml/ref/psql-ref.sgml
@@ -274,6 +274,17 @@ EOF
</listitem>
</varlistentry>
+ <varlistentry id="app-psql-option-json">
+ <term><option>-J</option></term>
+ <term><option>--json</option></term>
+ <listitem>
+ <para>
+ Switches to <acronym>JSON</acronym> output mode. This is
+ equivalent to <command>\pset format json</command>.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="app-psql-option-list">
<term><option>-l</option></term>
<term><option>--list</option></term>
@@ -2956,6 +2967,7 @@ lo_import 152801
<literal>asciidoc</literal>,
<literal>csv</literal>,
<literal>html</literal>,
+ <literal>json</literal>,
<literal>latex</literal>,
<literal>latex-longtable</literal>, <literal>troff-ms</literal>,
<literal>unaligned</literal>, or <literal>wrapped</literal>.
@@ -2972,7 +2984,7 @@ lo_import 152801
in by other programs, for example, tab-separated or comma-separated
format. However, the field separator character is not treated
specially if it appears in a column's value;
- so <acronym>CSV</acronym> format may be better suited for such
+ the <acronym>CSV</acronym> or <acronym>JSON</acronym> formats may be better suited for such
purposes.
</para>
@@ -2997,6 +3009,18 @@ lo_import 152801
<command>\pset csv_fieldsep</command>.
</para>
+ <para><literal>json</literal> format
+ <indexterm>
+ <primary>JSON format</primary>
+ <secondary>in psql</secondary>
+ </indexterm>
+ writes output in JSON format, described in
+ <ulink url="https://www.ietf.org/rfc/rfc4627.txt">RFC 4627</ulink>.
+ The result is an array of JSON objects. In non-expanded mode, one
+ object per line is printed, while in expanded mode, each key-value
+ pair is a separate line.
+ </para>
+
<para><literal>wrapped</literal> format is like <literal>aligned</literal> but wraps
wide data values across lines to make the output fit in the target
column width. The target width is determined as described under
diff --git a/src/bin/psql/command.c b/src/bin/psql/command.c
index 82cc091568..d8d11e9519 100644
--- a/src/bin/psql/command.c
+++ b/src/bin/psql/command.c
@@ -4318,6 +4318,9 @@ _align2string(enum printFormat in)
case PRINT_HTML:
return "html";
break;
+ case PRINT_JSON:
+ return "json";
+ break;
case PRINT_LATEX:
return "latex";
break;
@@ -4408,6 +4411,7 @@ do_pset(const char *param, const char *value, printQueryOpt *popt, bool quiet)
{"asciidoc", PRINT_ASCIIDOC},
{"csv", PRINT_CSV},
{"html", PRINT_HTML},
+ {"json", PRINT_JSON},
{"latex", PRINT_LATEX},
{"troff-ms", PRINT_TROFF_MS},
{"unaligned", PRINT_UNALIGNED},
@@ -4448,7 +4452,7 @@ do_pset(const char *param, const char *value, printQueryOpt *popt, bool quiet)
}
else
{
- pg_log_error("\\pset: allowed formats are aligned, asciidoc, csv, html, latex, latex-longtable, troff-ms, unaligned, wrapped");
+ pg_log_error("\\pset: allowed formats are aligned, asciidoc, csv, html, json, latex, latex-longtable, troff-ms, unaligned, wrapped");
return false;
}
}
diff --git a/src/bin/psql/help.c b/src/bin/psql/help.c
index 3b2d59e2ee..1f5f081c98 100644
--- a/src/bin/psql/help.c
+++ b/src/bin/psql/help.c
@@ -99,6 +99,7 @@ usage(unsigned short int pager)
" field separator for unaligned output (default: \"%s\")\n",
DEFAULT_FIELD_SEP);
HELP0(" -H, --html HTML table output mode\n");
+ HELP0(" -J, --json JSON output mode\n");
HELP0(" -P, --pset=VAR[=ARG] set printing option VAR to ARG (see \\pset command)\n");
HELP0(" -R, --record-separator=STRING\n"
" record separator for unaligned output (default: newline)\n");
diff --git a/src/bin/psql/startup.c b/src/bin/psql/startup.c
index 78526eb9da..b1eed9d3fe 100644
--- a/src/bin/psql/startup.c
+++ b/src/bin/psql/startup.c
@@ -496,6 +496,7 @@ parse_psql_options(int argc, char *argv[], struct adhoc_opts *options)
{"field-separator-zero", no_argument, NULL, 'z'},
{"host", required_argument, NULL, 'h'},
{"html", no_argument, NULL, 'H'},
+ {"json", no_argument, NULL, 'J'},
{"list", no_argument, NULL, 'l'},
{"log-file", required_argument, NULL, 'L'},
{"no-readline", no_argument, NULL, 'n'},
@@ -528,7 +529,7 @@ parse_psql_options(int argc, char *argv[], struct adhoc_opts *options)
memset(options, 0, sizeof *options);
- while ((c = getopt_long(argc, argv, "aAbc:d:eEf:F:h:HlL:no:p:P:qR:sStT:U:v:VwWxXz?01",
+ while ((c = getopt_long(argc, argv, "aAbc:d:eEf:F:h:HJlL:no:p:P:qR:sStT:U:v:VwWxXz?01",
long_options, &optindex)) != -1)
{
switch (c)
@@ -576,6 +577,9 @@ parse_psql_options(int argc, char *argv[], struct adhoc_opts *options)
case 'H':
pset.popt.topt.format = PRINT_HTML;
break;
+ case 'J':
+ pset.popt.topt.format = PRINT_JSON;
+ break;
case 'l':
options->list_dbs = true;
break;
diff --git a/src/bin/psql/tab-complete.c b/src/bin/psql/tab-complete.c
index 049801186c..cf4b84b31e 100644
--- a/src/bin/psql/tab-complete.c
+++ b/src/bin/psql/tab-complete.c
@@ -4902,7 +4902,7 @@ psql_completion(const char *text, int start, int end)
else if (TailMatchesCS("\\pset", MatchAny))
{
if (TailMatchesCS("format"))
- COMPLETE_WITH_CS("aligned", "asciidoc", "csv", "html", "latex",
+ COMPLETE_WITH_CS("aligned", "asciidoc", "csv", "html", "json", "latex",
"latex-longtable", "troff-ms", "unaligned",
"wrapped");
else if (TailMatchesCS("xheader_width"))
diff --git a/src/fe_utils/print.c b/src/fe_utils/print.c
index a91292ab6d..8beb8a3188 100644
--- a/src/fe_utils/print.c
+++ b/src/fe_utils/print.c
@@ -2159,6 +2159,112 @@ print_html_vertical(const printTableContent *cont, FILE *fout)
}
+/**********************/
+/* JSON */
+/**********************/
+
+
+static void
+json_escaped_print(const char *in, FILE *fout)
+{
+ const char *p;
+
+ if (!in)
+ {
+ fputs("null", fout);
+ return;
+ }
+
+ fputc('"', fout);
+
+ for (p = in; *p; p++)
+ {
+ switch (*p)
+ {
+ case '\\':
+ fputs("\\\\", fout);
+ break;
+ case '"':
+ fputs("\\\"", fout);
+ break;
+ case '\b':
+ fputs("\\b", fout);
+ break;
+ case '\f':
+ fputs("\\f", fout);
+ break;
+ case '\n':
+ fputs("\\n", fout);
+ break;
+ case '\r':
+ fputs("\\r", fout);
+ break;
+ case '\t':
+ fputs("\\t", fout);
+ break;
+ default:
+ if (*p >= 0 && *p < 0x20)
+ fprintf(fout, "\\u%04X", *p);
+ else
+ fputc(*p, fout);
+ }
+ }
+
+ fputc('"', fout);
+}
+
+static void
+print_json_text(const printTableContent *cont, FILE *fout, bool vertical)
+{
+ unsigned int i;
+ const char *const *ptr;
+
+ if (cancel_pressed)
+ return;
+
+ /*
+ * The title and footer are never printed in json format.
+ */
+
+ if (cont->opt->start_table)
+ fputc('[', fout);
+ else if (cont->nrows > 0)
+ /* print trailing comma after previous FETCH_COUNT iteration */
+ fputc(',', fout);
+
+ /* print records */
+ for (i = 0, ptr = cont->cells; i < cont->ncolumns * cont->nrows; i++, ptr++)
+ {
+ /* start new record */
+ if (i % cont->ncolumns == 0)
+ {
+ if (cancel_pressed)
+ break;
+ /* print trailing comma after previous record */
+ if (i > 0)
+ fputc(',', fout);
+ fputs(vertical ? "{\n" : "\n{", fout);
+ }
+
+ /* cell */
+ fputs(vertical ? " " : " ", fout);
+ json_escaped_print(cont->headers[i % cont->ncolumns], fout);
+ fputs(": ", fout);
+ json_escaped_print(*ptr, fout);
+
+ /* next cell */
+ if (i % cont->ncolumns != cont->ncolumns - 1)
+ fputs(vertical ? ",\n" : ",", fout);
+ /* end of record */
+ else
+ fputs(vertical ? "\n}" : " }", fout);
+ }
+
+ if (cont->opt->stop_table)
+ fputs(vertical ? "]\n" : "\n]\n", fout);
+}
+
+
/*************************/
/* ASCIIDOC */
/*************************/
@@ -3274,13 +3380,19 @@ printTableAddCell(printTableContent *const content, char *cell,
exit(EXIT_FAILURE);
}
- *content->cell = (char *) mbvalidate((unsigned char *) cell,
- content->opt->encoding);
+ /* cell can be NULL in JSON format */
+ if (cell)
+ {
+ *content->cell = (char *) mbvalidate((unsigned char *) cell,
+ content->opt->encoding);
#ifdef ENABLE_NLS
- if (translate)
- *content->cell = _(*content->cell);
+ if (translate)
+ *content->cell = _(*content->cell);
#endif
+ }
+ else
+ *content->cell = cell;
if (mustfree)
{
@@ -3502,6 +3614,9 @@ printTable(const printTableContent *cont,
else
print_html_text(cont, fout);
break;
+ case PRINT_JSON:
+ print_json_text(cont, fout, cont->opt->expanded == 1);
+ break;
case PRINT_ASCIIDOC:
if (cont->opt->expanded == 1)
print_asciidoc_vertical(cont, fout);
@@ -3581,7 +3696,10 @@ printQuery(const PGresult *result, const printQueryOpt *opt,
bool translate;
if (PQgetisnull(result, r, c))
- cell = opt->nullPrint ? opt->nullPrint : "";
+ if (cont.opt->format == PRINT_JSON)
+ cell = NULL;
+ else
+ cell = opt->nullPrint ? opt->nullPrint : "";
else
{
cell = PQgetvalue(result, r, c);
diff --git a/src/include/fe_utils/print.h b/src/include/fe_utils/print.h
index cfc26b9520..d0d0fe1224 100644
--- a/src/include/fe_utils/print.h
+++ b/src/include/fe_utils/print.h
@@ -32,6 +32,7 @@ enum printFormat
PRINT_ASCIIDOC,
PRINT_CSV,
PRINT_HTML,
+ PRINT_JSON,
PRINT_LATEX,
PRINT_LATEX_LONGTABLE,
PRINT_TROFF_MS,
diff --git a/src/test/regress/expected/psql.out b/src/test/regress/expected/psql.out
index 13e4f6db7b..0ca0ccb67f 100644
--- a/src/test/regress/expected/psql.out
+++ b/src/test/regress/expected/psql.out
@@ -3562,6 +3562,73 @@ execute q;
\pset tableattr
deallocate q;
+\pset expanded off
+-- test json output format
+\pset format json
+prepare q as
+ select 'some"text' as "a\title", E' <foo>\n<bar>' as "junk",
+ '' as "empty", n as int, null as "null"
+ from generate_series(1,2) as n;
+execute q;
+[
+{ "a\\title": "some\"text", "junk": " <foo>\n<bar>", "empty": "", "int": "1", "null": null },
+{ "a\\title": "some\"text", "junk": " <foo>\n<bar>", "empty": "", "int": "2", "null": null }
+]
+execute q \gx
+[{
+ "a\\title": "some\"text",
+ "junk": " <foo>\n<bar>",
+ "empty": "",
+ "int": "1",
+ "null": null
+},{
+ "a\\title": "some\"text",
+ "junk": " <foo>\n<bar>",
+ "empty": "",
+ "int": "2",
+ "null": null
+}]
+deallocate q;
+-- check if commas are placed correctly with FETCH_COUNT
+\set FETCH_COUNT 2
+select i from generate_series(1, 3) g(i);
+[
+{ "i": "1" },
+{ "i": "2" },
+{ "i": "3" }
+]
+select i from generate_series(1, 3) g(i) \gx
+[{
+ "i": "1"
+},{
+ "i": "2"
+},{
+ "i": "3"
+}]
+select i from generate_series(1, 4) g(i);
+[
+{ "i": "1" },
+{ "i": "2" },
+{ "i": "3" },
+{ "i": "4" }
+]
+select i from generate_series(1, 4) g(i) \gx
+[{
+ "i": "1"
+},{
+ "i": "2"
+},{
+ "i": "3"
+},{
+ "i": "4"
+}]
+\unset FETCH_COUNT
+-- empty output
+select;
+[
+]
+select \gx
+[]
-- test latex output format
\pset format latex
\pset border 1
diff --git a/src/test/regress/sql/psql.sql b/src/test/regress/sql/psql.sql
index 695c72d866..c8e1bc4ed3 100644
--- a/src/test/regress/sql/psql.sql
+++ b/src/test/regress/sql/psql.sql
@@ -674,6 +674,31 @@ execute q;
\pset tableattr
deallocate q;
+\pset expanded off
+
+-- test json output format
+
+\pset format json
+
+prepare q as
+ select 'some"text' as "a\title", E' <foo>\n<bar>' as "junk",
+ '' as "empty", n as int, null as "null"
+ from generate_series(1,2) as n;
+execute q;
+execute q \gx
+deallocate q;
+
+-- check if commas are placed correctly with FETCH_COUNT
+\set FETCH_COUNT 2
+select i from generate_series(1, 3) g(i);
+select i from generate_series(1, 3) g(i) \gx
+select i from generate_series(1, 4) g(i);
+select i from generate_series(1, 4) g(i) \gx
+\unset FETCH_COUNT
+
+-- empty output
+select;
+select \gx
-- test latex output format
--
2.43.0