From de709c69237e3304b7369c897842dc362d73ee4f Mon Sep 17 00:00:00 2001 From: Haibo Yan Date: Wed, 24 Jun 2026 20:46:00 -0700 Subject: [PATCH] Resolve CAST ... FORMAT with CoerceViaFormatter Resolve formatted casts through pg_formatter during parse analysis. The FORMAT expression is transformed and coerced to text, unknown source literals are treated as text, and formatter lookup is performed by exact source and target type. If no formatter exists, the cast fails; a FORMAT clause never falls back to ordinary cast resolution. Represent the analyzed expression with a CoerceViaFormatter node. The node preserves CAST ... FORMAT syntax for ruleutils and pg_dump, and records a dependency on the pg_formatter row as well as on the formatter function. Execution still uses the ordinary function-call machinery via ExecInitFunc, so no new executor opcode or JIT support is needed. Target typmods and domain checks are enforced by the existing coercion machinery layered above the node. --- src/backend/catalog/dependency.c | 23 ++ src/backend/executor/execExpr.c | 18 ++ src/backend/nodes/nodeFuncs.c | 53 +++++ src/backend/parser/parse_expr.c | 136 ++++++++++- src/backend/utils/adt/ruleutils.c | 19 ++ src/backend/utils/cache/lsyscache.c | 37 +++ src/include/nodes/primnodes.h | 38 ++++ src/include/utils/lsyscache.h | 2 + src/test/regress/expected/expressions.out | 30 +-- src/test/regress/expected/formatters.out | 263 +++++++++++++++++++++- src/test/regress/sql/expressions.sql | 14 +- src/test/regress/sql/formatters.sql | 153 ++++++++++++- 12 files changed, 742 insertions(+), 44 deletions(-) diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c index 7eeac91264f..61e9a28361d 100644 --- a/src/backend/catalog/dependency.c +++ b/src/backend/catalog/dependency.c @@ -2131,6 +2131,29 @@ find_expr_references_walker(Node *node, add_object_address(CollationRelationId, iocoerce->resultcollid, 0, context->addrs); } + else if (IsA(node, CoerceViaFormatter)) + { + CoerceViaFormatter *fmt = (CoerceViaFormatter *) node; + + /* depend on the result type */ + add_object_address(TypeRelationId, fmt->resulttype, 0, + context->addrs); + /* depend on the formatter function */ + add_object_address(ProcedureRelationId, fmt->formatterfunc, 0, + context->addrs); + /* + * Also depend on the pg_formatter row itself, so that DROP FORMATTER + * is refused (or cascades) while a stored expression uses it. + */ + if (OidIsValid(fmt->formatterid)) + add_object_address(FormatterRelationId, fmt->formatterid, 0, + context->addrs); + /* the collation might not be referenced anywhere else, either */ + if (OidIsValid(fmt->resultcollid) && + fmt->resultcollid != DEFAULT_COLLATION_OID) + add_object_address(CollationRelationId, fmt->resultcollid, 0, + context->addrs); + } else if (IsA(node, ArrayCoerceExpr)) { ArrayCoerceExpr *acoerce = (ArrayCoerceExpr *) node; diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c index cfea7e160c2..00ef3a8708f 100644 --- a/src/backend/executor/execExpr.c +++ b/src/backend/executor/execExpr.c @@ -1200,6 +1200,24 @@ ExecInitExprRec(Expr *node, ExprState *state, break; } + case T_CoerceViaFormatter: + { + CoerceViaFormatter *fmt = (CoerceViaFormatter *) node; + + /* + * A formatted cast is executed exactly like a call to its + * formatter function: formatterfunc(arg, format). Reuse the + * ordinary function-call setup, which also performs the + * run-time EXECUTE permission check on the formatter function. + */ + ExecInitFunc(&scratch, node, + list_make2(fmt->arg, fmt->format), + fmt->formatterfunc, fmt->inputcollid, + state); + ExprEvalPushStep(state, &scratch); + break; + } + case T_OpExpr: { OpExpr *op = (OpExpr *) node; diff --git a/src/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c index 66495546179..eac3fee0404 100644 --- a/src/backend/nodes/nodeFuncs.c +++ b/src/backend/nodes/nodeFuncs.c @@ -182,6 +182,9 @@ exprType(const Node *expr) case T_CoerceViaIO: type = ((const CoerceViaIO *) expr)->resulttype; break; + case T_CoerceViaFormatter: + type = ((const CoerceViaFormatter *) expr)->resulttype; + break; case T_ArrayCoerceExpr: type = ((const ArrayCoerceExpr *) expr)->resulttype; break; @@ -531,6 +534,8 @@ exprTypmod(const Node *expr) break; case T_CoerceToDomain: return ((const CoerceToDomain *) expr)->resulttypmod; + case T_CoerceViaFormatter: + return ((const CoerceViaFormatter *) expr)->resulttypmod; case T_CoerceToDomainValue: return ((const CoerceToDomainValue *) expr)->typeMod; case T_SetToDefault: @@ -943,6 +948,9 @@ exprCollation(const Node *expr) case T_CoerceViaIO: coll = ((const CoerceViaIO *) expr)->resultcollid; break; + case T_CoerceViaFormatter: + coll = ((const CoerceViaFormatter *) expr)->resultcollid; + break; case T_ArrayCoerceExpr: coll = ((const ArrayCoerceExpr *) expr)->resultcollid; break; @@ -1227,6 +1235,9 @@ exprSetCollation(Node *expr, Oid collation) case T_CoerceViaIO: ((CoerceViaIO *) expr)->resultcollid = collation; break; + case T_CoerceViaFormatter: + ((CoerceViaFormatter *) expr)->resultcollid = collation; + break; case T_ArrayCoerceExpr: ((ArrayCoerceExpr *) expr)->resultcollid = collation; break; @@ -1349,6 +1360,9 @@ exprSetInputCollation(Node *expr, Oid inputcollation) case T_FuncExpr: ((FuncExpr *) expr)->inputcollid = inputcollation; break; + case T_CoerceViaFormatter: + ((CoerceViaFormatter *) expr)->inputcollid = inputcollation; + break; case T_OpExpr: ((OpExpr *) expr)->inputcollid = inputcollation; break; @@ -1527,6 +1541,15 @@ exprLocation(const Node *expr) exprLocation((Node *) cexpr->arg)); } break; + case T_CoerceViaFormatter: + { + const CoerceViaFormatter *cexpr = (const CoerceViaFormatter *) expr; + + /* Much as above */ + loc = leftmostLoc(cexpr->location, + exprLocation((Node *) cexpr->arg)); + } + break; case T_ArrayCoerceExpr: { const ArrayCoerceExpr *cexpr = (const ArrayCoerceExpr *) expr; @@ -1994,6 +2017,15 @@ check_functions_in_node(Node *node, check_function_callback checker, return true; } break; + case T_CoerceViaFormatter: + { + CoerceViaFormatter *expr = (CoerceViaFormatter *) node; + + /* check the formatter function */ + if (checker(expr->formatterfunc, context)) + return true; + } + break; case T_RowCompareExpr: { RowCompareExpr *rcexpr = (RowCompareExpr *) node; @@ -2296,6 +2328,16 @@ expression_tree_walker_impl(Node *node, return WALK(((RelabelType *) node)->arg); case T_CoerceViaIO: return WALK(((CoerceViaIO *) node)->arg); + case T_CoerceViaFormatter: + { + CoerceViaFormatter *fmt = (CoerceViaFormatter *) node; + + if (WALK(fmt->arg)) + return true; + if (WALK(fmt->format)) + return true; + } + break; case T_ArrayCoerceExpr: { ArrayCoerceExpr *acoerce = (ArrayCoerceExpr *) node; @@ -3318,6 +3360,17 @@ expression_tree_mutator_impl(Node *node, return (Node *) newnode; } break; + case T_CoerceViaFormatter: + { + CoerceViaFormatter *fmtcoerce = (CoerceViaFormatter *) node; + CoerceViaFormatter *newnode; + + FLATCOPY(newnode, fmtcoerce, CoerceViaFormatter); + MUTATE(newnode->arg, fmtcoerce->arg, Expr *); + MUTATE(newnode->format, fmtcoerce->format, Expr *); + return (Node *) newnode; + } + break; case T_ArrayCoerceExpr: { ArrayCoerceExpr *acoerce = (ArrayCoerceExpr *) node; diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c index 1729ba56013..243c9121700 100644 --- a/src/backend/parser/parse_expr.c +++ b/src/backend/parser/parse_expr.c @@ -78,6 +78,7 @@ static Node *transformWholeRowRef(ParseState *pstate, int sublevels_up, int location); static Node *transformIndirection(ParseState *pstate, A_Indirection *ind); static Node *transformTypeCast(ParseState *pstate, TypeCast *tc); +static Node *transformFormattedTypeCast(ParseState *pstate, TypeCast *tc); static Node *transformCollateClause(ParseState *pstate, CollateClause *c); static Node *transformJsonObjectConstructor(ParseState *pstate, JsonObjectConstructor *ctor); @@ -2744,17 +2745,12 @@ transformTypeCast(ParseState *pstate, TypeCast *tc) int location; /* - * Formatted casts (CAST(expr AS type FORMAT format_expr)) are parsed and - * represented in the parse tree, but formatter resolution is not yet - * implemented. Reject such casts here rather than silently ignoring the - * FORMAT clause. + * A FORMAT clause turns this into a formatted cast, which is resolved + * exclusively through the pg_formatter catalog (never through ordinary + * cast rules). Handle it in a separate code path. */ if (tc->format != NULL) - ereport(ERROR, - (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), - errmsg("formatted casts are not implemented yet"), - errdetail("No formatter resolution mechanism is available."), - parser_errposition(pstate, exprLocation(tc->format)))); + return transformFormattedTypeCast(pstate, tc); /* Look up the type name first */ typenameTypeIdAndMod(pstate, tc->typeName, &targetType, &targetTypmod); @@ -2824,6 +2820,128 @@ transformTypeCast(ParseState *pstate, TypeCast *tc) return result; } +/* + * Handle a formatted cast: CAST(arg AS typeName FORMAT format). + * + * Resolved exclusively through the pg_formatter catalog, keyed by + * (source type, target type). We build a CoerceViaFormatter node, which at + * execution time calls the registered formatter function with the source + * value and the FORMAT expression (coerced to text). Using a dedicated node + * (rather than a bare FuncExpr) lets the expression deparse back to + * CAST ... FORMAT and depend on the pg_formatter row. + */ +static Node * +transformFormattedTypeCast(ParseState *pstate, TypeCast *tc) +{ + Node *expr; + Node *fmt; + Oid sourceType; + Oid targetType; + int32 targetTypmod; + Oid formatterid; + Oid fmtfuncid = InvalidOid; + Oid funcrettype; + CoerceViaFormatter *cvf; + Node *result; + int location; + + /* Resolve the declared target type, exactly as an ordinary cast does. */ + typenameTypeIdAndMod(pstate, tc->typeName, &targetType, &targetTypmod); + + /* Transform the source expression. */ + expr = transformExprRecurse(pstate, tc->arg); + sourceType = exprType(expr); + + /* + * An unknown-type source (typically a bare string literal or untyped + * NULL) is coerced to text before the formatter lookup, so that, e.g., + * CAST('2026-06-24' AS date FORMAT 'YYYY-MM-DD') uses a formatter + * registered for (text, date) rather than requiring one for (unknown, + * date). + */ + if (sourceType == UNKNOWNOID) + { + expr = coerce_to_specific_type(pstate, expr, TEXTOID, "CAST"); + sourceType = exprType(expr); + } + + location = tc->location; + if (location < 0) + location = tc->typeName->location; + + /* + * Transform the FORMAT expression and coerce it to text before the + * formatter lookup, so an invalid FORMAT expression reports a normal + * error rather than being masked by a missing-formatter error. + */ + fmt = transformExprRecurse(pstate, tc->format); + fmt = coerce_to_specific_type(pstate, fmt, TEXTOID, "FORMAT"); + + /* + * Look up the formatter for this (source, target) pair. A FORMAT clause + * never falls back to ordinary cast resolution. + */ + formatterid = get_formatter_for_cast(sourceType, targetType, &fmtfuncid); + if (!OidIsValid(formatterid)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("formatter for cast from type %s to type %s does not exist", + format_type_be(sourceType), + format_type_be(targetType)), + errhint("Use CREATE FORMATTER to define a formatter for this type pair."), + parser_errposition(pstate, location))); + + /* + * Build the CoerceViaFormatter node. Its result type is the formatter + * function's return type, which CREATE FORMATTER guarantees equals the + * target type. + * + * The collation fields are left unset here; they are assigned later by + * parse_collate.c, where CoerceViaFormatter takes the same general n-ary + * expression path as FuncExpr (its result/input collation get/set hooks + * live in nodeFuncs.c). + */ + funcrettype = get_func_rettype(fmtfuncid); + if (!OidIsValid(funcrettype)) + elog(ERROR, "cache lookup failed for function %u", fmtfuncid); + Assert(funcrettype == targetType); + + cvf = makeNode(CoerceViaFormatter); + cvf->arg = (Expr *) expr; + cvf->format = (Expr *) fmt; + cvf->resulttype = funcrettype; + cvf->resulttypmod = -1; /* typmod enforced below, if any */ + cvf->resultcollid = InvalidOid; + cvf->inputcollid = InvalidOid; + cvf->formatterfunc = fmtfuncid; + cvf->formatterid = formatterid; + cvf->coercionformat = COERCE_EXPLICIT_CAST; + cvf->location = location; + result = (Node *) cvf; + + /* + * Enforce the declared target type modifier (and any domain constraints) + * using the ordinary coercion machinery. In the common case where the + * target has no typmod and is not a domain this is a no-op; otherwise it + * layers the same length coercion / domain check that an ordinary cast to + * the same target would apply. + */ + result = coerce_to_target_type(pstate, result, funcrettype, + targetType, targetTypmod, + COERCION_EXPLICIT, + COERCE_EXPLICIT_CAST, + location); + if (result == NULL) + ereport(ERROR, + (errcode(ERRCODE_CANNOT_COERCE), + errmsg("cannot cast type %s to %s", + format_type_be(funcrettype), + format_type_be(targetType)), + parser_errposition(pstate, location))); + + return result; +} + /* * Handle an explicit COLLATE clause. * diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c index 88de5c0481c..76f07c85a8f 100644 --- a/src/backend/utils/adt/ruleutils.c +++ b/src/backend/utils/adt/ruleutils.c @@ -9447,6 +9447,10 @@ isSimpleNode(Node *node, Node *parentNode, int prettyFlags) /* function-like: name(..) or name[..] */ return true; + case T_CoerceViaFormatter: + /* deparses as CAST(.. FORMAT ..), self-delimiting */ + return true; + /* CASE keywords act as parentheses */ case T_CaseExpr: return true; @@ -10310,6 +10314,21 @@ get_rule_expr(Node *node, deparse_context *context, } break; + case T_CoerceViaFormatter: + { + CoerceViaFormatter *fmt = (CoerceViaFormatter *) node; + + /* always print the SQL-standard CAST(... FORMAT ...) syntax */ + appendStringInfoString(buf, "CAST("); + get_rule_expr((Node *) fmt->arg, context, false); + appendStringInfo(buf, " AS %s FORMAT ", + format_type_with_typemod(fmt->resulttype, + fmt->resulttypmod)); + get_rule_expr((Node *) fmt->format, context, false); + appendStringInfoChar(buf, ')'); + } + break; + case T_ArrayCoerceExpr: { ArrayCoerceExpr *acoerce = (ArrayCoerceExpr *) node; diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c index 036de5f79ef..a58111b431f 100644 --- a/src/backend/utils/cache/lsyscache.c +++ b/src/backend/utils/cache/lsyscache.c @@ -27,6 +27,7 @@ #include "catalog/pg_collation.h" #include "catalog/pg_constraint.h" #include "catalog/pg_database.h" +#include "catalog/pg_formatter.h" #include "catalog/pg_index.h" #include "catalog/pg_language.h" #include "catalog/pg_namespace.h" @@ -1238,6 +1239,42 @@ get_cast_oid(Oid sourcetypeid, Oid targettypeid, bool missing_ok) return oid; } +/* ---------- PG_FORMATTER CACHE ---------- */ + +/* + * get_formatter_for_cast + * + * Given source and target type OIDs, look up the formatter registered + * for that (source, target) pair. Returns the pg_formatter row OID, or + * InvalidOid if none is registered. If found and formatterfunc is not + * NULL, *formatterfunc is set to the formatter function OID. + */ +Oid +get_formatter_for_cast(Oid sourcetypeid, Oid targettypeid, Oid *formatterfunc) +{ + HeapTuple tp; + Oid result; + + tp = SearchSysCache2(FORMATTERSOURCETARGET, + ObjectIdGetDatum(sourcetypeid), + ObjectIdGetDatum(targettypeid)); + if (!HeapTupleIsValid(tp)) + return InvalidOid; + + { + Form_pg_formatter fmt = (Form_pg_formatter) GETSTRUCT(tp); + + /* A valid pg_formatter row always names a formatter function. */ + Assert(OidIsValid(fmt->fmtfunc)); + + result = fmt->oid; + if (formatterfunc != NULL) + *formatterfunc = fmt->fmtfunc; + } + ReleaseSysCache(tp); + return result; +} + /* ---------- COLLATION CACHE ---------- */ /* diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index bb05aeebee4..f5752fefee3 100644 --- a/src/include/nodes/primnodes.h +++ b/src/include/nodes/primnodes.h @@ -1230,6 +1230,44 @@ typedef struct CoerceViaIO ParseLoc location; /* token location, or -1 if unknown */ } CoerceViaIO; +/* ---------------- + * CoerceViaFormatter + * + * CoerceViaFormatter represents CAST(arg AS resulttype FORMAT format), a + * formatted cast resolved through the pg_formatter catalog. It calls the + * registered formatter function with the source value and the FORMAT + * expression (already coerced to text), returning the target type: + * + * formatterfunc(arg, format) returns resulttype + * + * Unlike a plain FuncExpr, this node remembers that it came from + * CAST ... FORMAT (so it deparses back to that syntax) and which + * pg_formatter row it resolved to (so a stored expression can depend on the + * formatter object). It is executed by reusing ordinary function-call + * evaluation, so EXECUTE permission on formatterfunc is checked at run time, + * as for an ordinary function-backed cast. + * ---------------- + */ +typedef struct CoerceViaFormatter +{ + Expr xpr; + Expr *arg; /* source expression */ + Expr *format; /* FORMAT expression, already coerced to text */ + Oid resulttype; /* output type of the cast */ + /* output typmod (usually -1; typmod enforcement is layered above) */ + int32 resulttypmod pg_node_attr(query_jumble_ignore); + /* OID of result collation, or InvalidOid if none */ + Oid resultcollid pg_node_attr(query_jumble_ignore); + /* input collation for the formatter function call */ + Oid inputcollid pg_node_attr(query_jumble_ignore); + Oid formatterfunc; /* pg_formatter.fmtfunc */ + /* pg_formatter row OID this resolved to (for dependencies) */ + Oid formatterid pg_node_attr(query_jumble_ignore); + /* how to display this node */ + CoercionForm coercionformat pg_node_attr(query_jumble_ignore); + ParseLoc location; /* token location, or -1 if unknown */ +} CoerceViaFormatter; + /* ---------------- * ArrayCoerceExpr * diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h index 865980cb0f1..c946bf6f59b 100644 --- a/src/include/utils/lsyscache.h +++ b/src/include/utils/lsyscache.h @@ -103,6 +103,8 @@ extern void get_atttypetypmodcoll(Oid relid, AttrNumber attnum, Oid *typid, int32 *typmod, Oid *collid); extern Datum get_attoptions(Oid relid, int16 attnum); extern Oid get_cast_oid(Oid sourcetypeid, Oid targettypeid, bool missing_ok); +extern Oid get_formatter_for_cast(Oid sourcetypeid, Oid targettypeid, + Oid *formatterfunc); extern char *get_collation_name(Oid colloid); extern bool get_collation_isdeterministic(Oid colloid); extern char *get_constraint_name(Oid conoid); diff --git a/src/test/regress/expected/expressions.out b/src/test/regress/expected/expressions.out index b2d71dca4fa..7d064024b3a 100644 --- a/src/test/regress/expected/expressions.out +++ b/src/test/regress/expected/expressions.out @@ -564,23 +564,27 @@ rollback; -- -- CAST(expr AS type FORMAT format_expr) -- --- The FORMAT clause is parsed and stored, but formatter resolution is not --- implemented yet, so parse analysis must reject it (not ignore it). --- basic form +-- A FORMAT clause is resolved through a registered formatter (see CREATE +-- FORMATTER) and never falls back to an ordinary cast. No formatters are +-- defined in this test, so these casts fail with a missing-formatter error; +-- this confirms the FORMAT clause is neither ignored nor treated as an +-- ordinary cast. An unknown-type source literal is coerced to text first, +-- so the lookup key is (text, target). +-- basic form (looks up a formatter for (text, date)) SELECT CAST('2026-06-24' AS date FORMAT 'YYYY-MM-DD'); -ERROR: formatted casts are not implemented yet +ERROR: formatter for cast from type text to type date does not exist LINE 1: SELECT CAST('2026-06-24' AS date FORMAT 'YYYY-MM-DD'); - ^ -DETAIL: No formatter resolution mechanism is available. + ^ +HINT: Use CREATE FORMATTER to define a formatter for this type pair. -- the format may be a general expression, not just a string literal SELECT CAST('2026-06-24' AS date FORMAT 'YYYY' || '-MM-DD'); -ERROR: formatted casts are not implemented yet +ERROR: formatter for cast from type text to type date does not exist LINE 1: SELECT CAST('2026-06-24' AS date FORMAT 'YYYY' || '-MM-DD'); - ^ -DETAIL: No formatter resolution mechanism is available. --- a no-op-looking cast must still be rejected, not relabeled away + ^ +HINT: Use CREATE FORMATTER to define a formatter for this type pair. +-- a no-op-looking cast must not be relabeled away; it needs a (text, text) formatter SELECT CAST('abc'::text AS text FORMAT 'whatever'); -ERROR: formatted casts are not implemented yet +ERROR: formatter for cast from type text to type text does not exist LINE 1: SELECT CAST('abc'::text AS text FORMAT 'whatever'); - ^ -DETAIL: No formatter resolution mechanism is available. + ^ +HINT: Use CREATE FORMATTER to define a formatter for this type pair. diff --git a/src/test/regress/expected/formatters.out b/src/test/regress/expected/formatters.out index bb1f091a1b8..6a47f007f12 100644 --- a/src/test/regress/expected/formatters.out +++ b/src/test/regress/expected/formatters.out @@ -1,9 +1,10 @@ -- -- FORMATTERS -- --- CREATE/DROP FORMATTER registers formatter metadata in pg_formatter, --- keyed by a (source type, target type) pair. This is catalog and DDL --- infrastructure only; it does not transform or execute formatted casts. +-- A formatter registers a function for a (source type, target type) pair; a +-- CAST(... AS target FORMAT format_expr) is resolved through pg_formatter and +-- calls that function. This test covers both the CREATE/DROP FORMATTER +-- catalog DDL and the execution of formatted casts. -- A simple formatter function with the required signature -- formatter(source_type, text) returns target_type CREATE FUNCTION int4_to_text_fmt(integer, text) RETURNS text @@ -64,14 +65,124 @@ CREATE FUNCTION fmt_anyel(anyelement, text) RETURNS text CREATE FORMATTER FOR CAST (anyelement AS text) WITH FUNCTION fmt_anyel(anyelement, text); ERROR: source data type anyelement is a pseudo-type --- Registering a formatter does not enable a formatted cast: the FORMAT --- clause must not be silently ignored or rewritten to a built-in function, --- so CAST(... FORMAT ...) is rejected during parse analysis. -SELECT CAST(5 AS text FORMAT 'YYYY'); -ERROR: formatted casts are not implemented yet -LINE 1: SELECT CAST(5 AS text FORMAT 'YYYY'); +-- ==================================================================== +-- Execution: a formatted cast resolves through pg_formatter and calls the +-- registered formatter function. +-- ==================================================================== +-- basic execution, using the (integer, text) formatter created above +SELECT CAST(5 AS text FORMAT 'abc'); + text +------- + 5:abc +(1 row) + +-- the FORMAT expression may be any expression, coerced to text +SELECT CAST(5 AS text FORMAT 'a' || 'b'); + text +------ + 5:ab +(1 row) + +SELECT CAST(5 AS text FORMAT 123); + text +------- + 5:123 +(1 row) + +-- The FORMAT expression is parse-analyzed independently of (and before) the +-- formatter lookup, so an invalid FORMAT expression reports a normal error. +SELECT CAST(5 AS text FORMAT no_such_column); +ERROR: column "no_such_column" does not exist +LINE 1: SELECT CAST(5 AS text FORMAT no_such_column); ^ -DETAIL: No formatter resolution mechanism is available. +-- Like an ordinary cast that uses a cast function, a formatted cast checks +-- EXECUTE on the formatter function at use time. +REVOKE EXECUTE ON FUNCTION int4_to_text_fmt(integer, text) FROM PUBLIC; +CREATE ROLE regress_formatter_noexec NOLOGIN; +SET ROLE regress_formatter_noexec; +SELECT CAST(5 AS text FORMAT 'p'); -- fails: no EXECUTE on formatter function +ERROR: permission denied for function int4_to_text_fmt +RESET ROLE; +DROP ROLE regress_formatter_noexec; +GRANT EXECUTE ON FUNCTION int4_to_text_fmt(integer, text) TO PUBLIC; +-- A FORMAT clause never falls back to an ordinary cast: text -> text is a +-- trivial ordinary cast, but a formatted cast still requires a formatter. +SELECT CAST('abc'::text AS text FORMAT 'whatever'); +ERROR: formatter for cast from type text to type text does not exist +LINE 1: SELECT CAST('abc'::text AS text FORMAT 'whatever'); + ^ +HINT: Use CREATE FORMATTER to define a formatter for this type pair. +-- An unknown-type source literal is coerced to text first, so this also looks +-- up (text, text), not (unknown, text). +SELECT CAST('abc' AS text FORMAT 'fmt'); +ERROR: formatter for cast from type text to type text does not exist +LINE 1: SELECT CAST('abc' AS text FORMAT 'fmt'); + ^ +HINT: Use CREATE FORMATTER to define a formatter for this type pair. +-- A missing formatter is an error even where an ordinary cast would be valid. +SELECT CAST(5 AS integer FORMAT 'x'); +ERROR: formatter for cast from type integer to type integer does not exist +LINE 1: SELECT CAST(5 AS integer FORMAT 'x'); + ^ +HINT: Use CREATE FORMATTER to define a formatter for this type pair. +-- Define the (text, text) formatter and re-run the text-source cases. +CREATE FUNCTION text_to_text_fmt(text, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1 || '/' || $2; +CREATE FORMATTER FOR CAST (text AS text) + WITH FUNCTION text_to_text_fmt(text, text); +SELECT CAST('abc'::text AS text FORMAT 'whatever'); + text +-------------- + abc/whatever +(1 row) + +SELECT CAST('abc' AS text FORMAT 'fmt'); + text +--------- + abc/fmt +(1 row) + +-- A type modifier on the target is enforced through the ordinary coercion path. +CREATE FUNCTION int4_to_vc_fmt(integer, text) RETURNS varchar + LANGUAGE sql IMMUTABLE RETURN $1::text || $2; +CREATE FORMATTER FOR CAST (integer AS varchar) + WITH FUNCTION int4_to_vc_fmt(integer, text); +SELECT CAST(5 AS varchar FORMAT 'XXXX'); + varchar +--------- + 5XXXX +(1 row) + +SELECT CAST(5 AS varchar(3) FORMAT 'XXXX'); -- length 3 enforced + varchar +--------- + 5XX +(1 row) + +-- Domain target: the formatter must return the domain type, so the domain's +-- constraints are enforced by the function's result. +CREATE DOMAIN nonempty_text AS text CHECK (VALUE <> ''); +CREATE FUNCTION text_to_netext_fmt(text, text) RETURNS nonempty_text + LANGUAGE sql IMMUTABLE RETURN $2::nonempty_text; +CREATE FORMATTER FOR CAST (text AS nonempty_text) + WITH FUNCTION text_to_netext_fmt(text, text); +SELECT CAST('z'::text AS nonempty_text FORMAT 'ok'); + nonempty_text +--------------- + ok +(1 row) + +SELECT CAST('z'::text AS nonempty_text FORMAT ''); -- domain check violation +ERROR: value for domain nonempty_text violates check constraint "nonempty_text_check" +CONTEXT: SQL function "text_to_netext_fmt" statement 1 +-- Drop the objects created in this execution section. +DROP FORMATTER FOR CAST (text AS text); +DROP FUNCTION text_to_text_fmt(text, text); +DROP FORMATTER FOR CAST (integer AS varchar); +DROP FUNCTION int4_to_vc_fmt(integer, text); +DROP FORMATTER FOR CAST (text AS nonempty_text); +DROP FUNCTION text_to_netext_fmt(text, text); +DROP DOMAIN nonempty_text; -- Dependency behavior: the formatter depends on its function. DROP FUNCTION int4_to_text_fmt(integer, text); -- fails (RESTRICT) ERROR: cannot drop function int4_to_text_fmt(integer,text) because other objects depend on it @@ -114,3 +225,135 @@ DROP FUNCTION fmt_bad_arg1(bigint, text); DROP FUNCTION fmt_bad_ret(integer, text); DROP FUNCTION fmt_bad_set(integer, text); DROP FUNCTION fmt_anyel(anyelement, text); +-- ==================================================================== +-- CoerceViaFormatter: deparse fidelity and dependency on the formatter row +-- ==================================================================== +CREATE FUNCTION i2t_view(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2; +CREATE FORMATTER FOR CAST (integer AS text) + WITH FUNCTION i2t_view(integer, text); +-- A view over a formatted cast deparses back to CAST(... FORMAT ...). +CREATE VIEW formatter_view AS SELECT CAST(5 AS text FORMAT 'abc') AS x; +SELECT pg_get_viewdef('formatter_view'::regclass, true); + pg_get_viewdef +-------------------------------------------------- + SELECT CAST(5 AS text FORMAT 'abc'::text) AS x; +(1 row) + +SELECT * FROM formatter_view; + x +------- + 5:abc +(1 row) + +-- The view depends on the pg_formatter row, so DROP FORMATTER RESTRICT fails. +DROP FORMATTER FOR CAST (integer AS text); -- fails (RESTRICT, view depends) +ERROR: cannot drop formatter for cast from integer to text because other objects depend on it +DETAIL: view formatter_view depends on formatter for cast from integer to text +HINT: Use DROP ... CASCADE to drop the dependent objects too. +-- ... and CASCADE drops the dependent view. +DROP FORMATTER FOR CAST (integer AS text) CASCADE; +NOTICE: drop cascades to view formatter_view +SELECT count(*) FROM pg_class WHERE relname = 'formatter_view'; + count +------- + 0 +(1 row) + +DROP FUNCTION i2t_view(integer, text); +-- ==================================================================== +-- Collation: a formatted cast behaves like calling the formatter function +-- formatterfunc(arg, format). Its result collation is the result type's +-- collation, and the input collation is derived from the arg and FORMAT +-- expressions exactly as for an ordinary two-argument function call. +-- ==================================================================== +-- text_larger(text, text) returns text and is collation-sensitive at the C +-- level (it reads PG_GET_COLLATION), so it exercises the input collation. +CREATE FORMATTER FOR CAST (text AS text) + WITH FUNCTION pg_catalog.text_larger(text, text); +-- The default collation flows into the formatter call; if the input collation +-- were left unset this would fail with "could not determine which collation". +SELECT CAST('apple'::text AS text FORMAT 'banana'::text); + text +-------- + banana +(1 row) + +-- An explicit COLLATE on an operand is honored as the input collation. +SELECT CAST('apple'::text AS text FORMAT 'banana'::text COLLATE "C"); + text +-------- + banana +(1 row) + +-- The result is collatable, so an explicit COLLATE on the cast itself works. +SELECT CAST('apple'::text AS text FORMAT 'banana'::text) COLLATE "C" < 'zzz'; + ?column? +---------- + t +(1 row) + +-- Conflicting explicit input collations are rejected, just like a plain +-- function call text_larger('a' COLLATE "C", 'b' COLLATE "POSIX"). +SELECT CAST('a'::text COLLATE "C" AS text FORMAT 'b'::text COLLATE "POSIX"); +ERROR: collation mismatch between explicit collations "C" and "POSIX" +LINE 1: ...ST('a'::text COLLATE "C" AS text FORMAT 'b'::text COLLATE "P... + ^ +DROP FORMATTER FOR CAST (text AS text); +-- ==================================================================== +-- ruleutils: arg and FORMAT subexpressions deparse unambiguously inside +-- CAST(...), needing no extra parentheses, and re-parse to the same thing. +-- ==================================================================== +CREATE FUNCTION i2t_paren(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || $2; +CREATE FORMATTER FOR CAST (integer AS text) + WITH FUNCTION i2t_paren(integer, text); +CREATE VIEW formatter_paren_view AS + SELECT CAST((1 + 2) AS text FORMAT ('a' || 'b')) AS x; +SELECT pg_get_viewdef('formatter_paren_view'::regclass, true); + pg_get_viewdef +----------------------------------------------------------------- + SELECT CAST(1 + 2 AS text FORMAT 'a'::text || 'b'::text) AS x; +(1 row) + +SELECT * FROM formatter_paren_view; + x +----- + 3ab +(1 row) + +DROP VIEW formatter_paren_view; +DROP FORMATTER FOR CAST (integer AS text); +DROP FUNCTION i2t_paren(integer, text); +-- ==================================================================== +-- Dependency completeness: a view over a formatted cast depends (through the +-- pg_formatter row) on the formatter function, so dropping the function with +-- CASCADE removes both the formatter row and the view in one step. +-- ==================================================================== +CREATE FUNCTION i2t_chain(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || $2; +CREATE FORMATTER FOR CAST (integer AS text) + WITH FUNCTION i2t_chain(integer, text); +CREATE VIEW formatter_chain_view AS SELECT CAST(7 AS text FORMAT 'q') AS x; +DROP FUNCTION i2t_chain(integer, text); -- fails: formatter + view depend +ERROR: cannot drop function i2t_chain(integer,text) because other objects depend on it +DETAIL: formatter for cast from integer to text depends on function i2t_chain(integer,text) +view formatter_chain_view depends on function i2t_chain(integer,text) +HINT: Use DROP ... CASCADE to drop the dependent objects too. +DROP FUNCTION i2t_chain(integer, text) CASCADE; -- drops formatter and view +NOTICE: drop cascades to 2 other objects +DETAIL: drop cascades to formatter for cast from integer to text +drop cascades to view formatter_chain_view +SELECT count(*) FROM pg_formatter + WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype; + count +------- + 0 +(1 row) + +SELECT count(*) FROM pg_class WHERE relname = 'formatter_chain_view'; + count +------- + 0 +(1 row) + diff --git a/src/test/regress/sql/expressions.sql b/src/test/regress/sql/expressions.sql index fb05990ed2d..46575bc410d 100644 --- a/src/test/regress/sql/expressions.sql +++ b/src/test/regress/sql/expressions.sql @@ -305,14 +305,18 @@ rollback; -- -- CAST(expr AS type FORMAT format_expr) -- --- The FORMAT clause is parsed and stored, but formatter resolution is not --- implemented yet, so parse analysis must reject it (not ignore it). - --- basic form +-- A FORMAT clause is resolved through a registered formatter (see CREATE +-- FORMATTER) and never falls back to an ordinary cast. No formatters are +-- defined in this test, so these casts fail with a missing-formatter error; +-- this confirms the FORMAT clause is neither ignored nor treated as an +-- ordinary cast. An unknown-type source literal is coerced to text first, +-- so the lookup key is (text, target). + +-- basic form (looks up a formatter for (text, date)) SELECT CAST('2026-06-24' AS date FORMAT 'YYYY-MM-DD'); -- the format may be a general expression, not just a string literal SELECT CAST('2026-06-24' AS date FORMAT 'YYYY' || '-MM-DD'); --- a no-op-looking cast must still be rejected, not relabeled away +-- a no-op-looking cast must not be relabeled away; it needs a (text, text) formatter SELECT CAST('abc'::text AS text FORMAT 'whatever'); diff --git a/src/test/regress/sql/formatters.sql b/src/test/regress/sql/formatters.sql index 8050aee6f45..2c9b4351652 100644 --- a/src/test/regress/sql/formatters.sql +++ b/src/test/regress/sql/formatters.sql @@ -1,9 +1,10 @@ -- -- FORMATTERS -- --- CREATE/DROP FORMATTER registers formatter metadata in pg_formatter, --- keyed by a (source type, target type) pair. This is catalog and DDL --- infrastructure only; it does not transform or execute formatted casts. +-- A formatter registers a function for a (source type, target type) pair; a +-- CAST(... AS target FORMAT format_expr) is resolved through pg_formatter and +-- calls that function. This test covers both the CREATE/DROP FORMATTER +-- catalog DDL and the execution of formatted casts. -- A simple formatter function with the required signature -- formatter(source_type, text) returns target_type @@ -59,10 +60,74 @@ CREATE FUNCTION fmt_anyel(anyelement, text) RETURNS text CREATE FORMATTER FOR CAST (anyelement AS text) WITH FUNCTION fmt_anyel(anyelement, text); --- Registering a formatter does not enable a formatted cast: the FORMAT --- clause must not be silently ignored or rewritten to a built-in function, --- so CAST(... FORMAT ...) is rejected during parse analysis. -SELECT CAST(5 AS text FORMAT 'YYYY'); +-- ==================================================================== +-- Execution: a formatted cast resolves through pg_formatter and calls the +-- registered formatter function. +-- ==================================================================== + +-- basic execution, using the (integer, text) formatter created above +SELECT CAST(5 AS text FORMAT 'abc'); +-- the FORMAT expression may be any expression, coerced to text +SELECT CAST(5 AS text FORMAT 'a' || 'b'); +SELECT CAST(5 AS text FORMAT 123); + +-- The FORMAT expression is parse-analyzed independently of (and before) the +-- formatter lookup, so an invalid FORMAT expression reports a normal error. +SELECT CAST(5 AS text FORMAT no_such_column); + +-- Like an ordinary cast that uses a cast function, a formatted cast checks +-- EXECUTE on the formatter function at use time. +REVOKE EXECUTE ON FUNCTION int4_to_text_fmt(integer, text) FROM PUBLIC; +CREATE ROLE regress_formatter_noexec NOLOGIN; +SET ROLE regress_formatter_noexec; +SELECT CAST(5 AS text FORMAT 'p'); -- fails: no EXECUTE on formatter function +RESET ROLE; +DROP ROLE regress_formatter_noexec; +GRANT EXECUTE ON FUNCTION int4_to_text_fmt(integer, text) TO PUBLIC; + +-- A FORMAT clause never falls back to an ordinary cast: text -> text is a +-- trivial ordinary cast, but a formatted cast still requires a formatter. +SELECT CAST('abc'::text AS text FORMAT 'whatever'); +-- An unknown-type source literal is coerced to text first, so this also looks +-- up (text, text), not (unknown, text). +SELECT CAST('abc' AS text FORMAT 'fmt'); +-- A missing formatter is an error even where an ordinary cast would be valid. +SELECT CAST(5 AS integer FORMAT 'x'); + +-- Define the (text, text) formatter and re-run the text-source cases. +CREATE FUNCTION text_to_text_fmt(text, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1 || '/' || $2; +CREATE FORMATTER FOR CAST (text AS text) + WITH FUNCTION text_to_text_fmt(text, text); +SELECT CAST('abc'::text AS text FORMAT 'whatever'); +SELECT CAST('abc' AS text FORMAT 'fmt'); + +-- A type modifier on the target is enforced through the ordinary coercion path. +CREATE FUNCTION int4_to_vc_fmt(integer, text) RETURNS varchar + LANGUAGE sql IMMUTABLE RETURN $1::text || $2; +CREATE FORMATTER FOR CAST (integer AS varchar) + WITH FUNCTION int4_to_vc_fmt(integer, text); +SELECT CAST(5 AS varchar FORMAT 'XXXX'); +SELECT CAST(5 AS varchar(3) FORMAT 'XXXX'); -- length 3 enforced + +-- Domain target: the formatter must return the domain type, so the domain's +-- constraints are enforced by the function's result. +CREATE DOMAIN nonempty_text AS text CHECK (VALUE <> ''); +CREATE FUNCTION text_to_netext_fmt(text, text) RETURNS nonempty_text + LANGUAGE sql IMMUTABLE RETURN $2::nonempty_text; +CREATE FORMATTER FOR CAST (text AS nonempty_text) + WITH FUNCTION text_to_netext_fmt(text, text); +SELECT CAST('z'::text AS nonempty_text FORMAT 'ok'); +SELECT CAST('z'::text AS nonempty_text FORMAT ''); -- domain check violation + +-- Drop the objects created in this execution section. +DROP FORMATTER FOR CAST (text AS text); +DROP FUNCTION text_to_text_fmt(text, text); +DROP FORMATTER FOR CAST (integer AS varchar); +DROP FUNCTION int4_to_vc_fmt(integer, text); +DROP FORMATTER FOR CAST (text AS nonempty_text); +DROP FUNCTION text_to_netext_fmt(text, text); +DROP DOMAIN nonempty_text; -- Dependency behavior: the formatter depends on its function. DROP FUNCTION int4_to_text_fmt(integer, text); -- fails (RESTRICT) @@ -97,3 +162,77 @@ DROP FUNCTION fmt_bad_arg1(bigint, text); DROP FUNCTION fmt_bad_ret(integer, text); DROP FUNCTION fmt_bad_set(integer, text); DROP FUNCTION fmt_anyel(anyelement, text); + +-- ==================================================================== +-- CoerceViaFormatter: deparse fidelity and dependency on the formatter row +-- ==================================================================== +CREATE FUNCTION i2t_view(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2; +CREATE FORMATTER FOR CAST (integer AS text) + WITH FUNCTION i2t_view(integer, text); + +-- A view over a formatted cast deparses back to CAST(... FORMAT ...). +CREATE VIEW formatter_view AS SELECT CAST(5 AS text FORMAT 'abc') AS x; +SELECT pg_get_viewdef('formatter_view'::regclass, true); +SELECT * FROM formatter_view; + +-- The view depends on the pg_formatter row, so DROP FORMATTER RESTRICT fails. +DROP FORMATTER FOR CAST (integer AS text); -- fails (RESTRICT, view depends) +-- ... and CASCADE drops the dependent view. +DROP FORMATTER FOR CAST (integer AS text) CASCADE; +SELECT count(*) FROM pg_class WHERE relname = 'formatter_view'; +DROP FUNCTION i2t_view(integer, text); + +-- ==================================================================== +-- Collation: a formatted cast behaves like calling the formatter function +-- formatterfunc(arg, format). Its result collation is the result type's +-- collation, and the input collation is derived from the arg and FORMAT +-- expressions exactly as for an ordinary two-argument function call. +-- ==================================================================== +-- text_larger(text, text) returns text and is collation-sensitive at the C +-- level (it reads PG_GET_COLLATION), so it exercises the input collation. +CREATE FORMATTER FOR CAST (text AS text) + WITH FUNCTION pg_catalog.text_larger(text, text); +-- The default collation flows into the formatter call; if the input collation +-- were left unset this would fail with "could not determine which collation". +SELECT CAST('apple'::text AS text FORMAT 'banana'::text); +-- An explicit COLLATE on an operand is honored as the input collation. +SELECT CAST('apple'::text AS text FORMAT 'banana'::text COLLATE "C"); +-- The result is collatable, so an explicit COLLATE on the cast itself works. +SELECT CAST('apple'::text AS text FORMAT 'banana'::text) COLLATE "C" < 'zzz'; +-- Conflicting explicit input collations are rejected, just like a plain +-- function call text_larger('a' COLLATE "C", 'b' COLLATE "POSIX"). +SELECT CAST('a'::text COLLATE "C" AS text FORMAT 'b'::text COLLATE "POSIX"); +DROP FORMATTER FOR CAST (text AS text); + +-- ==================================================================== +-- ruleutils: arg and FORMAT subexpressions deparse unambiguously inside +-- CAST(...), needing no extra parentheses, and re-parse to the same thing. +-- ==================================================================== +CREATE FUNCTION i2t_paren(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || $2; +CREATE FORMATTER FOR CAST (integer AS text) + WITH FUNCTION i2t_paren(integer, text); +CREATE VIEW formatter_paren_view AS + SELECT CAST((1 + 2) AS text FORMAT ('a' || 'b')) AS x; +SELECT pg_get_viewdef('formatter_paren_view'::regclass, true); +SELECT * FROM formatter_paren_view; +DROP VIEW formatter_paren_view; +DROP FORMATTER FOR CAST (integer AS text); +DROP FUNCTION i2t_paren(integer, text); + +-- ==================================================================== +-- Dependency completeness: a view over a formatted cast depends (through the +-- pg_formatter row) on the formatter function, so dropping the function with +-- CASCADE removes both the formatter row and the view in one step. +-- ==================================================================== +CREATE FUNCTION i2t_chain(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || $2; +CREATE FORMATTER FOR CAST (integer AS text) + WITH FUNCTION i2t_chain(integer, text); +CREATE VIEW formatter_chain_view AS SELECT CAST(7 AS text FORMAT 'q') AS x; +DROP FUNCTION i2t_chain(integer, text); -- fails: formatter + view depend +DROP FUNCTION i2t_chain(integer, text) CASCADE; -- drops formatter and view +SELECT count(*) FROM pg_formatter + WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype; +SELECT count(*) FROM pg_class WHERE relname = 'formatter_chain_view'; -- 2.54.0