On 2026-03-06 Fr 4:38 AM, jian he wrote:
COPY (SELECT 1 UNION ALL SELECT 2) TO stdout WITH (format json);still fails with v25-0002, json_tupledesc_ready is not helpful. I think I figured it out. We need to use BlessTupleDesc in BeginCopyTo. Then let slot->tts_tupleDescriptor point to cstate->queryDesc->tupDesc in CopyToJsonOneRow * CSV, text and json formats share the same TextLike routines except for the * one-row callback. This comment is not useful, I want to delete it. CopyToTextLikeStart + /* JSON-specific initialization */ + if (cstate->opts.format == COPY_FORMAT_JSON) + { + MemoryContext oldcxt; + + /* Allocate reusable JSON output buffer in long-lived context */ + oldcxt = MemoryContextSwitchTo(cstate->copycontext); + initStringInfo(&cstate->json_buf); + MemoryContextSwitchTo(oldcxt); + } We ca just add cstate->json_buf = makeStringInfo(); in BeginCopyTo. v25-0004-COPY-TO-JSON-build-JSON-per-column-support-colum.patch added several fields to the CopyToStateData. Actually, there is a simpler way (construct a new Tupdesc and let composite_to_json do the job), please see my v26-0004.
Yeah, I got some crashes using this, but reworked it a bit, and all now seems good. I was apparently mistaken about the supposed speedup, but the performance is steady.
cheers andrew -- Andrew Dunstan EDB: https://www.enterprisedb.com
From e94cc2d8543153d4ab671b33e4494d4428ef005c Mon Sep 17 00:00:00 2001 From: jian he <[email protected]> Date: Wed, 21 Jan 2026 18:38:24 +0800 Subject: [PATCH v27 1/4] introduce CopyFormat refactor CopyFormatOptions Currently, COPY command format is determined by two booleans (binary, csv_mode) fields in CopyFormatOptions This approach, while functional, isn't ideal for future other implement other format. To simplify adding new formats, we've introduced an enum CopyFormat. This makes the code cleaner and more maintainable, allowing for easier integration of additional formats down the line. The CopyFormat enum was originally contributed by Joel Jacobson <[email protected]>, later refactored by Jian He to address various issues. discussion: https://postgr.es/m/CALvfUkBxTYy5uWPFVwpk_7ii2zgT07t3d-yR_cy4sfrrLU%3Dkcg%40mail.gmail.com discussion: https://postgr.es/m/[email protected] --- src/backend/commands/copy.c | 50 +++++++++++++++------------- src/backend/commands/copyfrom.c | 6 ++-- src/backend/commands/copyfromparse.c | 7 ++-- src/backend/commands/copyto.c | 8 ++--- src/include/commands/copy.h | 13 ++++++-- src/tools/pgindent/typedefs.list | 1 + 6 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c index 63b86802ba2..2f46be516f2 100644 --- a/src/backend/commands/copy.c +++ b/src/backend/commands/copy.c @@ -576,6 +576,8 @@ ProcessCopyOptions(ParseState *pstate, opts_out = palloc0_object(CopyFormatOptions); opts_out->file_encoding = -1; + /* default format */ + opts_out->format = COPY_FORMAT_TEXT; /* Extract options from the statement node tree */ foreach(option, options) @@ -590,11 +592,11 @@ ProcessCopyOptions(ParseState *pstate, errorConflictingDefElem(defel, pstate); format_specified = true; if (strcmp(fmt, "text") == 0) - /* default format */ ; + opts_out->format = COPY_FORMAT_TEXT; else if (strcmp(fmt, "csv") == 0) - opts_out->csv_mode = true; + opts_out->format = COPY_FORMAT_CSV; else if (strcmp(fmt, "binary") == 0) - opts_out->binary = true; + opts_out->format = COPY_FORMAT_BINARY; else ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), @@ -754,31 +756,31 @@ ProcessCopyOptions(ParseState *pstate, * Check for incompatible options (must do these three before inserting * defaults) */ - if (opts_out->binary && opts_out->delim) + if (opts_out->format == COPY_FORMAT_BINARY && opts_out->delim) ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ errmsg("cannot specify %s in BINARY mode", "DELIMITER"))); - if (opts_out->binary && opts_out->null_print) + if (opts_out->format == COPY_FORMAT_BINARY && opts_out->null_print) ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), errmsg("cannot specify %s in BINARY mode", "NULL"))); - if (opts_out->binary && opts_out->default_print) + if (opts_out->format == COPY_FORMAT_BINARY && opts_out->default_print) ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), errmsg("cannot specify %s in BINARY mode", "DEFAULT"))); /* Set defaults for omitted options */ if (!opts_out->delim) - opts_out->delim = opts_out->csv_mode ? "," : "\t"; + opts_out->delim = (opts_out->format == COPY_FORMAT_CSV) ? "," : "\t"; if (!opts_out->null_print) - opts_out->null_print = opts_out->csv_mode ? "" : "\\N"; + opts_out->null_print = (opts_out->format == COPY_FORMAT_CSV) ? "" : "\\N"; opts_out->null_print_len = strlen(opts_out->null_print); - if (opts_out->csv_mode) + if (opts_out->format == COPY_FORMAT_CSV) { if (!opts_out->quote) opts_out->quote = "\""; @@ -826,7 +828,7 @@ ProcessCopyOptions(ParseState *pstate, * future-proofing. Likewise we disallow all digits though only octal * digits are actually dangerous. */ - if (!opts_out->csv_mode && + if (opts_out->format != COPY_FORMAT_CSV && strchr("\\.abcdefghijklmnopqrstuvwxyz0123456789", opts_out->delim[0]) != NULL) ereport(ERROR, @@ -834,43 +836,43 @@ ProcessCopyOptions(ParseState *pstate, errmsg("COPY delimiter cannot be \"%s\"", opts_out->delim))); /* Check header */ - if (opts_out->binary && opts_out->header_line != COPY_HEADER_FALSE) + if (opts_out->format == COPY_FORMAT_BINARY && opts_out->header_line != COPY_HEADER_FALSE) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ errmsg("cannot specify %s in BINARY mode", "HEADER"))); /* Check quote */ - if (!opts_out->csv_mode && opts_out->quote != NULL) + if (opts_out->format != COPY_FORMAT_CSV && opts_out->quote != NULL) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ errmsg("COPY %s requires CSV mode", "QUOTE"))); - if (opts_out->csv_mode && strlen(opts_out->quote) != 1) + if (opts_out->format == COPY_FORMAT_CSV && strlen(opts_out->quote) != 1) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("COPY quote must be a single one-byte character"))); - if (opts_out->csv_mode && opts_out->delim[0] == opts_out->quote[0]) + if (opts_out->format == COPY_FORMAT_CSV && opts_out->delim[0] == opts_out->quote[0]) ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("COPY delimiter and quote must be different"))); /* Check escape */ - if (!opts_out->csv_mode && opts_out->escape != NULL) + if (opts_out->format != COPY_FORMAT_CSV && opts_out->escape != NULL) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ errmsg("COPY %s requires CSV mode", "ESCAPE"))); - if (opts_out->csv_mode && strlen(opts_out->escape) != 1) + if (opts_out->format == COPY_FORMAT_CSV && strlen(opts_out->escape) != 1) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("COPY escape must be a single one-byte character"))); /* Check force_quote */ - if (!opts_out->csv_mode && (opts_out->force_quote || opts_out->force_quote_all)) + if (opts_out->format != COPY_FORMAT_CSV && (opts_out->force_quote || opts_out->force_quote_all)) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ @@ -884,8 +886,8 @@ ProcessCopyOptions(ParseState *pstate, "COPY FROM"))); /* Check force_notnull */ - if (!opts_out->csv_mode && (opts_out->force_notnull != NIL || - opts_out->force_notnull_all)) + if (opts_out->format != COPY_FORMAT_CSV && (opts_out->force_notnull != NIL || + opts_out->force_notnull_all)) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ @@ -900,8 +902,8 @@ ProcessCopyOptions(ParseState *pstate, "COPY TO"))); /* Check force_null */ - if (!opts_out->csv_mode && (opts_out->force_null != NIL || - opts_out->force_null_all)) + if (opts_out->format != COPY_FORMAT_CSV && (opts_out->force_null != NIL || + opts_out->force_null_all)) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ @@ -925,7 +927,7 @@ ProcessCopyOptions(ParseState *pstate, "NULL"))); /* Don't allow the CSV quote char to appear in the null string. */ - if (opts_out->csv_mode && + if (opts_out->format == COPY_FORMAT_CSV && strchr(opts_out->null_print, opts_out->quote[0]) != NULL) ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), @@ -961,7 +963,7 @@ ProcessCopyOptions(ParseState *pstate, "DEFAULT"))); /* Don't allow the CSV quote char to appear in the default string. */ - if (opts_out->csv_mode && + if (opts_out->format == COPY_FORMAT_CSV && strchr(opts_out->default_print, opts_out->quote[0]) != NULL) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), @@ -978,7 +980,7 @@ ProcessCopyOptions(ParseState *pstate, errmsg("NULL specification and DEFAULT specification cannot be the same"))); } /* Check on_error */ - if (opts_out->binary && opts_out->on_error != COPY_ON_ERROR_STOP) + if (opts_out->format == COPY_FORMAT_BINARY && opts_out->on_error != COPY_ON_ERROR_STOP) ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), errmsg("only ON_ERROR STOP is allowed in BINARY mode"))); diff --git a/src/backend/commands/copyfrom.c b/src/backend/commands/copyfrom.c index 2f42f55e229..4d927410159 100644 --- a/src/backend/commands/copyfrom.c +++ b/src/backend/commands/copyfrom.c @@ -156,9 +156,9 @@ static const CopyFromRoutine CopyFromRoutineBinary = { static const CopyFromRoutine * CopyFromGetRoutine(const CopyFormatOptions *opts) { - if (opts->csv_mode) + if (opts->format == COPY_FORMAT_CSV) return &CopyFromRoutineCSV; - else if (opts->binary) + else if (opts->format == COPY_FORMAT_BINARY) return &CopyFromRoutineBinary; /* default is text */ @@ -262,7 +262,7 @@ CopyFromErrorCallback(void *arg) cstate->cur_relname); return; } - if (cstate->opts.binary) + if (cstate->opts.format == COPY_FORMAT_BINARY) { /* can't usefully display the data */ if (cstate->cur_attname) diff --git a/src/backend/commands/copyfromparse.c b/src/backend/commands/copyfromparse.c index fbd13353efc..c366874bd95 100644 --- a/src/backend/commands/copyfromparse.c +++ b/src/backend/commands/copyfromparse.c @@ -172,7 +172,7 @@ ReceiveCopyBegin(CopyFromState cstate) { StringInfoData buf; int natts = list_length(cstate->attnumlist); - int16 format = (cstate->opts.binary ? 1 : 0); + int16 format = (cstate->opts.format == COPY_FORMAT_BINARY ? 1 : 0); int i; pq_beginmessage(&buf, PqMsg_CopyInResponse); @@ -750,7 +750,7 @@ bool NextCopyFromRawFields(CopyFromState cstate, char ***fields, int *nfields) { return NextCopyFromRawFieldsInternal(cstate, fields, nfields, - cstate->opts.csv_mode); + cstate->opts.format == COPY_FORMAT_CSV); } /* @@ -777,7 +777,8 @@ NextCopyFromRawFieldsInternal(CopyFromState cstate, char ***fields, int *nfields bool done = false; /* only available for text or csv input */ - Assert(!cstate->opts.binary); + Assert(cstate->opts.format == COPY_FORMAT_TEXT || + cstate->opts.format == COPY_FORMAT_CSV); /* on input check that the header line is correct if needed */ if (cstate->cur_lineno == 0 && cstate->opts.header_line != COPY_HEADER_FALSE) diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c index 9ceeff6d99e..0325a16f82a 100644 --- a/src/backend/commands/copyto.c +++ b/src/backend/commands/copyto.c @@ -181,9 +181,9 @@ static const CopyToRoutine CopyToRoutineBinary = { static const CopyToRoutine * CopyToGetRoutine(const CopyFormatOptions *opts) { - if (opts->csv_mode) + if (opts->format == COPY_FORMAT_CSV) return &CopyToRoutineCSV; - else if (opts->binary) + else if (opts->format == COPY_FORMAT_BINARY) return &CopyToRoutineBinary; /* default is text */ @@ -220,7 +220,7 @@ CopyToTextLikeStart(CopyToState cstate, TupleDesc tupDesc) colname = NameStr(TupleDescAttr(tupDesc, attnum - 1)->attname); - if (cstate->opts.csv_mode) + if (cstate->opts.format == COPY_FORMAT_CSV) CopyAttributeOutCSV(cstate, colname, false); else CopyAttributeOutText(cstate, colname); @@ -397,7 +397,7 @@ SendCopyBegin(CopyToState cstate) { StringInfoData buf; int natts = list_length(cstate->attnumlist); - int16 format = (cstate->opts.binary ? 1 : 0); + int16 format = (cstate->opts.format == COPY_FORMAT_BINARY ? 1 : 0); int i; pq_beginmessage(&buf, PqMsg_CopyOutResponse); diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h index 877202af67b..2430fb0b2e5 100644 --- a/src/include/commands/copy.h +++ b/src/include/commands/copy.h @@ -49,6 +49,16 @@ typedef enum CopyLogVerbosityChoice COPY_LOG_VERBOSITY_VERBOSE, /* logs additional messages */ } CopyLogVerbosityChoice; +/* + * Represents the format of the COPY operation. + */ +typedef enum CopyFormat +{ + COPY_FORMAT_TEXT = 0, + COPY_FORMAT_BINARY, + COPY_FORMAT_CSV, +} CopyFormat; + /* * A struct to hold COPY options, in a parsed form. All of these are related * to formatting, except for 'freeze', which doesn't really belong here, but @@ -59,9 +69,8 @@ typedef struct CopyFormatOptions /* parameters from the COPY command */ int file_encoding; /* file or remote side's character encoding, * -1 if not specified */ - bool binary; /* binary format? */ + CopyFormat format; /* format of the COPY operation */ bool freeze; /* freeze rows on loading? */ - bool csv_mode; /* Comma Separated Value format? */ int header_line; /* number of lines to skip or COPY_HEADER_XXX * value (see the above) */ char *null_print; /* NULL marker string (server encoding!) */ diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 77e3c04144e..8399be97fd5 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -528,6 +528,7 @@ ConversionLocation ConvertRowtypeExpr CookedConstraint CopyDest +CopyFormat CopyFormatOptions CopyFromRoutine CopyFromState -- 2.43.0
From b3222f265bd157690d5f69ea8eba84be13e5910a Mon Sep 17 00:00:00 2001 From: jian he <[email protected]> Date: Fri, 6 Mar 2026 14:32:48 +0800 Subject: [PATCH v27 2/4] json format for COPY TO This introduces the JSON format option for the COPY TO command, allowing users to export query results or table data directly as a single JSON object or a stream of JSON objects. The JSON format is currently supported only for COPY TO operations; it is not available for COPY FROM. JSON format is incompatible with some standard text/CSV parsing or formatting options, including: - HEADER - DEFAULT - NULL - DELIMITER - FORCE QUOTE / FORCE NOT NULL Regression tests covering valid JSON exports and error handling for incompatible options have been added to src/test/regress/sql/copy.sql. Author: Joe Conway <[email protected]> Author: jian he <[email protected]> Reviewed-by: "Andrey M. Borodin" <[email protected]>, Reviewed-by: Dean Rasheed <[email protected]>, Reviewed-by: Daniel Verite <[email protected]>, Reviewed-by: Andrew Dunstan <[email protected]>, Reviewed-by: Davin Shearer <[email protected]>, Reviewed-by: Masahiko Sawada <[email protected]>, Reviewed-by: Alvaro Herrera <[email protected]> Reviewed-by: Junwang Zhao <[email protected]> discussion: https://postgr.es/m/CALvfUkBxTYy5uWPFVwpk_7ii2zgT07t3d-yR_cy4sfrrLU%3Dkcg%40mail.gmail.com discussion: https://postgr.es/m/[email protected] --- doc/src/sgml/ref/copy.sgml | 13 +++-- src/backend/commands/copy.c | 49 ++++++++++++----- src/backend/commands/copyto.c | 85 +++++++++++++++++++++++++---- src/backend/parser/gram.y | 8 +++ src/backend/utils/adt/json.c | 5 +- src/bin/psql/tab-complete.in.c | 2 +- src/include/commands/copy.h | 1 + src/include/utils/json.h | 2 + src/test/regress/expected/copy.out | 86 ++++++++++++++++++++++++++++++ src/test/regress/sql/copy.sql | 51 ++++++++++++++++++ 10 files changed, 271 insertions(+), 31 deletions(-) diff --git a/doc/src/sgml/ref/copy.sgml b/doc/src/sgml/ref/copy.sgml index 0ad890ef95f..75f55bbf6f8 100644 --- a/doc/src/sgml/ref/copy.sgml +++ b/doc/src/sgml/ref/copy.sgml @@ -228,10 +228,15 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable Selects the data format to be read or written: <literal>text</literal>, <literal>csv</literal> (Comma Separated Values), + <literal>json</literal> (JavaScript Object Notation), or <literal>binary</literal>. The default is <literal>text</literal>. See <xref linkend="sql-copy-file-formats"/> below for details. </para> + <para> + The <literal>json</literal> option is allowed only in + <command>COPY TO</command>. + </para> </listitem> </varlistentry> @@ -266,7 +271,7 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable (line) of the file. The default is a tab character in text format, a comma in <literal>CSV</literal> format. This must be a single one-byte character. - This option is not allowed when using <literal>binary</literal> format. + This option is not allowed when using <literal>binary</literal> or <literal>json</literal> format. </para> </listitem> </varlistentry> @@ -280,7 +285,7 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable string in <literal>CSV</literal> format. You might prefer an empty string even in text format for cases where you don't want to distinguish nulls from empty strings. - This option is not allowed when using <literal>binary</literal> format. + This option is not allowed when using <literal>binary</literal> or <literal>json</literal> format. </para> <note> @@ -303,7 +308,7 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable is found in the input file, the default value of the corresponding column will be used. This option is allowed only in <command>COPY FROM</command>, and only when - not using <literal>binary</literal> format. + not using <literal>binary</literal> or <literal>json</literal> format. </para> </listitem> </varlistentry> @@ -330,7 +335,7 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable <command>COPY FROM</command> commands. </para> <para> - This option is not allowed when using <literal>binary</literal> format. + This option is not allowed when using <literal>binary</literal> or <literal>json</literal> format. </para> </listitem> </varlistentry> diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c index 2f46be516f2..29c121c7f08 100644 --- a/src/backend/commands/copy.c +++ b/src/backend/commands/copy.c @@ -597,6 +597,8 @@ ProcessCopyOptions(ParseState *pstate, opts_out->format = COPY_FORMAT_CSV; else if (strcmp(fmt, "binary") == 0) opts_out->format = COPY_FORMAT_BINARY; + else if (strcmp(fmt, "json") == 0) + opts_out->format = COPY_FORMAT_JSON; else ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), @@ -756,21 +758,32 @@ ProcessCopyOptions(ParseState *pstate, * Check for incompatible options (must do these three before inserting * defaults) */ - if (opts_out->format == COPY_FORMAT_BINARY && opts_out->delim) + if (opts_out->delim && + (opts_out->format == COPY_FORMAT_BINARY || + opts_out->format == COPY_FORMAT_JSON)) ereport(ERROR, - (errcode(ERRCODE_SYNTAX_ERROR), - /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ - errmsg("cannot specify %s in BINARY mode", "DELIMITER"))); + errcode(ERRCODE_SYNTAX_ERROR), + opts_out->format == COPY_FORMAT_BINARY + ? errmsg("cannot specify %s in BINARY mode", "DELIMITER") + : errmsg("cannot specify %s in JSON mode", "DELIMITER")); - if (opts_out->format == COPY_FORMAT_BINARY && opts_out->null_print) + if (opts_out->null_print && + (opts_out->format == COPY_FORMAT_BINARY || + opts_out->format == COPY_FORMAT_JSON)) ereport(ERROR, - (errcode(ERRCODE_SYNTAX_ERROR), - errmsg("cannot specify %s in BINARY mode", "NULL"))); + errcode(ERRCODE_SYNTAX_ERROR), + opts_out->format == COPY_FORMAT_BINARY + ? errmsg("cannot specify %s in BINARY mode", "NULL") + : errmsg("cannot specify %s in JSON mode", "NULL")); - if (opts_out->format == COPY_FORMAT_BINARY && opts_out->default_print) + if (opts_out->default_print && + (opts_out->format == COPY_FORMAT_BINARY || + opts_out->format == COPY_FORMAT_JSON)) ereport(ERROR, - (errcode(ERRCODE_SYNTAX_ERROR), - errmsg("cannot specify %s in BINARY mode", "DEFAULT"))); + errcode(ERRCODE_SYNTAX_ERROR), + opts_out->format == COPY_FORMAT_BINARY + ? errmsg("cannot specify %s in BINARY mode", "DEFAULT") + : errmsg("cannot specify %s in JSON mode", "DEFAULT")); /* Set defaults for omitted options */ if (!opts_out->delim) @@ -836,11 +849,15 @@ ProcessCopyOptions(ParseState *pstate, errmsg("COPY delimiter cannot be \"%s\"", opts_out->delim))); /* Check header */ - if (opts_out->format == COPY_FORMAT_BINARY && opts_out->header_line != COPY_HEADER_FALSE) + if (opts_out->header_line != COPY_HEADER_FALSE && + (opts_out->format == COPY_FORMAT_BINARY || + opts_out->format == COPY_FORMAT_JSON)) ereport(ERROR, - (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errcode(ERRCODE_FEATURE_NOT_SUPPORTED), /*- translator: %s is the name of a COPY option, e.g. ON_ERROR */ - errmsg("cannot specify %s in BINARY mode", "HEADER"))); + opts_out->format == COPY_FORMAT_BINARY + ? errmsg("cannot specify %s in BINARY mode", "HEADER") + : errmsg("cannot specify %s in JSON mode", "HEADER")); /* Check quote */ if (opts_out->format != COPY_FORMAT_CSV && opts_out->quote != NULL) @@ -944,6 +961,12 @@ ProcessCopyOptions(ParseState *pstate, errmsg("COPY %s cannot be used with %s", "FREEZE", "COPY TO"))); + /* Check json format */ + if (opts_out->format == COPY_FORMAT_JSON && is_from) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("COPY %s mode cannot be used with %s", "JSON", "COPY FROM")); + if (opts_out->default_print) { if (!is_from) diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c index 0325a16f82a..e87310ec5a0 100644 --- a/src/backend/commands/copyto.c +++ b/src/backend/commands/copyto.c @@ -26,6 +26,7 @@ #include "executor/execdesc.h" #include "executor/executor.h" #include "executor/tuptable.h" +#include "funcapi.h" #include "libpq/libpq.h" #include "libpq/pqformat.h" #include "mb/pg_wchar.h" @@ -33,6 +34,7 @@ #include "pgstat.h" #include "storage/fd.h" #include "tcop/tcopprot.h" +#include "utils/json.h" #include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/rel.h" @@ -84,6 +86,8 @@ typedef struct CopyToStateData List *attnumlist; /* integer list of attnums to copy */ char *filename; /* filename, or NULL for STDOUT */ bool is_program; /* is 'filename' a program to popen? */ + StringInfo json_buf; /* reusable buffer for JSON output, it is + * initliazed in BeginCopyTo */ copy_data_dest_cb data_dest_cb; /* function for writing data */ CopyFormatOptions opts; @@ -130,6 +134,7 @@ static void CopyToCSVOneRow(CopyToState cstate, TupleTableSlot *slot); static void CopyToTextLikeOneRow(CopyToState cstate, TupleTableSlot *slot, bool is_csv); static void CopyToTextLikeEnd(CopyToState cstate); +static void CopyToJsonOneRow(CopyToState cstate, TupleTableSlot *slot); static void CopyToBinaryStart(CopyToState cstate, TupleDesc tupDesc); static void CopyToBinaryOutFunc(CopyToState cstate, Oid atttypid, FmgrInfo *finfo); static void CopyToBinaryOneRow(CopyToState cstate, TupleTableSlot *slot); @@ -148,9 +153,6 @@ static void CopySendInt16(CopyToState cstate, int16 val); /* * COPY TO routines for built-in formats. - * - * CSV and text formats share the same TextLike routines except for the - * one-row callback. */ /* text format */ @@ -169,6 +171,14 @@ static const CopyToRoutine CopyToRoutineCSV = { .CopyToEnd = CopyToTextLikeEnd, }; +/* json format */ +static const CopyToRoutine CopyToRoutineJson = { + .CopyToStart = CopyToTextLikeStart, + .CopyToOutFunc = CopyToTextLikeOutFunc, + .CopyToOneRow = CopyToJsonOneRow, + .CopyToEnd = CopyToTextLikeEnd, +}; + /* binary format */ static const CopyToRoutine CopyToRoutineBinary = { .CopyToStart = CopyToBinaryStart, @@ -185,12 +195,14 @@ CopyToGetRoutine(const CopyFormatOptions *opts) return &CopyToRoutineCSV; else if (opts->format == COPY_FORMAT_BINARY) return &CopyToRoutineBinary; + else if (opts->format == COPY_FORMAT_JSON) + return &CopyToRoutineJson; /* default is text */ return &CopyToRoutineText; } -/* Implementation of the start callback for text and CSV formats */ +/* Implementation of the start callback for text, CSV, and json formats */ static void CopyToTextLikeStart(CopyToState cstate, TupleDesc tupDesc) { @@ -209,6 +221,8 @@ CopyToTextLikeStart(CopyToState cstate, TupleDesc tupDesc) ListCell *cur; bool hdr_delim = false; + Assert(cstate->opts.format != COPY_FORMAT_JSON); + foreach(cur, cstate->attnumlist) { int attnum = lfirst_int(cur); @@ -231,7 +245,7 @@ CopyToTextLikeStart(CopyToState cstate, TupleDesc tupDesc) } /* - * Implementation of the outfunc callback for text and CSV formats. Assign + * Implementation of the outfunc callback for text, CSV, and json formats. Assign * the output function data to the given *finfo. */ static void @@ -304,13 +318,38 @@ CopyToTextLikeOneRow(CopyToState cstate, CopySendTextLikeEndOfRow(cstate); } -/* Implementation of the end callback for text and CSV formats */ +/* Implementation of the end callback for text, CSV, and json formats */ static void CopyToTextLikeEnd(CopyToState cstate) { /* Nothing to do here */ } +/* Implementation of per-row callback for json format */ +static void +CopyToJsonOneRow(CopyToState cstate, TupleTableSlot *slot) +{ + Datum rowdata; + + /* + * composite_to_json() requires a stable TupleDesc. The slot's descriptor + * (slot->tts_tupleDescriptor) may change during the execution of a SELECT + * query, using cstate->queryDesc instead. No need worry this if COPY TO + * is directly from a table. + */ + if (!cstate->rel) + slot->tts_tupleDescriptor = cstate->queryDesc->tupDesc; + + resetStringInfo(cstate->json_buf); + + rowdata = ExecFetchSlotHeapTupleDatum(slot); + composite_to_json(rowdata, cstate->json_buf, false); + + CopySendData(cstate, cstate->json_buf->data, cstate->json_buf->len); + + CopySendTextLikeEndOfRow(cstate); +} + /* * Implementation of the start callback for binary format. Send a header * for a binary copy. @@ -402,9 +441,23 @@ SendCopyBegin(CopyToState cstate) pq_beginmessage(&buf, PqMsg_CopyOutResponse); pq_sendbyte(&buf, format); /* overall format */ - pq_sendint16(&buf, natts); - for (i = 0; i < natts; i++) - pq_sendint16(&buf, format); /* per-column formats */ + if (cstate->opts.format != COPY_FORMAT_JSON) + { + pq_sendint16(&buf, natts); + for (i = 0; i < natts; i++) + pq_sendint16(&buf, format); /* per-column formats */ + } + else + { + /* + * For JSON format, report one text-format column. Each CopyData + * message contains one complete JSON object, not individual column + * values, so the per-column count is always 1. + */ + pq_sendint16(&buf, 1); + pq_sendint16(&buf, 0); + } + pq_endmessage(&buf); cstate->copy_dest = COPY_FRONTEND; } @@ -506,7 +559,7 @@ CopySendEndOfRow(CopyToState cstate) } /* - * Wrapper function of CopySendEndOfRow for text and CSV formats. Sends the + * Wrapper function of CopySendEndOfRow for text, CSV, and json formats. Sends the * line termination and do common appropriate things for the end of row. */ static inline void @@ -885,11 +938,23 @@ BeginCopyTo(ParseState *pstate, ExecutorStart(cstate->queryDesc, 0); tupDesc = cstate->queryDesc->tupDesc; + tupDesc = BlessTupleDesc(tupDesc); } /* Generate or convert list of attributes to process */ cstate->attnumlist = CopyGetAttnums(tupDesc, cstate->rel, attnamelist); + /* JSON outputs whole rows; a column list doesn't make sense */ + if (cstate->opts.format == COPY_FORMAT_JSON) + { + cstate->json_buf = makeStringInfo(); + + if (attnamelist != NIL) + ereport(ERROR, + errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("column selection is not supported in JSON mode")); + } + num_phys_attrs = tupDesc->natts; /* Convert FORCE_QUOTE name list to per-column flags, check validity */ diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y index 3c3e24324a8..40ad9073901 100644 --- a/src/backend/parser/gram.y +++ b/src/backend/parser/gram.y @@ -3612,6 +3612,10 @@ copy_opt_item: { $$ = makeDefElem("format", (Node *) makeString("csv"), @1); } + | JSON + { + $$ = makeDefElem("format", (Node *) makeString("json"), @1); + } | HEADER_P { $$ = makeDefElem("header", (Node *) makeBoolean(true), @1); @@ -3694,6 +3698,10 @@ copy_generic_opt_elem: { $$ = makeDefElem($1, $2, @1); } + | FORMAT_LA copy_generic_opt_arg + { + $$ = makeDefElem("format", $2, @1); + } ; copy_generic_opt_arg: diff --git a/src/backend/utils/adt/json.c b/src/backend/utils/adt/json.c index 0b161398465..f609d7b9417 100644 --- a/src/backend/utils/adt/json.c +++ b/src/backend/utils/adt/json.c @@ -86,8 +86,6 @@ typedef struct JsonAggState JsonUniqueBuilderState unique_check; } JsonAggState; -static void composite_to_json(Datum composite, StringInfo result, - bool use_line_feeds); static void array_dim_to_json(StringInfo result, int dim, int ndims, int *dims, const Datum *vals, const bool *nulls, int *valcount, JsonTypeCategory tcategory, Oid outfuncoid, @@ -517,8 +515,9 @@ array_to_json_internal(Datum array, StringInfo result, bool use_line_feeds) /* * Turn a composite / record into JSON. + * Exported so COPY TO can use it. */ -static void +void composite_to_json(Datum composite, StringInfo result, bool use_line_feeds) { HeapTupleHeader td; diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c index f8c0865ca89..0d9649c1f0a 100644 --- a/src/bin/psql/tab-complete.in.c +++ b/src/bin/psql/tab-complete.in.c @@ -3425,7 +3425,7 @@ match_previous_words(int pattern_id, /* Complete COPY <sth> FROM|TO filename WITH (FORMAT */ else if (TailMatches("FORMAT")) - COMPLETE_WITH("binary", "csv", "text"); + COMPLETE_WITH("binary", "csv", "text", "json"); /* Complete COPY <sth> FROM|TO filename WITH (FREEZE */ else if (TailMatches("FREEZE")) diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h index 2430fb0b2e5..2b5bef6738e 100644 --- a/src/include/commands/copy.h +++ b/src/include/commands/copy.h @@ -57,6 +57,7 @@ typedef enum CopyFormat COPY_FORMAT_TEXT = 0, COPY_FORMAT_BINARY, COPY_FORMAT_CSV, + COPY_FORMAT_JSON, } CopyFormat; /* diff --git a/src/include/utils/json.h b/src/include/utils/json.h index f8cc52b1e78..2f4be40518d 100644 --- a/src/include/utils/json.h +++ b/src/include/utils/json.h @@ -17,6 +17,8 @@ #include "lib/stringinfo.h" /* functions in json.c */ +extern void composite_to_json(Datum composite, StringInfo result, + bool use_line_feeds); extern void escape_json(StringInfo buf, const char *str); extern void escape_json_with_len(StringInfo buf, const char *str, int len); extern void escape_json_text(StringInfo buf, const text *txt); diff --git a/src/test/regress/expected/copy.out b/src/test/regress/expected/copy.out index d0d563e0fa8..4324e3e4961 100644 --- a/src/test/regress/expected/copy.out +++ b/src/test/regress/expected/copy.out @@ -73,6 +73,92 @@ copy copytest3 to stdout csv header; c1,"col with , comma","col with "" quote" 1,a,1 2,b,2 +--- test copying in JSON mode with various styles +copy (select 1 union all select 2) to stdout with (format json); +{"?column?":1} +{"?column?":2} +copy (values (1), (2)) TO stdout with (format json); +{"column1":1} +{"column1":2} +copy copytest to stdout json; +{"style":"DOS","test":"abc\r\ndef","filler":1} +{"style":"Unix","test":"abc\ndef","filler":2} +{"style":"Mac","test":"abc\rdef","filler":3} +{"style":"esc\\ape","test":"a\\r\\\r\\\n\\nb","filler":4} +copy copytest to stdout (format json); +{"style":"DOS","test":"abc\r\ndef","filler":1} +{"style":"Unix","test":"abc\ndef","filler":2} +{"style":"Mac","test":"abc\rdef","filler":3} +{"style":"esc\\ape","test":"a\\r\\\r\\\n\\nb","filler":4} +-- all of the following should yield error +copy copytest to stdout (format json, delimiter '|'); +ERROR: cannot specify DELIMITER in JSON mode +copy copytest to stdout (format json, null '\N'); +ERROR: cannot specify NULL in JSON mode +copy copytest to stdout (format json, default '|'); +ERROR: cannot specify DEFAULT in JSON mode +copy copytest to stdout (format json, header); +ERROR: cannot specify HEADER in JSON mode +copy copytest to stdout (format json, header 1); +ERROR: cannot specify HEADER in JSON mode +copy copytest to stdout (format json, quote '"'); +ERROR: COPY QUOTE requires CSV mode +copy copytest to stdout (format json, escape '"'); +ERROR: COPY ESCAPE requires CSV mode +copy copytest to stdout (format json, force_quote *); +ERROR: COPY FORCE_QUOTE requires CSV mode +copy copytest to stdout (format json, force_not_null *); +ERROR: COPY FORCE_NOT_NULL requires CSV mode +copy copytest to stdout (format json, force_null *); +ERROR: COPY FORCE_NULL requires CSV mode +copy copytest to stdout (format json, on_error ignore); +ERROR: COPY ON_ERROR cannot be used with COPY TO +LINE 1: copy copytest to stdout (format json, on_error ignore); + ^ +copy copytest to stdout (format json, reject_limit 1); +ERROR: COPY REJECT_LIMIT requires ON_ERROR to be set to IGNORE +copy copytest from stdin(format json); +ERROR: COPY JSON mode cannot be used with COPY FROM +copy copytest (style) to stdout (format json); +ERROR: column selection is not supported in JSON mode +-- all of the above should yield error +-- embedded escaped characters +create temp table copyjsontest ( + id bigserial, + f1 text, + f2 timestamptz); +insert into copyjsontest + select g.i, + CASE WHEN g.i % 2 = 0 THEN + 'line with '' in it: ' || g.i::text + ELSE + 'line with " in it: ' || g.i::text + END, + 'Mon Feb 10 17:32:01 1997 PST' + from generate_series(1,5) as g(i); +insert into copyjsontest (f1) values +(E'aaa\"bbb'::text), +(E'aaa\\bbb'::text), +(E'aaa\/bbb'::text), +(E'aaa\bbbb'::text), +(E'aaa\fbbb'::text), +(E'aaa\nbbb'::text), +(E'aaa\rbbb'::text), +(E'aaa\tbbb'::text); +copy copyjsontest to stdout json; +{"id":1,"f1":"line with \" in it: 1","f2":"1997-02-10T17:32:01-08:00"} +{"id":2,"f1":"line with ' in it: 2","f2":"1997-02-10T17:32:01-08:00"} +{"id":3,"f1":"line with \" in it: 3","f2":"1997-02-10T17:32:01-08:00"} +{"id":4,"f1":"line with ' in it: 4","f2":"1997-02-10T17:32:01-08:00"} +{"id":5,"f1":"line with \" in it: 5","f2":"1997-02-10T17:32:01-08:00"} +{"id":1,"f1":"aaa\"bbb","f2":null} +{"id":2,"f1":"aaa\\bbb","f2":null} +{"id":3,"f1":"aaa/bbb","f2":null} +{"id":4,"f1":"aaa\bbbb","f2":null} +{"id":5,"f1":"aaa\fbbb","f2":null} +{"id":6,"f1":"aaa\nbbb","f2":null} +{"id":7,"f1":"aaa\rbbb","f2":null} +{"id":8,"f1":"aaa\tbbb","f2":null} create temp table copytest4 ( c1 int, "colname with tab: " text); diff --git a/src/test/regress/sql/copy.sql b/src/test/regress/sql/copy.sql index 65cbdaf7f3e..4e9f74537f8 100644 --- a/src/test/regress/sql/copy.sql +++ b/src/test/regress/sql/copy.sql @@ -82,6 +82,57 @@ this is just a line full of junk that would error out if parsed copy copytest3 to stdout csv header; +--- test copying in JSON mode with various styles +copy (select 1 union all select 2) to stdout with (format json); +copy (values (1), (2)) TO stdout with (format json); +copy copytest to stdout json; +copy copytest to stdout (format json); + +-- all of the following should yield error +copy copytest to stdout (format json, delimiter '|'); +copy copytest to stdout (format json, null '\N'); +copy copytest to stdout (format json, default '|'); +copy copytest to stdout (format json, header); +copy copytest to stdout (format json, header 1); +copy copytest to stdout (format json, quote '"'); +copy copytest to stdout (format json, escape '"'); +copy copytest to stdout (format json, force_quote *); +copy copytest to stdout (format json, force_not_null *); +copy copytest to stdout (format json, force_null *); +copy copytest to stdout (format json, on_error ignore); +copy copytest to stdout (format json, reject_limit 1); +copy copytest from stdin(format json); +copy copytest (style) to stdout (format json); +-- all of the above should yield error + +-- embedded escaped characters +create temp table copyjsontest ( + id bigserial, + f1 text, + f2 timestamptz); + +insert into copyjsontest + select g.i, + CASE WHEN g.i % 2 = 0 THEN + 'line with '' in it: ' || g.i::text + ELSE + 'line with " in it: ' || g.i::text + END, + 'Mon Feb 10 17:32:01 1997 PST' + from generate_series(1,5) as g(i); + +insert into copyjsontest (f1) values +(E'aaa\"bbb'::text), +(E'aaa\\bbb'::text), +(E'aaa\/bbb'::text), +(E'aaa\bbbb'::text), +(E'aaa\fbbb'::text), +(E'aaa\nbbb'::text), +(E'aaa\rbbb'::text), +(E'aaa\tbbb'::text); + +copy copyjsontest to stdout json; + create temp table copytest4 ( c1 int, "colname with tab: " text); -- 2.43.0
From 6c338f2f14f8d00be0204752e066373ab86d96d3 Mon Sep 17 00:00:00 2001 From: jian he <[email protected]> Date: Fri, 6 Mar 2026 14:47:58 +0800 Subject: [PATCH v27 3/4] Add option force_array for COPY JSON FORMAT This adds the force_array option, which is available exclusively when using COPY TO with the JSON format. When enabled, this option wraps the output in a top-level JSON array (enclosed in square brackets with comma-separated elements), making the entire result a valid single JSON value. Without this option, the default behavior is to output a stream of independent JSON objects. Attempting to use this option with COPY FROM or with formats other than JSON will raise an error. Author: Joe Conway <[email protected]> Author: jian he <[email protected]> Reviewed-by: Junwang Zhao <[email protected]> Reviewed-by: Masahiko Sawada <[email protected]>, Reviewed-by: Florents Tselai <[email protected]> Reviewed-by: Andrew Dunstan <[email protected]> discussion: https://postgr.es/m/CALvfUkBxTYy5uWPFVwpk_7ii2zgT07t3d-yR_cy4sfrrLU%3Dkcg%40mail.gmail.com discussion: https://postgr.es/m/[email protected] --- doc/src/sgml/ref/copy.sgml | 30 ++++++++++++++++++++++ src/backend/commands/copy.c | 13 ++++++++++ src/backend/commands/copyto.c | 41 ++++++++++++++++++++++++++++-- src/bin/psql/tab-complete.in.c | 2 +- src/include/commands/copy.h | 1 + src/test/regress/expected/copy.out | 33 ++++++++++++++++++++++++ src/test/regress/sql/copy.sql | 10 ++++++++ 7 files changed, 127 insertions(+), 3 deletions(-) diff --git a/doc/src/sgml/ref/copy.sgml b/doc/src/sgml/ref/copy.sgml index 75f55bbf6f8..a79587f7613 100644 --- a/doc/src/sgml/ref/copy.sgml +++ b/doc/src/sgml/ref/copy.sgml @@ -40,6 +40,7 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable HEADER [ <replaceable class="parameter">boolean</replaceable> | <replaceable class="parameter">integer</replaceable> | MATCH ] QUOTE '<replaceable class="parameter">quote_character</replaceable>' ESCAPE '<replaceable class="parameter">escape_character</replaceable>' + FORCE_ARRAY [ <replaceable class="parameter">boolean</replaceable> ] FORCE_QUOTE { ( <replaceable class="parameter">column_name</replaceable> [, ...] ) | * } FORCE_NOT_NULL { ( <replaceable class="parameter">column_name</replaceable> [, ...] ) | * } FORCE_NULL { ( <replaceable class="parameter">column_name</replaceable> [, ...] ) | * } @@ -366,6 +367,19 @@ COPY { <replaceable class="parameter">table_name</replaceable> [ ( <replaceable </listitem> </varlistentry> + <varlistentry id="sql-copy-params-force-array"> + <term><literal>FORCE_ARRAY</literal></term> + <listitem> + <para> + Force output of square brackets as array decorations at the beginning + and end of output, and commas between the rows. It is allowed only in + <command>COPY TO</command>, and only when using + <literal>json</literal> format. The default is + <literal>false</literal>. + </para> + </listitem> + </varlistentry> + <varlistentry id="sql-copy-params-force-quote"> <term><literal>FORCE_QUOTE</literal></term> <listitem> @@ -1103,6 +1117,22 @@ COPY country TO STDOUT (DELIMITER '|'); </programlisting> </para> +<para> + When the <literal>FORCE_ARRAY</literal> option is enabled, + the entire output is wrapped in a single JSON array with rows separated by commas: +<programlisting> +COPY (SELECT * FROM (VALUES(1),(2)) val(id)) TO STDOUT (FORMAT JSON, FORCE_ARRAY); +</programlisting> +The output is as follows: +<screen> +[ + {"id":1} +,{"id":2} +] +</screen> +</para> + + <para> To copy data from a file into the <literal>country</literal> table: <programlisting> diff --git a/src/backend/commands/copy.c b/src/backend/commands/copy.c index 29c121c7f08..84254d46a67 100644 --- a/src/backend/commands/copy.c +++ b/src/backend/commands/copy.c @@ -569,6 +569,7 @@ ProcessCopyOptions(ParseState *pstate, bool on_error_specified = false; bool log_verbosity_specified = false; bool reject_limit_specified = false; + bool force_array_specified = false; ListCell *option; /* Support external use for option sanity checking */ @@ -725,6 +726,13 @@ ProcessCopyOptions(ParseState *pstate, defel->defname), parser_errposition(pstate, defel->location))); } + else if (strcmp(defel->defname, "force_array") == 0) + { + if (force_array_specified) + errorConflictingDefElem(defel, pstate); + force_array_specified = true; + opts_out->force_array = defGetBoolean(defel); + } else if (strcmp(defel->defname, "on_error") == 0) { if (on_error_specified) @@ -967,6 +975,11 @@ ProcessCopyOptions(ParseState *pstate, errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("COPY %s mode cannot be used with %s", "JSON", "COPY FROM")); + if (opts_out->format != COPY_FORMAT_JSON && opts_out->force_array) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("COPY %s can only be used with JSON mode", "FORCE_ARRAY")); + if (opts_out->default_print) { if (!is_from) diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c index e87310ec5a0..4ea44daee0a 100644 --- a/src/backend/commands/copyto.c +++ b/src/backend/commands/copyto.c @@ -86,6 +86,7 @@ typedef struct CopyToStateData List *attnumlist; /* integer list of attnums to copy */ char *filename; /* filename, or NULL for STDOUT */ bool is_program; /* is 'filename' a program to popen? */ + bool json_row_delim_needed; /* need delimiter before next row */ StringInfo json_buf; /* reusable buffer for JSON output, it is * initliazed in BeginCopyTo */ copy_data_dest_cb data_dest_cb; /* function for writing data */ @@ -135,6 +136,7 @@ static void CopyToTextLikeOneRow(CopyToState cstate, TupleTableSlot *slot, bool is_csv); static void CopyToTextLikeEnd(CopyToState cstate); static void CopyToJsonOneRow(CopyToState cstate, TupleTableSlot *slot); +static void CopyToJsonEnd(CopyToState cstate); static void CopyToBinaryStart(CopyToState cstate, TupleDesc tupDesc); static void CopyToBinaryOutFunc(CopyToState cstate, Oid atttypid, FmgrInfo *finfo); static void CopyToBinaryOneRow(CopyToState cstate, TupleTableSlot *slot); @@ -176,7 +178,7 @@ static const CopyToRoutine CopyToRoutineJson = { .CopyToStart = CopyToTextLikeStart, .CopyToOutFunc = CopyToTextLikeOutFunc, .CopyToOneRow = CopyToJsonOneRow, - .CopyToEnd = CopyToTextLikeEnd, + .CopyToEnd = CopyToJsonEnd, }; /* binary format */ @@ -242,6 +244,18 @@ CopyToTextLikeStart(CopyToState cstate, TupleDesc tupDesc) CopySendTextLikeEndOfRow(cstate); } + + if (cstate->opts.format == COPY_FORMAT_JSON) + { + /* + * If FORCE_ARRAY has been specified, send the opening bracket. + */ + if (cstate->opts.force_array) + { + CopySendChar(cstate, '['); + CopySendTextLikeEndOfRow(cstate); + } + } } /* @@ -318,13 +332,24 @@ CopyToTextLikeOneRow(CopyToState cstate, CopySendTextLikeEndOfRow(cstate); } -/* Implementation of the end callback for text, CSV, and json formats */ +/* Implementation of the end callback for text and CSV formats */ static void CopyToTextLikeEnd(CopyToState cstate) { /* Nothing to do here */ } +/* Implementation of the end callback for json format */ +static void +CopyToJsonEnd(CopyToState cstate) +{ + if (cstate->opts.force_array) + { + CopySendChar(cstate, ']'); + CopySendTextLikeEndOfRow(cstate); + } +} + /* Implementation of per-row callback for json format */ static void CopyToJsonOneRow(CopyToState cstate, TupleTableSlot *slot) @@ -345,6 +370,18 @@ CopyToJsonOneRow(CopyToState cstate, TupleTableSlot *slot) rowdata = ExecFetchSlotHeapTupleDatum(slot); composite_to_json(rowdata, cstate->json_buf, false); + if (cstate->opts.force_array) + { + if (cstate->json_row_delim_needed) + CopySendChar(cstate, ','); + else + { + /* first row needs no delimiter */ + CopySendChar(cstate, ' '); + cstate->json_row_delim_needed = true; + } + } + CopySendData(cstate, cstate->json_buf->data, cstate->json_buf->len); CopySendTextLikeEndOfRow(cstate); diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c index 0d9649c1f0a..4b18cc6e2cd 100644 --- a/src/bin/psql/tab-complete.in.c +++ b/src/bin/psql/tab-complete.in.c @@ -1232,7 +1232,7 @@ Copy_common_options, "DEFAULT", "FORCE_NOT_NULL", "FORCE_NULL", "FREEZE", \ /* COPY TO options */ #define Copy_to_options \ -Copy_common_options, "FORCE_QUOTE" +Copy_common_options, "FORCE_QUOTE", "FORCE_ARRAY" /* * These object types were introduced later than our support cutoff of diff --git a/src/include/commands/copy.h b/src/include/commands/copy.h index 2b5bef6738e..abecfe51098 100644 --- a/src/include/commands/copy.h +++ b/src/include/commands/copy.h @@ -88,6 +88,7 @@ typedef struct CopyFormatOptions List *force_notnull; /* list of column names */ bool force_notnull_all; /* FORCE_NOT_NULL *? */ bool *force_notnull_flags; /* per-column CSV FNN flags */ + bool force_array; /* add JSON array decorations */ List *force_null; /* list of column names */ bool force_null_all; /* FORCE_NULL *? */ bool *force_null_flags; /* per-column CSV FN flags */ diff --git a/src/test/regress/expected/copy.out b/src/test/regress/expected/copy.out index 4324e3e4961..309a33ca2e7 100644 --- a/src/test/regress/expected/copy.out +++ b/src/test/regress/expected/copy.out @@ -80,6 +80,16 @@ copy (select 1 union all select 2) to stdout with (format json); copy (values (1), (2)) TO stdout with (format json); {"column1":1} {"column1":2} +copy (select 1 union all select 2) to stdout with (format json, force_array true); +[ + {"?column?":1} +,{"?column?":2} +] +copy (values (1), (2)) TO stdout with (format json, force_array true); +[ + {"column1":1} +,{"column1":2} +] copy copytest to stdout json; {"style":"DOS","test":"abc\r\ndef","filler":1} {"style":"Unix","test":"abc\ndef","filler":2} @@ -122,6 +132,29 @@ ERROR: COPY JSON mode cannot be used with COPY FROM copy copytest (style) to stdout (format json); ERROR: column selection is not supported in JSON mode -- all of the above should yield error +-- should fail: force_array requires json format +copy copytest to stdout (format csv, force_array true); +ERROR: COPY FORCE_ARRAY can only be used with JSON mode +-- force_array variants +copy copytest to stdout (format json, force_array); +[ + {"style":"DOS","test":"abc\r\ndef","filler":1} +,{"style":"Unix","test":"abc\ndef","filler":2} +,{"style":"Mac","test":"abc\rdef","filler":3} +,{"style":"esc\\ape","test":"a\\r\\\r\\\n\\nb","filler":4} +] +copy copytest to stdout (format json, force_array true); +[ + {"style":"DOS","test":"abc\r\ndef","filler":1} +,{"style":"Unix","test":"abc\ndef","filler":2} +,{"style":"Mac","test":"abc\rdef","filler":3} +,{"style":"esc\\ape","test":"a\\r\\\r\\\n\\nb","filler":4} +] +copy copytest to stdout (format json, force_array false); +{"style":"DOS","test":"abc\r\ndef","filler":1} +{"style":"Unix","test":"abc\ndef","filler":2} +{"style":"Mac","test":"abc\rdef","filler":3} +{"style":"esc\\ape","test":"a\\r\\\r\\\n\\nb","filler":4} -- embedded escaped characters create temp table copyjsontest ( id bigserial, diff --git a/src/test/regress/sql/copy.sql b/src/test/regress/sql/copy.sql index 4e9f74537f8..8a20907dd4c 100644 --- a/src/test/regress/sql/copy.sql +++ b/src/test/regress/sql/copy.sql @@ -85,6 +85,8 @@ copy copytest3 to stdout csv header; --- test copying in JSON mode with various styles copy (select 1 union all select 2) to stdout with (format json); copy (values (1), (2)) TO stdout with (format json); +copy (select 1 union all select 2) to stdout with (format json, force_array true); +copy (values (1), (2)) TO stdout with (format json, force_array true); copy copytest to stdout json; copy copytest to stdout (format json); @@ -105,6 +107,14 @@ copy copytest from stdin(format json); copy copytest (style) to stdout (format json); -- all of the above should yield error +-- should fail: force_array requires json format +copy copytest to stdout (format csv, force_array true); + +-- force_array variants +copy copytest to stdout (format json, force_array); +copy copytest to stdout (format json, force_array true); +copy copytest to stdout (format json, force_array false); + -- embedded escaped characters create temp table copyjsontest ( id bigserial, -- 2.43.0
From a627df0760d9803dea491798efe1298bff6aefcb Mon Sep 17 00:00:00 2001 From: Andrew Dunstan <[email protected]> Date: Fri, 6 Mar 2026 06:06:28 -0500 Subject: [PATCH v27 4/4] COPY TO JSON: support column lists When a column list is specified (e.g. COPY t (a, b) TO ... FORMAT json), build a projected TupleDesc containing only the selected columns and form a new tuple per row via heap_form_tuple(), so that composite_to_json() emits the correct column names and values. Use HeapTupleHeaderGetDatum() directly on the formed tuple rather than heap_copy_tuple_as_datum(), since heap_form_tuple() already stamps the datum-length, type-id, and type-mod fields on t_data, avoiding an unnecessary palloc+memcpy per row. Add regression tests covering column lists with diverse data types including json, jsonb, int[], numeric, boolean, timestamp, and text, exercising various column subsets and NULL handling. Author: Andrew Dunstan <[email protected]> Reviewed-by: jian he <[email protected]> discussion: https://postgr.es/m/CALvfUkBxTYy5uWPFVwpk_7ii2zgT07t3d-yR_cy4sfrrLU%3Dkcg%40mail.gmail.com discussion: https://postgr.es/m/[email protected] --- src/backend/commands/copyto.c | 109 ++++++++++++++++++++++++----- src/test/regress/expected/copy.out | 66 ++++++++++++++++- src/test/regress/sql/copy.sql | 39 ++++++++++- 3 files changed, 193 insertions(+), 21 deletions(-) diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c index 4ea44daee0a..992f3970d1a 100644 --- a/src/backend/commands/copyto.c +++ b/src/backend/commands/copyto.c @@ -87,8 +87,12 @@ typedef struct CopyToStateData char *filename; /* filename, or NULL for STDOUT */ bool is_program; /* is 'filename' a program to popen? */ bool json_row_delim_needed; /* need delimiter before next row */ - StringInfo json_buf; /* reusable buffer for JSON output, it is - * initliazed in BeginCopyTo */ + StringInfo json_buf; /* reusable buffer for JSON output, + * initialized in BeginCopyTo */ + TupleDesc tupDesc; /* Descriptor for JSON output; for a column + * list this is a projected descriptor */ + Datum *json_projvalues; /* pre-allocated projection values, or NULL */ + bool *json_projnulls; /* pre-allocated projection nulls, or NULL */ copy_data_dest_cb data_dest_cb; /* function for writing data */ CopyFormatOptions opts; @@ -356,18 +360,51 @@ CopyToJsonOneRow(CopyToState cstate, TupleTableSlot *slot) { Datum rowdata; - /* - * composite_to_json() requires a stable TupleDesc. The slot's descriptor - * (slot->tts_tupleDescriptor) may change during the execution of a SELECT - * query, using cstate->queryDesc instead. No need worry this if COPY TO - * is directly from a table. - */ - if (!cstate->rel) - slot->tts_tupleDescriptor = cstate->queryDesc->tupDesc; - resetStringInfo(cstate->json_buf); - rowdata = ExecFetchSlotHeapTupleDatum(slot); + if (cstate->json_projvalues != NULL) + { + /* + * Column list case: project selected column values into sequential + * positions matching the custom TupleDesc, then form a new tuple. + */ + HeapTuple tup; + int i = 0; + + foreach_int(attnum, cstate->attnumlist) + { + cstate->json_projvalues[i] = slot->tts_values[attnum - 1]; + cstate->json_projnulls[i] = slot->tts_isnull[attnum - 1]; + i++; + } + + tup = heap_form_tuple(cstate->tupDesc, + cstate->json_projvalues, + cstate->json_projnulls); + + /* + * heap_form_tuple already stamps the datum-length, type-id, and + * type-mod fields on t_data, so we can use it directly as a + * composite Datum without the extra palloc+memcpy that + * heap_copy_tuple_as_datum would do. Any TOAST pointers in the + * projected values will be detoasted by the per-column output + * functions called from composite_to_json. + */ + rowdata = HeapTupleHeaderGetDatum(tup->t_data); + } + else + { + /* + * Full table or query without column list. Ensure the slot uses + * cstate->tupDesc so that the datum is stamped with the right type; + * for queries this must be the blessed descriptor so that + * composite_to_json can look it up via lookup_rowtype_tupdesc. + * For relations the assignment is a no-op (same pointer). + */ + slot->tts_tupleDescriptor = cstate->tupDesc; + rowdata = ExecFetchSlotHeapTupleDatum(slot); + } + composite_to_json(rowdata, cstate->json_buf, false); if (cstate->opts.force_array) @@ -839,6 +876,7 @@ BeginCopyTo(ParseState *pstate, tupDesc = RelationGetDescr(cstate->rel); cstate->partitions = children; + cstate->tupDesc = tupDesc; } else { @@ -975,21 +1013,56 @@ BeginCopyTo(ParseState *pstate, ExecutorStart(cstate->queryDesc, 0); tupDesc = cstate->queryDesc->tupDesc; - tupDesc = BlessTupleDesc(tupDesc); + + /* + * For JSON format, bless the TupleDesc so that composite_to_json() + * can look up the RECORD type via lookup_rowtype_tupdesc(). + */ + if (cstate->opts.format == COPY_FORMAT_JSON) + tupDesc = BlessTupleDesc(tupDesc); + cstate->tupDesc = tupDesc; } /* Generate or convert list of attributes to process */ cstate->attnumlist = CopyGetAttnums(tupDesc, cstate->rel, attnamelist); - /* JSON outputs whole rows; a column list doesn't make sense */ + /* Set up JSON-specific state */ if (cstate->opts.format == COPY_FORMAT_JSON) { cstate->json_buf = makeStringInfo(); - if (attnamelist != NIL) - ereport(ERROR, - errcode(ERRCODE_FEATURE_NOT_SUPPORTED), - errmsg("column selection is not supported in JSON mode")); + if (attnamelist != NIL && rel) + { + int natts = list_length(cstate->attnumlist); + TupleDesc resultDesc; + + /* + * Build a TupleDesc describing only the selected columns so that + * composite_to_json() emits the right column names and types. + */ + resultDesc = CreateTemplateTupleDesc(natts); + + foreach_int(attnum, cstate->attnumlist) + { + Form_pg_attribute attr = TupleDescAttr(tupDesc, attnum - 1); + + TupleDescInitEntry(resultDesc, + foreach_current_index(attnum) + 1, + NameStr(attr->attname), + attr->atttypid, + attr->atttypmod, + attr->attndims); + } + + cstate->tupDesc = BlessTupleDesc(resultDesc); + + /* + * Pre-allocate arrays for projecting selected column values into + * sequential positions matching the custom TupleDesc. + */ + cstate->json_projvalues = (Datum *) palloc(natts * sizeof(Datum)); + cstate->json_projnulls = (bool *) palloc(natts * sizeof(bool)); + } } num_phys_attrs = tupDesc->natts; diff --git a/src/test/regress/expected/copy.out b/src/test/regress/expected/copy.out index 309a33ca2e7..645bdc1a21c 100644 --- a/src/test/regress/expected/copy.out +++ b/src/test/regress/expected/copy.out @@ -77,6 +77,9 @@ c1,"col with , comma","col with "" quote" copy (select 1 union all select 2) to stdout with (format json); {"?column?":1} {"?column?":2} +copy (select 1 as foo union all select 2) to stdout with (format json); +{"foo":1} +{"foo":2} copy (values (1), (2)) TO stdout with (format json); {"column1":1} {"column1":2} @@ -129,8 +132,6 @@ copy copytest to stdout (format json, reject_limit 1); ERROR: COPY REJECT_LIMIT requires ON_ERROR to be set to IGNORE copy copytest from stdin(format json); ERROR: COPY JSON mode cannot be used with COPY FROM -copy copytest (style) to stdout (format json); -ERROR: column selection is not supported in JSON mode -- all of the above should yield error -- should fail: force_array requires json format copy copytest to stdout (format csv, force_array true); @@ -155,6 +156,67 @@ copy copytest to stdout (format json, force_array false); {"style":"Unix","test":"abc\ndef","filler":2} {"style":"Mac","test":"abc\rdef","filler":3} {"style":"esc\\ape","test":"a\\r\\\r\\\n\\nb","filler":4} +-- column list with json format +copy copytest (style, filler) to stdout (format json); +{"style":"DOS","filler":1} +{"style":"Unix","filler":2} +{"style":"Mac","filler":3} +{"style":"esc\\ape","filler":4} +copy copytest (style, filler) to stdout (format json, force_array true); +[ + {"style":"DOS","filler":1} +,{"style":"Unix","filler":2} +,{"style":"Mac","filler":3} +,{"style":"esc\\ape","filler":4} +] +-- column list with diverse data types +create temp table copyjsontest_types ( + id int, + js json, + jsb jsonb, + arr int[], + n numeric(10,2), + b boolean, + ts timestamp, + t text); +insert into copyjsontest_types values +(1, '{"a":1}', '{"b":2}', '{1,2,3}', 3.14, true, + '2024-01-15 10:30:00', 'hello'), +(2, '[1,null,"x"]', '{"nested":{"k":"v"}}', '{4,5}', -99.99, false, + '2024-06-30 23:59:59', 'world'), +(3, 'null', 'null', '{}', null, null, null, null); +-- full table +copy copyjsontest_types to stdout (format json); +{"id":1,"js":{"a":1},"jsb":{"b": 2},"arr":[1,2,3],"n":3.14,"b":true,"ts":"2024-01-15T10:30:00","t":"hello"} +{"id":2,"js":[1,null,"x"],"jsb":{"nested": {"k": "v"}},"arr":[4,5],"n":-99.99,"b":false,"ts":"2024-06-30T23:59:59","t":"world"} +{"id":3,"js":null,"jsb":null,"arr":[],"n":null,"b":null,"ts":null,"t":null} +-- column subsets exercising each type +copy copyjsontest_types (id, js, jsb) to stdout (format json); +{"id":1,"js":{"a":1},"jsb":{"b": 2}} +{"id":2,"js":[1,null,"x"],"jsb":{"nested": {"k": "v"}}} +{"id":3,"js":null,"jsb":null} +copy copyjsontest_types (id, arr, n, b) to stdout (format json); +{"id":1,"arr":[1,2,3],"n":3.14,"b":true} +{"id":2,"arr":[4,5],"n":-99.99,"b":false} +{"id":3,"arr":[],"n":null,"b":null} +copy copyjsontest_types (jsb, t) to stdout (format json); +{"jsb":{"b": 2},"t":"hello"} +{"jsb":{"nested": {"k": "v"}},"t":"world"} +{"jsb":null,"t":null} +copy copyjsontest_types (id, ts) to stdout (format json); +{"id":1,"ts":"2024-01-15T10:30:00"} +{"id":2,"ts":"2024-06-30T23:59:59"} +{"id":3,"ts":null} +-- single column: json and jsonb +copy copyjsontest_types (js) to stdout (format json); +{"js":{"a":1}} +{"js":[1,null,"x"]} +{"js":null} +copy copyjsontest_types (jsb) to stdout (format json); +{"jsb":{"b": 2}} +{"jsb":{"nested": {"k": "v"}}} +{"jsb":null} +drop table copyjsontest_types; -- embedded escaped characters create temp table copyjsontest ( id bigserial, diff --git a/src/test/regress/sql/copy.sql b/src/test/regress/sql/copy.sql index 8a20907dd4c..c4d83762805 100644 --- a/src/test/regress/sql/copy.sql +++ b/src/test/regress/sql/copy.sql @@ -84,6 +84,7 @@ copy copytest3 to stdout csv header; --- test copying in JSON mode with various styles copy (select 1 union all select 2) to stdout with (format json); +copy (select 1 as foo union all select 2) to stdout with (format json); copy (values (1), (2)) TO stdout with (format json); copy (select 1 union all select 2) to stdout with (format json, force_array true); copy (values (1), (2)) TO stdout with (format json, force_array true); @@ -104,7 +105,6 @@ copy copytest to stdout (format json, force_null *); copy copytest to stdout (format json, on_error ignore); copy copytest to stdout (format json, reject_limit 1); copy copytest from stdin(format json); -copy copytest (style) to stdout (format json); -- all of the above should yield error -- should fail: force_array requires json format @@ -115,6 +115,43 @@ copy copytest to stdout (format json, force_array); copy copytest to stdout (format json, force_array true); copy copytest to stdout (format json, force_array false); +-- column list with json format +copy copytest (style, filler) to stdout (format json); +copy copytest (style, filler) to stdout (format json, force_array true); + +-- column list with diverse data types +create temp table copyjsontest_types ( + id int, + js json, + jsb jsonb, + arr int[], + n numeric(10,2), + b boolean, + ts timestamp, + t text); + +insert into copyjsontest_types values +(1, '{"a":1}', '{"b":2}', '{1,2,3}', 3.14, true, + '2024-01-15 10:30:00', 'hello'), +(2, '[1,null,"x"]', '{"nested":{"k":"v"}}', '{4,5}', -99.99, false, + '2024-06-30 23:59:59', 'world'), +(3, 'null', 'null', '{}', null, null, null, null); + +-- full table +copy copyjsontest_types to stdout (format json); + +-- column subsets exercising each type +copy copyjsontest_types (id, js, jsb) to stdout (format json); +copy copyjsontest_types (id, arr, n, b) to stdout (format json); +copy copyjsontest_types (jsb, t) to stdout (format json); +copy copyjsontest_types (id, ts) to stdout (format json); + +-- single column: json and jsonb +copy copyjsontest_types (js) to stdout (format json); +copy copyjsontest_types (jsb) to stdout (format json); + +drop table copyjsontest_types; + -- embedded escaped characters create temp table copyjsontest ( id bigserial, -- 2.43.0
