From 52fa46bdee07c4651c1c9e826b17be3e0b1cb9bd Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Wed, 8 Jul 2026 23:20:33 +0000 Subject: [PATCH 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 implicit; jsonb->json5 and text->json5 via I/O (text->json5 validates); json5->text verbatim. --- src/backend/utils/adt/json.c | 264 +++++++++- src/backend/utils/adt/jsonb.c | 47 ++ src/backend/utils/adt/jsonfuncs.c | 3 +- src/include/catalog/pg_cast.dat | 14 + src/include/catalog/pg_proc.dat | 20 + src/include/catalog/pg_type.dat | 7 + src/include/utils/json.h | 1 + src/test/regress/expected/json5.out | 612 ++++++++++++++++++++++ src/test/regress/expected/opr_sanity.out | 3 +- src/test/regress/expected/type_sanity.out | 1 + src/test/regress/parallel_schedule | 2 +- src/test/regress/sql/json5.sql | 165 ++++++ src/test/regress/sql/type_sanity.sql | 1 + src/tools/pgindent/typedefs.list | 1 + 14 files changed, 1130 insertions(+), 11 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 94040c5e03e..6e01f094a98 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,236 @@ 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; +} + +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_NUMERIC_VALUE_OUT_OF_RANGE), + errmsg("cannot convert JSON5 value \"%s\" to JSON", + token), + errdetail("JSON does not support Infinity or NaN numeric values."))); + if (IsValidJsonNumber(token, strlen(token))) + appendStringInfoString(s->result, token); + else + { + /* + * A json5-only number form (hex, leading/trailing decimal + * point, explicit plus sign) has to be normalized through + * numeric to become a valid JSON number. + */ + Datum num = DirectFunctionCall3(numeric_in, + CStringGetDatum(token), + ObjectIdGetDatum(InvalidOid), + Int32GetDatum(-1)); + + appendStringInfoString(s->result, + DatumGetCString(DirectFunctionCall1(numeric_out, num))); + } + 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. + */ + makeJsonLexContextCstringLen(&lex, VARDATA_ANY(json5), + VARSIZE_ANY_EXHDR(json5), + GetDatabaseEncoding(), false, false); + if (pg_parse_json(&lex, &nullSemAction) == JSON_SUCCESS) + PG_RETURN_TEXT_P(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 1c97ede567a..dabe8b2cd88 100644 --- a/src/backend/utils/adt/jsonb.c +++ b/src/backend/utils/adt/jsonb.c @@ -2034,3 +2034,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_NUMERIC_VALUE_OUT_OF_RANGE), + 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 08aec32c0d6..f895d7050b1 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))); } diff --git a/src/include/catalog/pg_cast.dat b/src/include/catalog/pg_cast.dat index a7b6d812c5a..9654ed318bd 100644 --- a/src/include/catalog/pg_cast.dat +++ b/src/include/catalog/pg_cast.dat @@ -594,4 +594,18 @@ { 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 => 'i' }, +{ castsource => 'json5', casttarget => 'text', castfunc => '0', + castcontext => 'a', castmethod => 'i' }, +{ castsource => 'text', casttarget => 'json5', castfunc => '0', + castcontext => 'e', castmethod => 'i' }, +{ 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 3cb84359adf..98952f0bd49 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -12718,4 +12718,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..b0ec0808b5d 100644 --- a/src/include/catalog/pg_type.dat +++ b/src/include/catalog/pg_type.dat @@ -135,6 +135,13 @@ # OIDS 100 - 199 +{ 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 => '114', array_type_oid => '199', descr => 'JSON stored as text', typname => 'json', typlen => '-1', typbyval => 'f', typcategory => 'U', typinput => 'json_in', typoutput => 'json_out', typreceive => 'json_recv', 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..6b349fe2799 --- /dev/null +++ b/src/test/regress/expected/json5.out @@ -0,0 +1,612 @@ +-- 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 '/* 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: JSON 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) + +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) + +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: JSON does not support Infinity or NaN numeric values. +SELECT '{"key": -Infinity}'::json5::json; -- should ERROR +ERROR: cannot convert JSON5 value "-Infinity" to JSON +DETAIL: JSON does not support Infinity or NaN numeric values. +SELECT '{"key": +Infinity}'::json5::json; -- should ERROR +ERROR: cannot convert JSON5 value "+Infinity" to JSON +DETAIL: JSON does not support Infinity or NaN numeric values. +SELECT '{"key": NaN}'::json5::json; -- should ERROR +ERROR: cannot convert JSON5 value "NaN" to JSON +DETAIL: JSON 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 '{"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. +-- cast: json -> json5 (implicit) +SELECT '{"key": "value"}'::json::json5; + json5 +------------------ + {"key": "value"} +(1 row) + +-- 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 (assignment; 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 (explicit; 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 6b519a65cc9..77c890461a8 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 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 8fa0a6c47fb..446fa45c5e5 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..4cf078c717d --- /dev/null +++ b/src/test/regress/sql/json5.sql @@ -0,0 +1,165 @@ +-- 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 '/* 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; +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; +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 '{"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 + +-- cast: json -> json5 (implicit) +SELECT '{"key": "value"}'::json::json5; + +-- 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 (assignment; verbatim) +SELECT '{"key": "value"}'::json5::text; +SELECT $json5${key: 'value', /* comment */}$json5$::json5::text; + +-- cast: text -> json5 (explicit; 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 68a93e27ab9..d599ee80ac7 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -1434,6 +1434,7 @@ JoinType JsObject JsValue Json5CommentState +Json5ToJsonState JsonAggConstructor JsonAggState JsonArgument -- 2.43.0