From fa11e0046abfb518b65b0916c33e66404bef3492 Mon Sep 17 00:00:00 2001 From: Haibo Yan Date: Wed, 24 Jun 2026 20:46:00 -0700 Subject: [PATCH 3/4] Resolve CAST ... FORMAT with CoerceViaFormatCast Resolve formatted casts through pg_format_cast during parse analysis. The FORMAT expression is transformed and coerced to text, unknown source literals are treated as text, and format cast lookup is performed by exact source and target type. If no format cast exists, the cast fails; a FORMAT clause never falls back to ordinary cast resolution. Represent the analyzed expression with a CoerceViaFormatCast node. The node preserves CAST ... FORMAT syntax for ruleutils and pg_dump, and records a dependency on the pg_format_cast row as well as on the format cast 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/format_casts.out | 263 ++++++++++++++++++++- src/test/regress/sql/expressions.sql | 14 +- src/test/regress/sql/format_casts.sql | 153 +++++++++++- 12 files changed, 742 insertions(+), 44 deletions(-) diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c index ff7ab606bcc..34be165849a 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, CoerceViaFormatCast)) + { + CoerceViaFormatCast *fmt = (CoerceViaFormatCast *) node; + + /* depend on the result type */ + add_object_address(TypeRelationId, fmt->resulttype, 0, + context->addrs); + /* depend on the format cast function */ + add_object_address(ProcedureRelationId, fmt->formatfunc, 0, + context->addrs); + /* + * Also depend on the pg_format_cast row itself, so that DROP FORMAT CAST + * is refused (or cascades) while a stored expression uses it. + */ + if (OidIsValid(fmt->formatcastid)) + add_object_address(FormatCastRelationId, fmt->formatcastid, 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..bc9c11c2a45 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_CoerceViaFormatCast: + { + CoerceViaFormatCast *fmt = (CoerceViaFormatCast *) node; + + /* + * A formatted cast is executed exactly like a call to its + * format cast function: formatfunc(arg, format). Reuse the + * ordinary function-call setup, which also performs the + * run-time EXECUTE permission check on the format cast function. + */ + ExecInitFunc(&scratch, node, + list_make2(fmt->arg, fmt->format), + fmt->formatfunc, 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..28fe0814732 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_CoerceViaFormatCast: + type = ((const CoerceViaFormatCast *) 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_CoerceViaFormatCast: + return ((const CoerceViaFormatCast *) 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_CoerceViaFormatCast: + coll = ((const CoerceViaFormatCast *) 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_CoerceViaFormatCast: + ((CoerceViaFormatCast *) 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_CoerceViaFormatCast: + ((CoerceViaFormatCast *) 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_CoerceViaFormatCast: + { + const CoerceViaFormatCast *cexpr = (const CoerceViaFormatCast *) 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_CoerceViaFormatCast: + { + CoerceViaFormatCast *expr = (CoerceViaFormatCast *) node; + + /* check the format cast function */ + if (checker(expr->formatfunc, 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_CoerceViaFormatCast: + { + CoerceViaFormatCast *fmt = (CoerceViaFormatCast *) 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_CoerceViaFormatCast: + { + CoerceViaFormatCast *fmtcoerce = (CoerceViaFormatCast *) node; + CoerceViaFormatCast *newnode; + + FLATCOPY(newnode, fmtcoerce, CoerceViaFormatCast); + 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 ba6480acf12..3d481119d16 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 format cast 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_format_cast 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 format cast 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_format_cast catalog, keyed by + * (source type, target type). We build a CoerceViaFormatCast node, which at + * execution time calls the registered format cast 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_format_cast row. + */ +static Node * +transformFormattedTypeCast(ParseState *pstate, TypeCast *tc) +{ + Node *expr; + Node *fmt; + Oid sourceType; + Oid targetType; + int32 targetTypmod; + Oid formatcastid; + Oid fmtfuncid = InvalidOid; + Oid funcrettype; + CoerceViaFormatCast *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 format cast lookup, so that, e.g., + * CAST('2026-06-24' AS date FORMAT 'YYYY-MM-DD') uses a format cast + * 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 + * format cast lookup, so an invalid FORMAT expression reports a normal + * error rather than being masked by a missing-format cast error. + */ + fmt = transformExprRecurse(pstate, tc->format); + fmt = coerce_to_specific_type(pstate, fmt, TEXTOID, "FORMAT"); + + /* + * Look up the format cast for this (source, target) pair. A FORMAT clause + * never falls back to ordinary cast resolution. + */ + formatcastid = get_format_cast_function(sourceType, targetType, &fmtfuncid); + if (!OidIsValid(formatcastid)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("format cast from type %s to type %s does not exist", + format_type_be(sourceType), + format_type_be(targetType)), + errhint("Use CREATE FORMAT CAST to define a format cast for this type pair."), + parser_errposition(pstate, location))); + + /* + * Build the CoerceViaFormatCast node. Its result type is the format cast + * function's return type, which CREATE FORMAT CAST guarantees equals the + * target type. + * + * The collation fields are left unset here; they are assigned later by + * parse_collate.c, where CoerceViaFormatCast 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(CoerceViaFormatCast); + 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->formatfunc = fmtfuncid; + cvf->formatcastid = formatcastid; + 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..eb0eaf37da8 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_CoerceViaFormatCast: + /* 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_CoerceViaFormatCast: + { + CoerceViaFormatCast *fmt = (CoerceViaFormatCast *) 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..76b720668b5 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_format_cast.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_FORMAT_CAST CACHE ---------- */ + +/* + * get_format_cast_function + * + * Given source and target type OIDs, look up the format cast registered + * for that (source, target) pair. Returns the pg_format_cast row OID, or + * InvalidOid if none is registered. If found and formatfunc is not + * NULL, *formatfunc is set to the format cast function OID. + */ +Oid +get_format_cast_function(Oid sourcetypeid, Oid targettypeid, Oid *formatfunc) +{ + HeapTuple tp; + Oid result; + + tp = SearchSysCache2(FORMATCASTSOURCETARGET, + ObjectIdGetDatum(sourcetypeid), + ObjectIdGetDatum(targettypeid)); + if (!HeapTupleIsValid(tp)) + return InvalidOid; + + { + Form_pg_format_cast fmt = (Form_pg_format_cast) GETSTRUCT(tp); + + /* A valid pg_format_cast row always names a format cast function. */ + Assert(OidIsValid(fmt->fmtfunc)); + + result = fmt->oid; + if (formatfunc != NULL) + *formatfunc = fmt->fmtfunc; + } + ReleaseSysCache(tp); + return result; +} + /* ---------- COLLATION CACHE ---------- */ /* diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index bb05aeebee4..4aaebcb0307 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; +/* ---------------- + * CoerceViaFormatCast + * + * CoerceViaFormatCast represents CAST(arg AS resulttype FORMAT format), a + * formatted cast resolved through the pg_format_cast catalog. It calls the + * registered format cast function with the source value and the FORMAT + * expression (already coerced to text), returning the target type: + * + * formatfunc(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_format_cast row it resolved to (so a stored expression can depend on the + * format cast object). It is executed by reusing ordinary function-call + * evaluation, so EXECUTE permission on formatfunc is checked at run time, + * as for an ordinary function-backed cast. + * ---------------- + */ +typedef struct CoerceViaFormatCast +{ + 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 format cast function call */ + Oid inputcollid pg_node_attr(query_jumble_ignore); + Oid formatfunc; /* pg_format_cast.fmtfunc */ + /* pg_format_cast row OID this resolved to (for dependencies) */ + Oid formatcastid 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 */ +} CoerceViaFormatCast; + /* ---------------- * ArrayCoerceExpr * diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h index 865980cb0f1..53026b090a3 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_format_cast_function(Oid sourcetypeid, Oid targettypeid, + Oid *formatfunc); 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 d39cd1b7fbd..1cbd05135dd 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 format cast resolution is not --- implemented yet, so parse analysis must reject it (not ignore it). --- basic form +-- A FORMAT clause is resolved through a registered format cast (see CREATE +-- FORMAT CAST) and never falls back to an ordinary cast. No format_casts are +-- defined in this test, so these casts fail with a missing-format cast 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 format cast for (text, date)) SELECT CAST('2026-06-24' AS date FORMAT 'YYYY-MM-DD'); -ERROR: formatted casts are not implemented yet +ERROR: format 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 format cast resolution mechanism is available. + ^ +HINT: Use CREATE FORMAT CAST to define a format cast 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: format 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 format cast resolution mechanism is available. --- a no-op-looking cast must still be rejected, not relabeled away + ^ +HINT: Use CREATE FORMAT CAST to define a format cast for this type pair. +-- a no-op-looking cast must not be relabeled away; it needs a (text, text) format cast SELECT CAST('abc'::text AS text FORMAT 'whatever'); -ERROR: formatted casts are not implemented yet +ERROR: format cast from type text to type text does not exist LINE 1: SELECT CAST('abc'::text AS text FORMAT 'whatever'); - ^ -DETAIL: No format cast resolution mechanism is available. + ^ +HINT: Use CREATE FORMAT CAST to define a format cast for this type pair. diff --git a/src/test/regress/expected/format_casts.out b/src/test/regress/expected/format_casts.out index 2ae4ca0d0d0..cb8684080ef 100644 --- a/src/test/regress/expected/format_casts.out +++ b/src/test/regress/expected/format_casts.out @@ -1,9 +1,10 @@ -- -- FORMAT CASTS -- --- CREATE/DROP FORMAT CAST registers format cast metadata in pg_format_cast, --- keyed by a (source type, target type) pair. This is catalog and DDL --- infrastructure only; it does not transform or execute formatted casts. +-- A format cast registers a function for a (source type, target type) pair; a +-- CAST(... AS target FORMAT format_expr) is resolved through pg_format_cast and +-- calls that function. This test covers both the CREATE/DROP FORMAT CAST +-- catalog DDL and the execution of formatted casts. -- A simple format cast function with the required signature -- function(source_type, text) returns target_type CREATE FUNCTION int4_to_text_fmt(integer, text) RETURNS text @@ -104,14 +105,124 @@ CREATE FUNCTION fmt_anyel(anyelement, text) RETURNS text CREATE FORMAT CAST (anyelement AS text) WITH FUNCTION fmt_anyel(anyelement, text); ERROR: source data type anyelement is a pseudo-type --- Registering a format cast 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_format_cast and calls the +-- registered format cast function. +-- ==================================================================== +-- basic execution, using the (integer, text) format cast 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 +-- format cast 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 format cast resolution mechanism is available. +-- Like an ordinary cast that uses a cast function, a formatted cast checks +-- EXECUTE on the format cast function at use time. +REVOKE EXECUTE ON FUNCTION int4_to_text_fmt(integer, text) FROM PUBLIC; +CREATE ROLE regress_format_cast_noexec NOLOGIN; +SET ROLE regress_format_cast_noexec; +SELECT CAST(5 AS text FORMAT 'p'); -- fails: no EXECUTE on format cast function +ERROR: permission denied for function int4_to_text_fmt +RESET ROLE; +DROP ROLE regress_format_cast_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 format cast. +SELECT CAST('abc'::text AS text FORMAT 'whatever'); +ERROR: format cast from type text to type text does not exist +LINE 1: SELECT CAST('abc'::text AS text FORMAT 'whatever'); + ^ +HINT: Use CREATE FORMAT CAST to define a format cast 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: format cast from type text to type text does not exist +LINE 1: SELECT CAST('abc' AS text FORMAT 'fmt'); + ^ +HINT: Use CREATE FORMAT CAST to define a format cast for this type pair. +-- A missing format cast is an error even where an ordinary cast would be valid. +SELECT CAST(5 AS integer FORMAT 'x'); +ERROR: format cast from type integer to type integer does not exist +LINE 1: SELECT CAST(5 AS integer FORMAT 'x'); + ^ +HINT: Use CREATE FORMAT CAST to define a format cast for this type pair. +-- Define the (text, text) format cast and re-run the text-source cases. +CREATE FUNCTION text_to_text_fmt(text, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1 || '/' || $2; +CREATE FORMAT 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 FORMAT 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 format cast 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 FORMAT 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 FORMAT CAST (text AS text); +DROP FUNCTION text_to_text_fmt(text, text); +DROP FORMAT CAST (integer AS varchar); +DROP FUNCTION int4_to_vc_fmt(integer, text); +DROP FORMAT CAST (text AS nonempty_text); +DROP FUNCTION text_to_netext_fmt(text, text); +DROP DOMAIN nonempty_text; -- Dependency behavior: the format cast 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 @@ -154,3 +265,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); +-- ==================================================================== +-- CoerceViaFormatCast: deparse fidelity and dependency on the format cast row +-- ==================================================================== +CREATE FUNCTION i2t_view(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2; +CREATE FORMAT CAST (integer AS text) + WITH FUNCTION i2t_view(integer, text); +-- A view over a formatted cast deparses back to CAST(... FORMAT ...). +CREATE VIEW format_cast_view AS SELECT CAST(5 AS text FORMAT 'abc') AS x; +SELECT pg_get_viewdef('format_cast_view'::regclass, true); + pg_get_viewdef +-------------------------------------------------- + SELECT CAST(5 AS text FORMAT 'abc'::text) AS x; +(1 row) + +SELECT * FROM format_cast_view; + x +------- + 5:abc +(1 row) + +-- The view depends on the pg_format_cast row, so DROP FORMAT CAST RESTRICT fails. +DROP FORMAT CAST (integer AS text); -- fails (RESTRICT, view depends) +ERROR: cannot drop format cast from integer to text because other objects depend on it +DETAIL: view format_cast_view depends on format cast from integer to text +HINT: Use DROP ... CASCADE to drop the dependent objects too. +-- ... and CASCADE drops the dependent view. +DROP FORMAT CAST (integer AS text) CASCADE; +NOTICE: drop cascades to view format_cast_view +SELECT count(*) FROM pg_class WHERE relname = 'format_cast_view'; + count +------- + 0 +(1 row) + +DROP FUNCTION i2t_view(integer, text); +-- ==================================================================== +-- Collation: a formatted cast behaves like calling the format cast function +-- formatfunc(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 FORMAT CAST (text AS text) + WITH FUNCTION pg_catalog.text_larger(text, text); +-- The default collation flows into the format cast 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 FORMAT 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 FORMAT CAST (integer AS text) + WITH FUNCTION i2t_paren(integer, text); +CREATE VIEW format_cast_paren_view AS + SELECT CAST((1 + 2) AS text FORMAT ('a' || 'b')) AS x; +SELECT pg_get_viewdef('format_cast_paren_view'::regclass, true); + pg_get_viewdef +----------------------------------------------------------------- + SELECT CAST(1 + 2 AS text FORMAT 'a'::text || 'b'::text) AS x; +(1 row) + +SELECT * FROM format_cast_paren_view; + x +----- + 3ab +(1 row) + +DROP VIEW format_cast_paren_view; +DROP FORMAT CAST (integer AS text); +DROP FUNCTION i2t_paren(integer, text); +-- ==================================================================== +-- Dependency completeness: a view over a formatted cast depends (through the +-- pg_format_cast row) on the format cast function, so dropping the function with +-- CASCADE removes both the format cast row and the view in one step. +-- ==================================================================== +CREATE FUNCTION i2t_chain(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || $2; +CREATE FORMAT CAST (integer AS text) + WITH FUNCTION i2t_chain(integer, text); +CREATE VIEW format_cast_chain_view AS SELECT CAST(7 AS text FORMAT 'q') AS x; +DROP FUNCTION i2t_chain(integer, text); -- fails: format cast + view depend +ERROR: cannot drop function i2t_chain(integer,text) because other objects depend on it +DETAIL: format cast from integer to text depends on function i2t_chain(integer,text) +view format_cast_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 format cast and view +NOTICE: drop cascades to 2 other objects +DETAIL: drop cascades to format cast from integer to text +drop cascades to view format_cast_chain_view +SELECT count(*) FROM pg_format_cast + WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype; + count +------- + 0 +(1 row) + +SELECT count(*) FROM pg_class WHERE relname = 'format_cast_chain_view'; + count +------- + 0 +(1 row) + diff --git a/src/test/regress/sql/expressions.sql b/src/test/regress/sql/expressions.sql index 36eae8c3f04..df4f5b20466 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 format cast resolution is not --- implemented yet, so parse analysis must reject it (not ignore it). - --- basic form +-- A FORMAT clause is resolved through a registered format cast (see CREATE +-- FORMAT CAST) and never falls back to an ordinary cast. No format_casts are +-- defined in this test, so these casts fail with a missing-format cast 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 format cast 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) format cast SELECT CAST('abc'::text AS text FORMAT 'whatever'); diff --git a/src/test/regress/sql/format_casts.sql b/src/test/regress/sql/format_casts.sql index 4453a09784e..05d3236b5ac 100644 --- a/src/test/regress/sql/format_casts.sql +++ b/src/test/regress/sql/format_casts.sql @@ -1,9 +1,10 @@ -- -- FORMAT CASTS -- --- CREATE/DROP FORMAT CAST registers format cast metadata in pg_format_cast, --- keyed by a (source type, target type) pair. This is catalog and DDL --- infrastructure only; it does not transform or execute formatted casts. +-- A format cast registers a function for a (source type, target type) pair; a +-- CAST(... AS target FORMAT format_expr) is resolved through pg_format_cast and +-- calls that function. This test covers both the CREATE/DROP FORMAT CAST +-- catalog DDL and the execution of formatted casts. -- A simple format cast function with the required signature -- function(source_type, text) returns target_type @@ -86,10 +87,74 @@ CREATE FUNCTION fmt_anyel(anyelement, text) RETURNS text CREATE FORMAT CAST (anyelement AS text) WITH FUNCTION fmt_anyel(anyelement, text); --- Registering a format cast 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_format_cast and calls the +-- registered format cast function. +-- ==================================================================== + +-- basic execution, using the (integer, text) format cast 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 +-- format cast 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 format cast function at use time. +REVOKE EXECUTE ON FUNCTION int4_to_text_fmt(integer, text) FROM PUBLIC; +CREATE ROLE regress_format_cast_noexec NOLOGIN; +SET ROLE regress_format_cast_noexec; +SELECT CAST(5 AS text FORMAT 'p'); -- fails: no EXECUTE on format cast function +RESET ROLE; +DROP ROLE regress_format_cast_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 format cast. +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 format cast is an error even where an ordinary cast would be valid. +SELECT CAST(5 AS integer FORMAT 'x'); + +-- Define the (text, text) format cast and re-run the text-source cases. +CREATE FUNCTION text_to_text_fmt(text, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1 || '/' || $2; +CREATE FORMAT 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 FORMAT 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 format cast 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 FORMAT 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 FORMAT CAST (text AS text); +DROP FUNCTION text_to_text_fmt(text, text); +DROP FORMAT CAST (integer AS varchar); +DROP FUNCTION int4_to_vc_fmt(integer, text); +DROP FORMAT CAST (text AS nonempty_text); +DROP FUNCTION text_to_netext_fmt(text, text); +DROP DOMAIN nonempty_text; -- Dependency behavior: the format cast depends on its function. DROP FUNCTION int4_to_text_fmt(integer, text); -- fails (RESTRICT) @@ -124,3 +189,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); + +-- ==================================================================== +-- CoerceViaFormatCast: deparse fidelity and dependency on the format cast row +-- ==================================================================== +CREATE FUNCTION i2t_view(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2; +CREATE FORMAT CAST (integer AS text) + WITH FUNCTION i2t_view(integer, text); + +-- A view over a formatted cast deparses back to CAST(... FORMAT ...). +CREATE VIEW format_cast_view AS SELECT CAST(5 AS text FORMAT 'abc') AS x; +SELECT pg_get_viewdef('format_cast_view'::regclass, true); +SELECT * FROM format_cast_view; + +-- The view depends on the pg_format_cast row, so DROP FORMAT CAST RESTRICT fails. +DROP FORMAT CAST (integer AS text); -- fails (RESTRICT, view depends) +-- ... and CASCADE drops the dependent view. +DROP FORMAT CAST (integer AS text) CASCADE; +SELECT count(*) FROM pg_class WHERE relname = 'format_cast_view'; +DROP FUNCTION i2t_view(integer, text); + +-- ==================================================================== +-- Collation: a formatted cast behaves like calling the format cast function +-- formatfunc(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 FORMAT CAST (text AS text) + WITH FUNCTION pg_catalog.text_larger(text, text); +-- The default collation flows into the format cast 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 FORMAT 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 FORMAT CAST (integer AS text) + WITH FUNCTION i2t_paren(integer, text); +CREATE VIEW format_cast_paren_view AS + SELECT CAST((1 + 2) AS text FORMAT ('a' || 'b')) AS x; +SELECT pg_get_viewdef('format_cast_paren_view'::regclass, true); +SELECT * FROM format_cast_paren_view; +DROP VIEW format_cast_paren_view; +DROP FORMAT CAST (integer AS text); +DROP FUNCTION i2t_paren(integer, text); + +-- ==================================================================== +-- Dependency completeness: a view over a formatted cast depends (through the +-- pg_format_cast row) on the format cast function, so dropping the function with +-- CASCADE removes both the format cast row and the view in one step. +-- ==================================================================== +CREATE FUNCTION i2t_chain(integer, text) RETURNS text + LANGUAGE sql IMMUTABLE RETURN $1::text || $2; +CREATE FORMAT CAST (integer AS text) + WITH FUNCTION i2t_chain(integer, text); +CREATE VIEW format_cast_chain_view AS SELECT CAST(7 AS text FORMAT 'q') AS x; +DROP FUNCTION i2t_chain(integer, text); -- fails: format cast + view depend +DROP FUNCTION i2t_chain(integer, text) CASCADE; -- drops format cast and view +SELECT count(*) FROM pg_format_cast + WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype; +SELECT count(*) FROM pg_class WHERE relname = 'format_cast_chain_view'; -- 2.54.0