From 04f8589507ab4fa5ec1c93f0ab4b3015bf5e28f6 Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Mon, 20 Jul 2026 10:24:54 +0000 Subject: [PATCH v2 7/8] Add json5 data type with cast paths to json/jsonb New json5 type stores JSON5 text verbatim (validated via the json5 lexer). Casts: json5->json and json5->jsonb normalize and error on Infinity/NaN; json->json5 is implicit and binary coercible, as every json value is a valid json5 value with the same representation, so changing a json column to json5 needs no rewrite; jsonb->json5 via I/O. Casts to and from text need no catalog entries, the automatic I/O coercion rules cover them like for json. --- src/backend/utils/adt/json.c | 292 +++++++- src/backend/utils/adt/jsonb.c | 47 ++ src/backend/utils/adt/jsonfuncs.c | 13 +- src/include/catalog/pg_cast.dat | 10 + src/include/catalog/pg_proc.dat | 20 + src/include/catalog/pg_type.dat | 8 + src/include/utils/json.h | 1 + src/test/regress/expected/json5.out | 774 ++++++++++++++++++++++ src/test/regress/expected/opr_sanity.out | 6 +- src/test/regress/expected/type_sanity.out | 1 + src/test/regress/parallel_schedule | 2 +- src/test/regress/sql/json5.sql | 208 ++++++ src/test/regress/sql/type_sanity.sql | 1 + src/tools/pgindent/typedefs.list | 1 + 14 files changed, 1372 insertions(+), 12 deletions(-) create mode 100644 src/test/regress/expected/json5.out create mode 100644 src/test/regress/sql/json5.sql diff --git a/src/backend/utils/adt/json.c b/src/backend/utils/adt/json.c index 7b48f3915c9..8f69815ee54 100644 --- a/src/backend/utils/adt/json.c +++ b/src/backend/utils/adt/json.c @@ -100,17 +100,20 @@ static void add_json(Datum val, bool is_null, StringInfo result, static text *catenate_stringinfo_string(StringInfo buffer, const char *addon); /* - * Input. + * Input. Shared by json and json5: both store the input text verbatim and + * differ only in the syntax the lexer accepts. */ -Datum -json_in(PG_FUNCTION_ARGS) +static Datum +json_in_common(FunctionCallInfo fcinfo, bool json5) { char *json = PG_GETARG_CSTRING(0); text *result = cstring_to_text(json); JsonLexContext lex; /* validate it */ - makeJsonLexContext(&lex, result, false); + makeJsonLexContextCstringLen(&lex, VARDATA_ANY(result), + VARSIZE_ANY_EXHDR(result), + GetDatabaseEncoding(), false, json5); if (!pg_parse_json_or_errsave(&lex, &nullSemAction, fcinfo->context)) PG_RETURN_NULL(); @@ -118,6 +121,12 @@ json_in(PG_FUNCTION_ARGS) PG_RETURN_TEXT_P(result); } +Datum +json_in(PG_FUNCTION_ARGS) +{ + return json_in_common(fcinfo, false); +} + /* * Output. */ @@ -145,10 +154,10 @@ json_send(PG_FUNCTION_ARGS) } /* - * Binary receive. + * Binary receive. Shared by json and json5, like the input function. */ -Datum -json_recv(PG_FUNCTION_ARGS) +static Datum +json_recv_common(FunctionCallInfo fcinfo, bool json5) { StringInfo buf = (StringInfo) PG_GETARG_POINTER(0); char *str; @@ -159,12 +168,18 @@ json_recv(PG_FUNCTION_ARGS) /* Validate it. */ makeJsonLexContextCstringLen(&lex, str, nbytes, GetDatabaseEncoding(), - false, false); + false, json5); pg_parse_json_or_ereport(&lex, &nullSemAction); PG_RETURN_TEXT_P(cstring_to_text_with_len(str, nbytes)); } +Datum +json_recv(PG_FUNCTION_ARGS) +{ + return json_recv_common(fcinfo, false); +} + /* * Turn a Datum into JSON text, appending the string to "result". * @@ -1881,3 +1896,264 @@ json_typeof(PG_FUNCTION_ARGS) PG_RETURN_TEXT_P(cstring_to_text(type)); } + +/* + * json5 I/O functions + * + * json5 stores its text verbatim, same as json, but input is validated + * with the json5 lexer instead of the strict json one. Output and binary + * send are shared with json at the catalog level (see pg_proc.dat). + */ + +Datum +json5_in(PG_FUNCTION_ARGS) +{ + return json_in_common(fcinfo, true); +} + +Datum +json5_recv(PG_FUNCTION_ARGS) +{ + return json_recv_common(fcinfo, true); +} + +/* + * Does a json5 number token denote one of the non-finite values (Infinity + * or NaN, optionally signed) that neither json nor jsonb can represent? + */ +bool +json5_nonfinite_number(const char *token) +{ + if (token[0] == '+' || token[0] == '-') + token++; + return strcmp(token, "Infinity") == 0 || strcmp(token, "NaN") == 0; +} + +/* + * json5_to_json: convert json5 text to strict JSON + * + * Re-lexes the json5 text and re-emits standard JSON via the semantic + * callbacks below. Infinity/NaN numbers have no strict JSON + * representation, so they're rejected here. + */ + +typedef struct Json5ToJsonState +{ + StringInfo result; + bool *has_previous; + int depth; + int capacity; +} Json5ToJsonState; + +/* grow has_previous so it can hold at least s->depth + 1 entries */ +static void +json5_to_json_push(Json5ToJsonState *s) +{ + if (s->depth >= s->capacity) + { + s->capacity *= 2; + s->has_previous = repalloc(s->has_previous, + s->capacity * sizeof(bool)); + } + s->has_previous[s->depth++] = false; +} + +/* emit the separating comma if a sibling value precedes this one */ +static void +json5_to_json_value_prefix(Json5ToJsonState *s) +{ + if (s->depth > 0 && s->has_previous[s->depth - 1]) + appendStringInfoChar(s->result, ','); +} + +static JsonParseErrorType +json5_to_json_object_start(void *state) +{ + Json5ToJsonState *s = (Json5ToJsonState *) state; + + json5_to_json_value_prefix(s); + appendStringInfoChar(s->result, '{'); + json5_to_json_push(s); + return JSON_SUCCESS; +} + +static JsonParseErrorType +json5_to_json_object_end(void *state) +{ + Json5ToJsonState *s = (Json5ToJsonState *) state; + + s->depth--; + appendStringInfoChar(s->result, '}'); + if (s->depth > 0) + s->has_previous[s->depth - 1] = true; + return JSON_SUCCESS; +} + +static JsonParseErrorType +json5_to_json_array_start(void *state) +{ + Json5ToJsonState *s = (Json5ToJsonState *) state; + + json5_to_json_value_prefix(s); + appendStringInfoChar(s->result, '['); + json5_to_json_push(s); + return JSON_SUCCESS; +} + +static JsonParseErrorType +json5_to_json_array_end(void *state) +{ + Json5ToJsonState *s = (Json5ToJsonState *) state; + + s->depth--; + appendStringInfoChar(s->result, ']'); + if (s->depth > 0) + s->has_previous[s->depth - 1] = true; + return JSON_SUCCESS; +} + +static JsonParseErrorType +json5_to_json_object_field_start(void *state, char *fname, bool isnull) +{ + Json5ToJsonState *s = (Json5ToJsonState *) state; + + if (s->has_previous[s->depth - 1]) + appendStringInfoChar(s->result, ','); + escape_json(s->result, fname); + appendStringInfoChar(s->result, ':'); + /* comma already emitted here, the upcoming value must not add one */ + s->has_previous[s->depth - 1] = false; + return JSON_SUCCESS; +} + +/* + * Append a finite json5 number token as a valid JSON number. + * + * Decimal forms are rewritten textually, so that the value keeps the + * written digits and isn't subject to numeric's range limits: an explicit + * plus sign is dropped, a leading decimal point gets a zero in front and a + * bare trailing decimal point is removed. Hexadecimal integers have no + * JSON spelling, so they go through numeric. + */ +static void +json5_append_number(StringInfo buf, const char *token) +{ + const char *p = token; + + if (*p == '+') + p++; + else if (*p == '-') + appendStringInfoChar(buf, *p++); + + if (p[0] == '0' && (p[1] == 'x' || p[1] == 'X')) + { + Datum num = DirectFunctionCall3(numeric_in, + CStringGetDatum(p), + ObjectIdGetDatum(InvalidOid), + Int32GetDatum(-1)); + + appendStringInfoString(buf, + DatumGetCString(DirectFunctionCall1(numeric_out, + num))); + return; + } + + if (*p == '.') + appendStringInfoChar(buf, '0'); + for (; *p; p++) + { + if (*p == '.' && !(p[1] >= '0' && p[1] <= '9')) + continue; + appendStringInfoChar(buf, *p); + } +} + +static JsonParseErrorType +json5_to_json_scalar(void *state, char *token, JsonTokenType tokentype) +{ + Json5ToJsonState *s = (Json5ToJsonState *) state; + + json5_to_json_value_prefix(s); + + switch (tokentype) + { + case JSON_TOKEN_STRING: + escape_json(s->result, token); + break; + case JSON_TOKEN_NUMBER: + if (json5_nonfinite_number(token)) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("cannot convert JSON5 value \"%s\" to json", + token), + errdetail("The json type does not support Infinity or NaN numeric values."))); + json5_append_number(s->result, token); + break; + case JSON_TOKEN_TRUE: + appendStringInfoString(s->result, "true"); + break; + case JSON_TOKEN_FALSE: + appendStringInfoString(s->result, "false"); + break; + case JSON_TOKEN_NULL: + appendStringInfoString(s->result, "null"); + break; + default: + elog(ERROR, "unexpected json token type: %d", tokentype); + break; + } + + if (s->depth > 0) + s->has_previous[s->depth - 1] = true; + + return JSON_SUCCESS; +} + +Datum +json5_to_json(PG_FUNCTION_ARGS) +{ + text *json5 = PG_GETARG_TEXT_PP(0); + JsonLexContext lex; + JsonSemAction sem; + Json5ToJsonState state; + StringInfoData result; + + /* + * Fast path: json5 is a superset of json, so stored text that is already + * valid strict JSON casts verbatim. Besides skipping the rebuild, this + * keeps \u0000 escapes intact, which the de-escaping rebuild below cannot + * represent. Return a copy, as callers such as datum_to_json_internal() + * free the result of a cast function. + */ + makeJsonLexContextCstringLen(&lex, VARDATA_ANY(json5), + VARSIZE_ANY_EXHDR(json5), + GetDatabaseEncoding(), false, false); + if (pg_parse_json(&lex, &nullSemAction) == JSON_SUCCESS) + PG_RETURN_TEXT_P(cstring_to_text_with_len(VARDATA_ANY(json5), + VARSIZE_ANY_EXHDR(json5))); + freeJsonLexContext(&lex); + + initStringInfo(&result); + memset(&state, 0, sizeof(state)); + state.result = &result; + state.depth = 0; + state.capacity = 64; + state.has_previous = palloc(state.capacity * sizeof(bool)); + + memset(&sem, 0, sizeof(sem)); + sem.semstate = &state; + sem.object_start = json5_to_json_object_start; + sem.object_end = json5_to_json_object_end; + sem.array_start = json5_to_json_array_start; + sem.array_end = json5_to_json_array_end; + sem.object_field_start = json5_to_json_object_field_start; + sem.scalar = json5_to_json_scalar; + + makeJsonLexContextCstringLen(&lex, VARDATA_ANY(json5), + VARSIZE_ANY_EXHDR(json5), + GetDatabaseEncoding(), true, true); + pg_parse_json_or_ereport(&lex, &sem); + freeJsonLexContext(&lex); + + PG_RETURN_TEXT_P(cstring_to_text_with_len(result.data, result.len)); +} diff --git a/src/backend/utils/adt/jsonb.c b/src/backend/utils/adt/jsonb.c index 12698e93437..cdd15326ea8 100644 --- a/src/backend/utils/adt/jsonb.c +++ b/src/backend/utils/adt/jsonb.c @@ -2033,3 +2033,50 @@ JsonbUnquote(Jsonb *jb) else return JsonbToCString(NULL, &jb->root, VARSIZE(jb)); } + +/* + * json5_to_jsonb: convert json5 text to jsonb + * + * Same building blocks as jsonb_in, but lexes with json5 enabled and + * rejects Infinity/NaN, which jsonb has no representation for. + */ +static JsonParseErrorType +json5_to_jsonb_scalar(void *pstate, char *token, JsonTokenType tokentype) +{ + if (tokentype == JSON_TOKEN_NUMBER && json5_nonfinite_number(token)) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("cannot convert JSON5 value \"%s\" to jsonb", token), + errdetail("The jsonb type does not support Infinity or NaN numeric values."))); + + return jsonb_in_scalar(pstate, token, tokentype); +} + +Datum +json5_to_jsonb(PG_FUNCTION_ARGS) +{ + text *json5 = PG_GETARG_TEXT_PP(0); + char *str = VARDATA_ANY(json5); + int len = VARSIZE_ANY_EXHDR(json5); + JsonLexContext lex; + JsonbInState state; + JsonSemAction sem; + + memset(&state, 0, sizeof(state)); + memset(&sem, 0, sizeof(sem)); + makeJsonLexContextCstringLen(&lex, str, len, GetDatabaseEncoding(), + true, true); + + sem.semstate = &state; + sem.object_start = jsonb_in_object_start; + sem.array_start = jsonb_in_array_start; + sem.object_end = jsonb_in_object_end; + sem.array_end = jsonb_in_array_end; + sem.scalar = json5_to_jsonb_scalar; + sem.object_field_start = jsonb_in_object_field_start; + + pg_parse_json_or_ereport(&lex, &sem); + freeJsonLexContext(&lex); + + PG_RETURN_POINTER(JsonbValueToJsonb(state.result)); +} diff --git a/src/backend/utils/adt/jsonfuncs.c b/src/backend/utils/adt/jsonfuncs.c index 9ef170f6331..42d389b771b 100644 --- a/src/backend/utils/adt/jsonfuncs.c +++ b/src/backend/utils/adt/jsonfuncs.c @@ -662,7 +662,8 @@ json_errsave_error(JsonParseErrorType error, JsonLexContext *lex, else errsave(escontext, (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION), - errmsg("invalid input syntax for type %s", "json"), + errmsg("invalid input syntax for type %s", + lex->json5 ? "json5" : "json"), errdetail_internal("%s", json_errdetail(error, lex)), report_json_context(lex))); } @@ -6016,6 +6017,16 @@ json_categorize_type(Oid typoid, bool is_jsonb, *tcategory = is_jsonb ? JSONTYPE_JSONB : JSONTYPE_JSON; break; + case JSON5OID: + + /* + * json5 text isn't valid JSON, so go through the json5 -> json + * cast, as for a non-builtin type with a cast to json. + */ + *outfuncoid = F_JSON5_TO_JSON; + *tcategory = JSONTYPE_CAST; + break; + default: /* Check for arrays and composites */ if (OidIsValid(get_element_type(typoid)) || typoid == ANYARRAYOID diff --git a/src/include/catalog/pg_cast.dat b/src/include/catalog/pg_cast.dat index a7b6d812c5a..80b5330e1eb 100644 --- a/src/include/catalog/pg_cast.dat +++ b/src/include/catalog/pg_cast.dat @@ -594,4 +594,14 @@ { castsource => 'tstzrange', casttarget => 'tstzmultirange', castfunc => 'tstzmultirange(tstzrange)', castcontext => 'e', castmethod => 'f' }, + +# json5 casts +{ castsource => 'json5', casttarget => 'json', + castfunc => 'json5_to_json', castcontext => 'a', castmethod => 'f' }, +{ castsource => 'json5', casttarget => 'jsonb', + castfunc => 'json5_to_jsonb', castcontext => 'a', castmethod => 'f' }, +{ castsource => 'json', casttarget => 'json5', castfunc => '0', + castcontext => 'i', castmethod => 'b' }, +{ castsource => 'jsonb', casttarget => 'json5', castfunc => '0', + castcontext => 'a', castmethod => 'i' }, ] diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index f46427258e3..632b204a64b 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -12778,4 +12778,24 @@ proname => 'hashoid8extended', prorettype => 'int8', proargtypes => 'oid8 int8', prosrc => 'hashoid8extended' }, +# json5 +{ oid => '9962', descr => 'I/O', + proname => 'json5_in', prorettype => 'json5', proargtypes => 'cstring', + prosrc => 'json5_in' }, +{ oid => '9963', descr => 'I/O', + proname => 'json5_out', prorettype => 'cstring', proargtypes => 'json5', + prosrc => 'json_out' }, +{ oid => '9964', descr => 'I/O', + proname => 'json5_recv', prorettype => 'json5', proargtypes => 'internal', + prosrc => 'json5_recv' }, +{ oid => '9965', descr => 'I/O', + proname => 'json5_send', prorettype => 'bytea', proargtypes => 'json5', + prosrc => 'json_send' }, +{ oid => '9966', descr => 'convert json5 to json', + proname => 'json5_to_json', prorettype => 'json', proargtypes => 'json5', + prosrc => 'json5_to_json' }, +{ oid => '9967', descr => 'convert json5 to jsonb', + proname => 'json5_to_jsonb', prorettype => 'jsonb', proargtypes => 'json5', + prosrc => 'json5_to_jsonb' }, + ] diff --git a/src/include/catalog/pg_type.dat b/src/include/catalog/pg_type.dat index 42ee494601b..9d89f9f4cb7 100644 --- a/src/include/catalog/pg_type.dat +++ b/src/include/catalog/pg_type.dat @@ -457,6 +457,14 @@ typreceive => 'jsonpath_recv', typsend => 'jsonpath_send', typalign => 'i', typstorage => 'x' }, +# json5 +{ oid => '9960', array_type_oid => '9961', + descr => 'JSON5 stored as text', + typname => 'json5', typlen => '-1', typbyval => 'f', typcategory => 'U', + typinput => 'json5_in', typoutput => 'json5_out', + typreceive => 'json5_recv', typsend => 'json5_send', + typalign => 'i', typstorage => 'x' }, + { oid => '2970', array_type_oid => '2949', descr => 'transaction snapshot', typname => 'txid_snapshot', typlen => '-1', typbyval => 'f', typcategory => 'U', typinput => 'txid_snapshot_in', diff --git a/src/include/utils/json.h b/src/include/utils/json.h index 2f4be40518d..5b3fab8bd5a 100644 --- a/src/include/utils/json.h +++ b/src/include/utils/json.h @@ -31,5 +31,6 @@ extern Datum json_build_object_worker(int nargs, const Datum *args, const bool * extern Datum json_build_array_worker(int nargs, const Datum *args, const bool *nulls, const Oid *types, bool absent_on_null); extern bool json_validate(text *json, bool check_unique_keys, bool throw_error); +extern bool json5_nonfinite_number(const char *token); #endif /* JSON_H */ diff --git a/src/test/regress/expected/json5.out b/src/test/regress/expected/json5.out new file mode 100644 index 00000000000..885260483c9 --- /dev/null +++ b/src/test/regress/expected/json5.out @@ -0,0 +1,774 @@ +-- JSON5 type input validation +-- +-- The json5 input function uses the non-incremental (recursive descent) +-- parser, so this file is also what exercises that parser's JSON5 +-- handling; the incremental parser is covered by the test_json_parser +-- module. +-- valid JSON is valid JSON5 +SELECT '{"key": "value"}'::json5; + json5 +------------------ + {"key": "value"} +(1 row) + +SELECT '[1, 2, 3]'::json5; + json5 +----------- + [1, 2, 3] +(1 row) + +SELECT '"hello"'::json5; + json5 +--------- + "hello" +(1 row) + +SELECT '42'::json5; + json5 +------- + 42 +(1 row) + +SELECT 'true'::json5; + json5 +------- + true +(1 row) + +SELECT 'null'::json5; + json5 +------- + null +(1 row) + +-- unquoted keys +SELECT '{key: "value"}'::json5; + json5 +---------------- + {key: "value"} +(1 row) + +SELECT '{_key: "value"}'::json5; + json5 +----------------- + {_key: "value"} +(1 row) + +SELECT '{$key: "value"}'::json5; + json5 +----------------- + {$key: "value"} +(1 row) + +-- reserved words as unquoted keys +SELECT '{true: 1, false: 2, null: 3}'::json5; + json5 +------------------------------ + {true: 1, false: 2, null: 3} +(1 row) + +-- trailing commas +SELECT '{"key": "value",}'::json5; + json5 +------------------- + {"key": "value",} +(1 row) + +SELECT '[1, 2, 3,]'::json5; + json5 +------------ + [1, 2, 3,] +(1 row) + +-- comments +SELECT E'// line comment\n{"key": "value"}'::json5; + json5 +------------------ + // line comment + + {"key": "value"} +(1 row) + +SELECT '/* block comment */ {"key": "value"}'::json5; + json5 +-------------------------------------- + /* block comment */ {"key": "value"} +(1 row) + +SELECT E'{"key": /* inline */ "value"}'::json5; + json5 +------------------------------- + {"key": /* inline */ "value"} +(1 row) + +-- single-quoted strings +SELECT $json5${'key': 'value'}$json5$::json5; + json5 +------------------ + {'key': 'value'} +(1 row) + +SELECT $json5$["single's quote"]$json5$::json5; + json5 +-------------------- + ["single's quote"] +(1 row) + +-- number extensions +SELECT '{"a": .5}'::json5; + json5 +----------- + {"a": .5} +(1 row) + +SELECT '{"a": 5.}'::json5; + json5 +----------- + {"a": 5.} +(1 row) + +SELECT '{"a": 0xFF}'::json5; + json5 +------------- + {"a": 0xFF} +(1 row) + +SELECT '{"a": +1}'::json5; + json5 +----------- + {"a": +1} +(1 row) + +SELECT '{"a": Infinity}'::json5; + json5 +----------------- + {"a": Infinity} +(1 row) + +SELECT '{"a": -Infinity}'::json5; + json5 +------------------ + {"a": -Infinity} +(1 row) + +SELECT '{"a": NaN}'::json5; + json5 +------------ + {"a": NaN} +(1 row) + +-- multi-line strings +SELECT E'{"key": "line1\\\nline2"}'::json5; + json5 +----------------- + {"key": "line1\+ + line2"} +(1 row) + +-- unquoted identifiers are only valid as keys, not values (all error) +SELECT '{key: value}'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '{key: value}'::json5; + ^ +DETAIL: Expected JSON value, but found "value". +CONTEXT: JSON data, line 1: {key: value... +SELECT '{"key": undefined}'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '{"key": undefined}'::json5; + ^ +DETAIL: Expected JSON value, but found "undefined". +CONTEXT: JSON data, line 1: {"key": undefined... +SELECT '[a]'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '[a]'::json5; + ^ +DETAIL: Expected JSON value, but found "a". +CONTEXT: JSON data, line 1: [a... +SELECT 'undefined'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT 'undefined'::json5; + ^ +DETAIL: Expected JSON value, but found "undefined". +CONTEXT: JSON data, line 1: undefined +-- invalid JSON5 (should error) +SELECT '{"key": }'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '{"key": }'::json5; + ^ +DETAIL: Expected JSON value, but found "}". +CONTEXT: JSON data, line 1: {"key": } +SELECT ''::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT ''::json5; + ^ +DETAIL: The input string ended unexpectedly. +CONTEXT: JSON data, line 1: +SELECT '[1,,]'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '[1,,]'::json5; + ^ +DETAIL: Expected JSON value, but found ",". +CONTEXT: JSON data, line 1: [1,,... +SELECT '{1a: 1}'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '{1a: 1}'::json5; + ^ +DETAIL: Token "1a" is invalid. +CONTEXT: JSON data, line 1: {1a... +SELECT '0x'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '0x'::json5; + ^ +DETAIL: Token "0x" is invalid. +CONTEXT: JSON data, line 1: 0x +SELECT '.'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '.'::json5; + ^ +DETAIL: Token "." is invalid. +CONTEXT: JSON data, line 1: . +SELECT '010'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '010'::json5; + ^ +DETAIL: Token "010" is invalid. +CONTEXT: JSON data, line 1: 010 +SELECT '080'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '080'::json5; + ^ +DETAIL: Token "080" is invalid. +CONTEXT: JSON data, line 1: 080 +SELECT '-00'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '-00'::json5; + ^ +DETAIL: Token "-00" is invalid. +CONTEXT: JSON data, line 1: -00 +SELECT '1e2.3'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '1e2.3'::json5; + ^ +DETAIL: Expected end of input, but found ".3". +CONTEXT: JSON data, line 1: 1e2.3 +SELECT '/* only a comment */'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '/* only a comment */'::json5; + ^ +DETAIL: The input string ended unexpectedly. +CONTEXT: JSON data, line 1: /* only a comment */ +SELECT '/* unterminated'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '/* unterminated'::json5; + ^ +DETAIL: Block comment is not terminated. +CONTEXT: JSON data, line 1: /* unterminated +SELECT $json5$'unterminated$json5$::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT $json5$'unterminated$json5$::json5; + ^ +DETAIL: Token "'unterminated" is invalid. +CONTEXT: JSON data, line 1: 'unterminated +-- unterminated block comments error even after a complete value +SELECT '1 /*'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '1 /*'::json5; + ^ +DETAIL: Block comment is not terminated. +CONTEXT: JSON data, line 1: 1 /* +SELECT '1 /**'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '1 /**'::json5; + ^ +DETAIL: Block comment is not terminated. +CONTEXT: JSON data, line 1: 1 /** +SELECT '1 /*/'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '1 /*/'::json5; + ^ +DETAIL: Block comment is not terminated. +CONTEXT: JSON data, line 1: 1 /*/ +SELECT '[1] /* {"garbage" [[['::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '[1] /* {"garbage" [[['::json5; + ^ +DETAIL: Block comment is not terminated. +CONTEXT: JSON data, line 1: [1] /* {"garbage" [[[ +SELECT '1 /**/ /*'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '1 /**/ /*'::json5; + ^ +DETAIL: Block comment is not terminated. +CONTEXT: JSON data, line 1: 1 /**/ /* +SELECT '1 /**/'::json5; + json5 +-------- + 1 /**/ +(1 row) + +-- line comments end at CR as well as LF +SELECT E'[1, // c\r2\n]'::json5::jsonb; + jsonb +-------- + [1, 2] +(1 row) + +SELECT E'// c\r1'::json5::json; + json +------ + 1 +(1 row) + +SELECT E'1 // c\rgarbage'::json5; +ERROR: invalid input syntax for type json5 +LINE 1: SELECT E'1 // c\rgarbage'::json5; + ^ +DETAIL: Expected end of input, but found "garbage". +CONTEXT: JSON data, line 1: 1 // c garbage +-- signed NaN +SELECT '-NaN'::json5; + json5 +------- + -NaN +(1 row) + +SELECT '+NaN'::json5; + json5 +------- + +NaN +(1 row) + +SELECT '{"a": -NaN}'::json5::json; -- should ERROR +ERROR: cannot convert JSON5 value "-NaN" to json +DETAIL: The json type does not support Infinity or NaN numeric values. +SELECT '{"a": +NaN}'::json5::jsonb; -- should ERROR +ERROR: cannot convert JSON5 value "+NaN" to jsonb +DETAIL: The jsonb type does not support Infinity or NaN numeric values. +-- Infinity and NaN are identifiers, so also valid as unquoted keys +SELECT '{Infinity: 1, NaN: 2}'::json5; + json5 +----------------------- + {Infinity: 1, NaN: 2} +(1 row) + +SELECT '{Infinity: 1, NaN: 2}'::json5::json; + json +------------------------ + {"Infinity":1,"NaN":2} +(1 row) + +SELECT '{Infinity: 1, NaN: 2}'::json5::jsonb; + jsonb +--------------------------- + {"NaN": 2, "Infinity": 1} +(1 row) + +SELECT '{-Infinity: 1}'::json5; -- should ERROR (not an identifier) +ERROR: invalid input syntax for type json5 +LINE 1: SELECT '{-Infinity: 1}'::json5; + ^ +DETAIL: Expected string or "}", but found "-Infinity". +CONTEXT: JSON data, line 1: {-Infinity... +-- \u0000 is valid json, so the json5 -> json cast must preserve it +SELECT '"\u0000"'::json5::json; + json +---------- + "\u0000" +(1 row) + +SELECT '{"a": "\u0000"}'::json5::json; + json +----------------- + {"a": "\u0000"} +(1 row) + +-- but combined with json5-only syntax the rebuild path still rejects it +SELECT '{a: "\u0000"}'::json5::json; -- should ERROR +ERROR: unsupported Unicode escape sequence +DETAIL: \u0000 cannot be converted to text. +CONTEXT: JSON data, line 1: {a: "\u0000... +-- jsonb rejects \u0000 itself, the cast stays consistent with that +SELECT '"\u0000"'::json5::jsonb; -- should ERROR +ERROR: unsupported Unicode escape sequence +DETAIL: \u0000 cannot be converted to text. +CONTEXT: JSON data, line 1: "\u0000... +-- verbatim storage: output preserves original formatting +SELECT $json5${key: 'value', /* comment */}$json5$::json5; + json5 +------------------------------- + {key: 'value', /* comment */} +(1 row) + +SELECT E'// comment\n[1, 2,]'::json5; + json5 +------------ + // comment+ + [1, 2,] +(1 row) + +-- cast: json5 -> json (normalizes) +SELECT '{"key": "value"}'::json5::json; + json +------------------ + {"key": "value"} +(1 row) + +SELECT $json5${key: 'value'}$json5$::json5::json; + json +----------------- + {"key":"value"} +(1 row) + +SELECT '{true: 1, null: 2}'::json5::json; + json +--------------------- + {"true":1,"null":2} +(1 row) + +SELECT '{"a": .5}'::json5::json; + json +----------- + {"a":0.5} +(1 row) + +SELECT '{"a": 5.}'::json5::json; + json +--------- + {"a":5} +(1 row) + +SELECT '{"a": 0xFF}'::json5::json; + json +----------- + {"a":255} +(1 row) + +SELECT '{"a": +1}'::json5::json; + json +--------- + {"a":1} +(1 row) + +SELECT '{"a": 6.022e23}'::json5::json; + json +----------------- + {"a": 6.022e23} +(1 row) + +-- decimal forms are rewritten textually, not limited by numeric's range +SELECT '[-.5, +.5e3, 5.e-3, -5., -0x10, +0XfF]'::json5::json; + json +------------------------------ + [-0.5,0.5e3,5e-3,-5,-16,255] +(1 row) + +-- e inside a hex literal is a digit, not an exponent marker +SELECT '[0xc8e4]'::json5::json; + json +--------- + [51428] +(1 row) + +SELECT '[.5e-1000000, 1.e400000]'::json5::json; + json +------------------------- + [0.5e-1000000,1e400000] +(1 row) + +-- escapes in unquoted keys and json5-only string escapes +SELECT $json5${\u0061b: 1, s: '\x41\v\q\''}$json5$::json5::json; + json +-------------------------- + {"ab":1,"s":"A\u000bq'"} +(1 row) + +SELECT $json5${\u0061b: 1, s: '\x41\v\q\''}$json5$::json5::jsonb; + jsonb +----------------------------- + {"s": "A\u000bq'", "ab": 1} +(1 row) + +SELECT '[1, 2, 3,]'::json5::json; + json +--------- + [1,2,3] +(1 row) + +SELECT E'// c\n{"a": [1, /* c */ 2]}'::json5::json; + json +------------- + {"a":[1,2]} +(1 row) + +SELECT E'{"key": "line1\\\nline2"}'::json5::json; + json +---------------------- + {"key":"line1line2"} +(1 row) + +-- containers as array elements need separating commas +SELECT '[[1], [2], {"a": [3, {"b": 4}]}]'::json5::json; + json +---------------------------------- + [[1], [2], {"a": [3, {"b": 4}]}] +(1 row) + +-- the rebuild keeps duplicate keys, like json does +SELECT '{a: 1, a: 2}'::json5::json; + json +--------------- + {"a":1,"a":2} +(1 row) + +SELECT '[{"a": 1}, {"b": 2}, 3, [4]]'::json5::json; + json +------------------------------ + [{"a": 1}, {"b": 2}, 3, [4]] +(1 row) + +SELECT '{"key": Infinity}'::json5::json; -- should ERROR +ERROR: cannot convert JSON5 value "Infinity" to json +DETAIL: The json type does not support Infinity or NaN numeric values. +SELECT '{"key": -Infinity}'::json5::json; -- should ERROR +ERROR: cannot convert JSON5 value "-Infinity" to json +DETAIL: The json type does not support Infinity or NaN numeric values. +SELECT '{"key": +Infinity}'::json5::json; -- should ERROR +ERROR: cannot convert JSON5 value "+Infinity" to json +DETAIL: The json type does not support Infinity or NaN numeric values. +SELECT '{"key": NaN}'::json5::json; -- should ERROR +ERROR: cannot convert JSON5 value "NaN" to json +DETAIL: The json type does not support Infinity or NaN numeric values. +-- cast: json5 -> jsonb +SELECT '{"key": "value"}'::json5::jsonb; + jsonb +------------------ + {"key": "value"} +(1 row) + +SELECT $json5${key: 'value'}$json5$::json5::jsonb; + jsonb +------------------ + {"key": "value"} +(1 row) + +SELECT '{true: 1, null: 2}'::json5::jsonb; + jsonb +------------------------ + {"null": 2, "true": 1} +(1 row) + +SELECT '{"a": .5}'::json5::jsonb; + jsonb +------------ + {"a": 0.5} +(1 row) + +SELECT '{"a": 5.}'::json5::jsonb; + jsonb +---------- + {"a": 5} +(1 row) + +SELECT '{"a": 0xFF}'::json5::jsonb; + jsonb +------------ + {"a": 255} +(1 row) + +SELECT '[1, 2, 3,]'::json5::jsonb; + jsonb +----------- + [1, 2, 3] +(1 row) + +SELECT '[[1], [2], {"a": [3, {"b": 4}]}]'::json5::jsonb; + jsonb +---------------------------------- + [[1], [2], {"a": [3, {"b": 4}]}] +(1 row) + +SELECT '{a: 1, a: 2}'::json5::jsonb; + jsonb +---------- + {"a": 2} +(1 row) + +SELECT '{"key": Infinity}'::json5::jsonb; -- should ERROR +ERROR: cannot convert JSON5 value "Infinity" to jsonb +DETAIL: The jsonb type does not support Infinity or NaN numeric values. +SELECT '{"key": -Infinity}'::json5::jsonb; -- should ERROR +ERROR: cannot convert JSON5 value "-Infinity" to jsonb +DETAIL: The jsonb type does not support Infinity or NaN numeric values. +SELECT '{"key": +Infinity}'::json5::jsonb; -- should ERROR +ERROR: cannot convert JSON5 value "+Infinity" to jsonb +DETAIL: The jsonb type does not support Infinity or NaN numeric values. +SELECT '{"key": NaN}'::json5::jsonb; -- should ERROR +ERROR: cannot convert JSON5 value "NaN" to jsonb +DETAIL: The jsonb type does not support Infinity or NaN numeric values. +-- JSON-producing functions embed json5 values via the json5 -> json cast +SELECT to_json($j${a: 1, /* c */ b: [.5, 'x',]}$j$::json5); + to_json +----------------------- + {"a":1,"b":[0.5,"x"]} +(1 row) + +SELECT to_jsonb($j${a: 1, /* c */ b: [.5, 'x',]}$j$::json5); + to_jsonb +--------------------------- + {"a": 1, "b": [0.5, "x"]} +(1 row) + +SELECT json_build_object('k', '{a: 1,}'::json5), + jsonb_build_object('k', '{a: 1,}'::json5); + json_build_object | jsonb_build_object +-------------------+-------------------- + {"k" : {"a":1}} | {"k": {"a": 1}} +(1 row) + +SELECT json_agg(v), jsonb_agg(v) + FROM (VALUES ('{a: 1}'::json5), ('[2,]'::json5)) t(v); + json_agg | jsonb_agg +----------------+----------------- + [{"a":1}, [2]] | [{"a": 1}, [2]] +(1 row) + +SELECT row_to_json(t) FROM (SELECT 1 AS id, '{a: 1}'::json5 AS j) t; + row_to_json +---------------------- + {"id":1,"j":{"a":1}} +(1 row) + +SELECT JSON_OBJECT('k': '{a: 1}'::json5), JSON_ARRAY('[1,]'::json5); + json_object | json_array +-----------------+------------ + {"k" : {"a":1}} | [[1]] +(1 row) + +SELECT to_json('[Infinity]'::json5); -- should ERROR +ERROR: cannot convert JSON5 value "Infinity" to json +DETAIL: The json type does not support Infinity or NaN numeric values. +SELECT json_build_object('{a: 1}'::json5, 1); -- should ERROR, key not scalar +ERROR: key value must be scalar, not array, composite, or json +-- values that are already strict JSON take the cast's verbatim path +CREATE TABLE json5_plain(j json5); +INSERT INTO json5_plain VALUES ('{"a": 1}'), ('[1, 2]'); +SELECT to_json(j), row_to_json(json5_plain) FROM json5_plain; + to_json | row_to_json +----------+---------------- + {"a": 1} | {"j":{"a": 1}} + [1, 2] | {"j":[1, 2]} +(2 rows) + +SELECT json_agg(j) FROM json5_plain; + json_agg +-------------------- + [{"a": 1}, [1, 2]] +(1 row) + +SELECT to_json('{"a": 1}'::json5) FROM generate_series(1, 2); + to_json +---------- + {"a": 1} + {"a": 1} +(2 rows) + +DROP TABLE json5_plain; +-- cast: json -> json5 (implicit, binary coercible) +SELECT '{"key": "value"}'::json::json5; + json5 +------------------ + {"key": "value"} +(1 row) + +-- so changing a json column to json5 needs no table rewrite +CREATE TABLE json5_alter(j json); +INSERT INTO json5_alter VALUES ('{"key": "value"}'); +SELECT pg_relation_filenode('json5_alter') AS before_filenode \gset +ALTER TABLE json5_alter ALTER COLUMN j TYPE json5; +SELECT pg_relation_filenode('json5_alter') = :before_filenode AS no_rewrite; + no_rewrite +------------ + t +(1 row) + +SELECT j, pg_typeof(j) FROM json5_alter; + j | pg_typeof +------------------+----------- + {"key": "value"} | json5 +(1 row) + +DROP TABLE json5_alter; +-- cast: jsonb -> json5 (assignment; serializes then validates) +SELECT '{"key": "value"}'::jsonb::json5; + json5 +------------------ + {"key": "value"} +(1 row) + +SELECT '{"a": 0.5, "b": [1,2,3]}'::jsonb::json5; + json5 +---------------------------- + {"a": 0.5, "b": [1, 2, 3]} +(1 row) + +-- cast: json5 -> text (automatic I/O coercion; verbatim) +SELECT '{"key": "value"}'::json5::text; + text +------------------ + {"key": "value"} +(1 row) + +SELECT $json5${key: 'value', /* comment */}$json5$::json5::text; + text +------------------------------- + {key: 'value', /* comment */} +(1 row) + +-- cast: text -> json5 (automatic I/O coercion; validates) +SELECT CAST('{"key": "value"}' AS json5); + json5 +------------------ + {"key": "value"} +(1 row) + +SELECT CAST($json5${key: 'value'}$json5$ AS json5); + json5 +---------------- + {key: 'value'} +(1 row) + +SELECT 'not json5 {'::text::json5; -- should ERROR (validated) +ERROR: invalid input syntax for type json5 +DETAIL: Expected JSON value, but found "not". +CONTEXT: JSON data, line 1: not... +-- deep nesting: json5 -> json must not impose an arbitrary depth cap +-- (parity with json5 -> jsonb and plain json; 100 > old fixed limit of 64) +SELECT (repeat('[', 100) || '1' || repeat(']', 100))::json5::json IS NOT NULL AS to_json_ok, + (repeat('[', 100) || '1' || repeat(']', 100))::json5::jsonb IS NOT NULL AS to_jsonb_ok, + (repeat('[', 100) || '1' || repeat(']', 100))::json IS NOT NULL AS json_ok; + to_json_ok | to_jsonb_ok | json_ok +------------+-------------+--------- + t | t | t +(1 row) + +-- table storage test +CREATE TABLE json5_test(id serial, data json5); +INSERT INTO json5_test(data) VALUES ($json5${key: 'value', /* comment */}$json5$); +INSERT INTO json5_test(data) VALUES ('{"normal": "json"}'); +INSERT INTO json5_test(data) VALUES ('[1, 2, 3,]'); +SELECT * FROM json5_test; + id | data +----+------------------------------- + 1 | {key: 'value', /* comment */} + 2 | {"normal": "json"} + 3 | [1, 2, 3,] +(3 rows) + +SELECT id, data::jsonb FROM json5_test; + id | data +----+-------------------- + 1 | {"key": "value"} + 2 | {"normal": "json"} + 3 | [1, 2, 3] +(3 rows) + +DROP TABLE json5_test; diff --git a/src/test/regress/expected/opr_sanity.out b/src/test/regress/expected/opr_sanity.out index 67cae397861..3041ebd0c2a 100644 --- a/src/test/regress/expected/opr_sanity.out +++ b/src/test/regress/expected/opr_sanity.out @@ -178,10 +178,11 @@ ORDER BY 1, 2; bigint | xid8 text | character text | character varying + json | json5 timestamp without time zone | timestamp with time zone bit | bit varying txid_snapshot | pg_snapshot -(6 rows) +(7 rows) SELECT DISTINCT p1.proargtypes[1]::regtype, p2.proargtypes[1]::regtype FROM pg_proc AS p1, pg_proc AS p2 @@ -1041,11 +1042,12 @@ WHERE c.castmethod = 'b' AND text | character | 0 | i xml | character | 0 | a pg_mcv_list | bytea | 0 | i + json | json5 | 0 | i cidr | inet | 0 | i pg_dependencies | bytea | 0 | i pg_node_tree | text | 0 | i pg_ndistinct | bytea | 0 | i -(10 rows) +(11 rows) -- **************** pg_conversion **************** -- Look for illegal values in pg_conversion fields. diff --git a/src/test/regress/expected/type_sanity.out b/src/test/regress/expected/type_sanity.out index 1d21d3eb446..336a5ecb70c 100644 --- a/src/test/regress/expected/type_sanity.out +++ b/src/test/regress/expected/type_sanity.out @@ -738,6 +738,7 @@ CREATE TABLE tab_core_types AS SELECT 'now'::timestamptz, '12 seconds'::interval, '{"reason":"because"}'::json, + '{reason:"because"}'::json5, '{"when":"now"}'::jsonb, '$.a[*] ? (@ > 2)'::jsonpath, '127.0.0.1'::inet, diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule index 75063f87a4a..796a50a0a4e 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -107,7 +107,7 @@ test: select_views portals_p2 foreign_key dependency guc bitmapops combocid tsea # ---------- # Another group of parallel tests (JSON related) # ---------- -test: json jsonb json_encoding jsonpath jsonpath_encoding jsonb_jsonpath sqljson sqljson_queryfuncs sqljson_jsontable +test: json jsonb json5 json_encoding jsonpath jsonpath_encoding jsonb_jsonpath sqljson sqljson_queryfuncs sqljson_jsontable # ---------- # Another group of parallel tests diff --git a/src/test/regress/sql/json5.sql b/src/test/regress/sql/json5.sql new file mode 100644 index 00000000000..b8f8fcfe368 --- /dev/null +++ b/src/test/regress/sql/json5.sql @@ -0,0 +1,208 @@ +-- JSON5 type input validation +-- +-- The json5 input function uses the non-incremental (recursive descent) +-- parser, so this file is also what exercises that parser's JSON5 +-- handling; the incremental parser is covered by the test_json_parser +-- module. + +-- valid JSON is valid JSON5 +SELECT '{"key": "value"}'::json5; +SELECT '[1, 2, 3]'::json5; +SELECT '"hello"'::json5; +SELECT '42'::json5; +SELECT 'true'::json5; +SELECT 'null'::json5; + +-- unquoted keys +SELECT '{key: "value"}'::json5; +SELECT '{_key: "value"}'::json5; +SELECT '{$key: "value"}'::json5; + +-- reserved words as unquoted keys +SELECT '{true: 1, false: 2, null: 3}'::json5; + +-- trailing commas +SELECT '{"key": "value",}'::json5; +SELECT '[1, 2, 3,]'::json5; + +-- comments +SELECT E'// line comment\n{"key": "value"}'::json5; +SELECT '/* block comment */ {"key": "value"}'::json5; +SELECT E'{"key": /* inline */ "value"}'::json5; + +-- single-quoted strings +SELECT $json5${'key': 'value'}$json5$::json5; +SELECT $json5$["single's quote"]$json5$::json5; + +-- number extensions +SELECT '{"a": .5}'::json5; +SELECT '{"a": 5.}'::json5; +SELECT '{"a": 0xFF}'::json5; +SELECT '{"a": +1}'::json5; +SELECT '{"a": Infinity}'::json5; +SELECT '{"a": -Infinity}'::json5; +SELECT '{"a": NaN}'::json5; + +-- multi-line strings +SELECT E'{"key": "line1\\\nline2"}'::json5; + +-- unquoted identifiers are only valid as keys, not values (all error) +SELECT '{key: value}'::json5; +SELECT '{"key": undefined}'::json5; +SELECT '[a]'::json5; +SELECT 'undefined'::json5; + +-- invalid JSON5 (should error) +SELECT '{"key": }'::json5; +SELECT ''::json5; +SELECT '[1,,]'::json5; +SELECT '{1a: 1}'::json5; +SELECT '0x'::json5; +SELECT '.'::json5; +SELECT '010'::json5; +SELECT '080'::json5; +SELECT '-00'::json5; +SELECT '1e2.3'::json5; +SELECT '/* only a comment */'::json5; +SELECT '/* unterminated'::json5; +SELECT $json5$'unterminated$json5$::json5; + +-- unterminated block comments error even after a complete value +SELECT '1 /*'::json5; +SELECT '1 /**'::json5; +SELECT '1 /*/'::json5; +SELECT '[1] /* {"garbage" [[['::json5; +SELECT '1 /**/ /*'::json5; +SELECT '1 /**/'::json5; + +-- line comments end at CR as well as LF +SELECT E'[1, // c\r2\n]'::json5::jsonb; +SELECT E'// c\r1'::json5::json; +SELECT E'1 // c\rgarbage'::json5; + +-- signed NaN +SELECT '-NaN'::json5; +SELECT '+NaN'::json5; +SELECT '{"a": -NaN}'::json5::json; -- should ERROR +SELECT '{"a": +NaN}'::json5::jsonb; -- should ERROR + +-- Infinity and NaN are identifiers, so also valid as unquoted keys +SELECT '{Infinity: 1, NaN: 2}'::json5; +SELECT '{Infinity: 1, NaN: 2}'::json5::json; +SELECT '{Infinity: 1, NaN: 2}'::json5::jsonb; +SELECT '{-Infinity: 1}'::json5; -- should ERROR (not an identifier) + +-- \u0000 is valid json, so the json5 -> json cast must preserve it +SELECT '"\u0000"'::json5::json; +SELECT '{"a": "\u0000"}'::json5::json; +-- but combined with json5-only syntax the rebuild path still rejects it +SELECT '{a: "\u0000"}'::json5::json; -- should ERROR +-- jsonb rejects \u0000 itself, the cast stays consistent with that +SELECT '"\u0000"'::json5::jsonb; -- should ERROR + +-- verbatim storage: output preserves original formatting +SELECT $json5${key: 'value', /* comment */}$json5$::json5; +SELECT E'// comment\n[1, 2,]'::json5; + +-- cast: json5 -> json (normalizes) +SELECT '{"key": "value"}'::json5::json; +SELECT $json5${key: 'value'}$json5$::json5::json; +SELECT '{true: 1, null: 2}'::json5::json; +SELECT '{"a": .5}'::json5::json; +SELECT '{"a": 5.}'::json5::json; +SELECT '{"a": 0xFF}'::json5::json; +SELECT '{"a": +1}'::json5::json; +SELECT '{"a": 6.022e23}'::json5::json; +-- decimal forms are rewritten textually, not limited by numeric's range +SELECT '[-.5, +.5e3, 5.e-3, -5., -0x10, +0XfF]'::json5::json; +-- e inside a hex literal is a digit, not an exponent marker +SELECT '[0xc8e4]'::json5::json; +SELECT '[.5e-1000000, 1.e400000]'::json5::json; +-- escapes in unquoted keys and json5-only string escapes +SELECT $json5${\u0061b: 1, s: '\x41\v\q\''}$json5$::json5::json; +SELECT $json5${\u0061b: 1, s: '\x41\v\q\''}$json5$::json5::jsonb; +SELECT '[1, 2, 3,]'::json5::json; +SELECT E'// c\n{"a": [1, /* c */ 2]}'::json5::json; +SELECT E'{"key": "line1\\\nline2"}'::json5::json; +-- containers as array elements need separating commas +SELECT '[[1], [2], {"a": [3, {"b": 4}]}]'::json5::json; +-- the rebuild keeps duplicate keys, like json does +SELECT '{a: 1, a: 2}'::json5::json; +SELECT '[{"a": 1}, {"b": 2}, 3, [4]]'::json5::json; +SELECT '{"key": Infinity}'::json5::json; -- should ERROR +SELECT '{"key": -Infinity}'::json5::json; -- should ERROR +SELECT '{"key": +Infinity}'::json5::json; -- should ERROR +SELECT '{"key": NaN}'::json5::json; -- should ERROR + +-- cast: json5 -> jsonb +SELECT '{"key": "value"}'::json5::jsonb; +SELECT $json5${key: 'value'}$json5$::json5::jsonb; +SELECT '{true: 1, null: 2}'::json5::jsonb; +SELECT '{"a": .5}'::json5::jsonb; +SELECT '{"a": 5.}'::json5::jsonb; +SELECT '{"a": 0xFF}'::json5::jsonb; +SELECT '[1, 2, 3,]'::json5::jsonb; +SELECT '[[1], [2], {"a": [3, {"b": 4}]}]'::json5::jsonb; +SELECT '{a: 1, a: 2}'::json5::jsonb; +SELECT '{"key": Infinity}'::json5::jsonb; -- should ERROR +SELECT '{"key": -Infinity}'::json5::jsonb; -- should ERROR +SELECT '{"key": +Infinity}'::json5::jsonb; -- should ERROR +SELECT '{"key": NaN}'::json5::jsonb; -- should ERROR + +-- JSON-producing functions embed json5 values via the json5 -> json cast +SELECT to_json($j${a: 1, /* c */ b: [.5, 'x',]}$j$::json5); +SELECT to_jsonb($j${a: 1, /* c */ b: [.5, 'x',]}$j$::json5); +SELECT json_build_object('k', '{a: 1,}'::json5), + jsonb_build_object('k', '{a: 1,}'::json5); +SELECT json_agg(v), jsonb_agg(v) + FROM (VALUES ('{a: 1}'::json5), ('[2,]'::json5)) t(v); +SELECT row_to_json(t) FROM (SELECT 1 AS id, '{a: 1}'::json5 AS j) t; +SELECT JSON_OBJECT('k': '{a: 1}'::json5), JSON_ARRAY('[1,]'::json5); +SELECT to_json('[Infinity]'::json5); -- should ERROR +SELECT json_build_object('{a: 1}'::json5, 1); -- should ERROR, key not scalar +-- values that are already strict JSON take the cast's verbatim path +CREATE TABLE json5_plain(j json5); +INSERT INTO json5_plain VALUES ('{"a": 1}'), ('[1, 2]'); +SELECT to_json(j), row_to_json(json5_plain) FROM json5_plain; +SELECT json_agg(j) FROM json5_plain; +SELECT to_json('{"a": 1}'::json5) FROM generate_series(1, 2); +DROP TABLE json5_plain; + +-- cast: json -> json5 (implicit, binary coercible) +SELECT '{"key": "value"}'::json::json5; +-- so changing a json column to json5 needs no table rewrite +CREATE TABLE json5_alter(j json); +INSERT INTO json5_alter VALUES ('{"key": "value"}'); +SELECT pg_relation_filenode('json5_alter') AS before_filenode \gset +ALTER TABLE json5_alter ALTER COLUMN j TYPE json5; +SELECT pg_relation_filenode('json5_alter') = :before_filenode AS no_rewrite; +SELECT j, pg_typeof(j) FROM json5_alter; +DROP TABLE json5_alter; + +-- cast: jsonb -> json5 (assignment; serializes then validates) +SELECT '{"key": "value"}'::jsonb::json5; +SELECT '{"a": 0.5, "b": [1,2,3]}'::jsonb::json5; + +-- cast: json5 -> text (automatic I/O coercion; verbatim) +SELECT '{"key": "value"}'::json5::text; +SELECT $json5${key: 'value', /* comment */}$json5$::json5::text; + +-- cast: text -> json5 (automatic I/O coercion; validates) +SELECT CAST('{"key": "value"}' AS json5); +SELECT CAST($json5${key: 'value'}$json5$ AS json5); +SELECT 'not json5 {'::text::json5; -- should ERROR (validated) + +-- deep nesting: json5 -> json must not impose an arbitrary depth cap +-- (parity with json5 -> jsonb and plain json; 100 > old fixed limit of 64) +SELECT (repeat('[', 100) || '1' || repeat(']', 100))::json5::json IS NOT NULL AS to_json_ok, + (repeat('[', 100) || '1' || repeat(']', 100))::json5::jsonb IS NOT NULL AS to_jsonb_ok, + (repeat('[', 100) || '1' || repeat(']', 100))::json IS NOT NULL AS json_ok; + +-- table storage test +CREATE TABLE json5_test(id serial, data json5); +INSERT INTO json5_test(data) VALUES ($json5${key: 'value', /* comment */}$json5$); +INSERT INTO json5_test(data) VALUES ('{"normal": "json"}'); +INSERT INTO json5_test(data) VALUES ('[1, 2, 3,]'); +SELECT * FROM json5_test; +SELECT id, data::jsonb FROM json5_test; +DROP TABLE json5_test; diff --git a/src/test/regress/sql/type_sanity.sql b/src/test/regress/sql/type_sanity.sql index 95d5b6e0915..5fba6ad4dc8 100644 --- a/src/test/regress/sql/type_sanity.sql +++ b/src/test/regress/sql/type_sanity.sql @@ -554,6 +554,7 @@ CREATE TABLE tab_core_types AS SELECT 'now'::timestamptz, '12 seconds'::interval, '{"reason":"because"}'::json, + '{reason:"because"}'::json5, '{"when":"now"}'::jsonb, '$.a[*] ? (@ > 2)'::jsonpath, '127.0.0.1'::inet, diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 656f1f60862..a6a2c37b2a7 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -1410,6 +1410,7 @@ JoinTreeItem JoinType JsObject JsValue +Json5ToJsonState JsonAggConstructor JsonAggState JsonArgument -- 2.55.0