From b0cdce84f7825651a6bda1a371bcc2458311545f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Tue, 7 Jul 2026 10:30:40 +0200 Subject: [PATCH v2 5/5] Allow schema-qualifying the comparison operator of a simple CASE A simple CASE, CASE x WHEN y THEN ..., is defined to compare the test expression against each WHEN value exactly as if "x = y" had been written, and each of those "=" operators is resolved independently by unqualified name. Two arms of one CASE can therefore resolve to different operators: in CASE x WHEN 4 THEN ... WHEN 5.0 THEN ... END the first arm resolves integer equality while the second resolves numeric equality, and either could belong to a schema that is not on the search path. ruleutils.c could not reproduce that at all: when it recognized the implicit "CaseTestExpr = RHS" form it printed just the RHS and silently dropped the operator, so a dumped-and-reloaded view reparsed every arm with whatever "=" the restore-time search path happened to resolve -- changed semantics, no error. Give the simple CASE an optional per-arm operator syntax mirroring the one added for NULLIF and IS [NOT] DISTINCT FROM, CASE x WHEN y USING OPERATOR(schema.=) THEN ... which pins the comparison operator for that arm (and only that arm). A new raw-only List field CaseWhen.opname carries the written name from the grammar to parse analysis, where transformCaseExpr expands the arm into the chosen operator instead of a bare "="; the field is reset to NIL in analyzed trees, and using the clause on a searched CASE (which has no test expression) is rejected. When deparsing, each recognized arm now emits USING OPERATOR() honestly: whenever a bare "WHEN value" would not reparse to the same operator -- the operator is not reachable by an unqualified lookup of its own name, or is not named "=" -- the clause is printed, schema-qualified as needed, per arm. Output is unchanged whenever the bare form already recovered the operator. CaseWhen now serializes an additional field, so bump the catalog version. Discussion: https://postgr.es/m/3856834.1783087886@sss.pgh.pa.us --- doc/src/sgml/func/func-conditional.sgml | 22 ++- doc/src/sgml/syntax.sgml | 6 +- src/backend/parser/gram.y | 10 ++ src/backend/parser/parse_expr.c | 23 ++- src/backend/utils/adt/ruleutils.c | 29 ++++ src/include/catalog/catversion.h | 2 +- src/include/nodes/primnodes.h | 6 + .../regress/expected/operator_qualify.out | 139 ++++++++++++++++++ src/test/regress/sql/operator_qualify.sql | 75 ++++++++++ 9 files changed, 304 insertions(+), 8 deletions(-) diff --git a/doc/src/sgml/func/func-conditional.sgml b/doc/src/sgml/func/func-conditional.sgml index 137d5c7e43..6729788e3a 100644 --- a/doc/src/sgml/func/func-conditional.sgml +++ b/doc/src/sgml/func/func-conditional.sgml @@ -99,7 +99,7 @@ SELECT a, CASE expression - WHEN value THEN result + WHEN value USING OPERATOR(operator) THEN result WHEN ... ELSE result END @@ -114,6 +114,26 @@ END to the switch statement in C. + + Each comparison is performed exactly as if you had + written expression + = value, so a + suitable = operator must be available, and it is + resolved by name through the current search path independently for each + WHEN clause. To pin a specific operator for a + given clause instead — for example one belonging to an extension whose + schema is not on the search path — give its name with the + optional USING OPERATOR() clause (see + for + the OPERATOR() notation). When a view or rule that uses + the simple CASE form is dumped, + pg_dump emits this clause automatically for any + clause whose bare name = would not resolve to the same + operator under the search path used at restore time, or whose intended + operator is not named = at all, so that the definition + reloads unambiguously. + + The example above can be written using the simple CASE syntax: diff --git a/doc/src/sgml/syntax.sgml b/doc/src/sgml/syntax.sgml index 5165533813..f06b98e728 100644 --- a/doc/src/sgml/syntax.sgml +++ b/doc/src/sgml/syntax.sgml @@ -1141,7 +1141,11 @@ SELECT 3 OPERATOR(pg_catalog.+) 4; of operators inside OPERATOR(), one for each column of the compared rows (see ); this includes row-constructor comparisons against a subquery - (see ). + (see ). The simple + CASE form accepts the decoration on each + WHEN clause with USING OPERATOR(), + pinning the comparison operator for that arm + (see ). diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y index 30bd69c01f..f28adc2b55 100644 --- a/src/backend/parser/gram.y +++ b/src/backend/parser/gram.y @@ -17900,6 +17900,16 @@ when_clause: w->location = @1; $$ = (Node *) w; } + | WHEN a_expr USING OPERATOR '(' any_operator ')' THEN a_expr + { + CaseWhen *w = makeNode(CaseWhen); + + w->opname = $6; + w->expr = (Expr *) $2; + w->result = (Expr *) $9; + w->location = @1; + $$ = (Node *) w; + } ; case_default: diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c index a8630e6c34..09d91c4378 100644 --- a/src/backend/parser/parse_expr.c +++ b/src/backend/parser/parse_expr.c @@ -1766,12 +1766,25 @@ transformCaseExpr(ParseState *pstate, CaseExpr *c) warg = (Node *) w->expr; if (placeholder) { - /* shorthand form was specified, so expand... */ - warg = (Node *) makeSimpleA_Expr(AEXPR_OP, "=", - (Node *) placeholder, - warg, - w->location); + /* + * Shorthand form was specified, so expand it into an equality + * comparison between the CASE test expression and the WHEN value. + * The comparison operator defaults to "=", but the user may pin a + * specific (possibly schema-qualified) operator with the optional + * USING OPERATOR() clause. + */ + warg = (Node *) makeA_Expr(AEXPR_OP, + w->opname ? w->opname : + list_make1(makeString("=")), + (Node *) placeholder, warg, + w->location); } + else if (w->opname != NIL) + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("CASE/WHEN ... USING OPERATOR requires a CASE test expression"), + parser_errposition(pstate, w->location))); + neww->opname = NIL; /* raw-only; keep analyzed trees clean */ neww->expr = (Expr *) transformExprRecurse(pstate, warg); neww->expr = (Expr *) coerce_to_boolean(pstate, diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c index 487ee4b6e0..aea9628106 100644 --- a/src/backend/utils/adt/ruleutils.c +++ b/src/backend/utils/adt/ruleutils.c @@ -10433,6 +10433,7 @@ get_rule_expr(Node *node, deparse_context *context, { CaseWhen *when = (CaseWhen *) lfirst(temp); Node *w = (Node *) when->expr; + OpExpr *usingop = NULL; if (caseexpr->arg) { @@ -10455,7 +10456,10 @@ get_rule_expr(Node *node, deparse_context *context, if (list_length(args) == 2 && IsA(strip_implicit_coercions(linitial(args)), CaseTestExpr)) + { + usingop = (OpExpr *) w; w = (Node *) lsecond(args); + } } } @@ -10464,6 +10468,31 @@ get_rule_expr(Node *node, deparse_context *context, appendContextKeyword(context, "WHEN ", 0, 0, 0); get_rule_expr(w, context, false); + + /* + * If we recognized the implicit-equality WHEN form, the + * comparison operator was resolved from the bare name "=" + * against the CASE test expression and the WHEN value. A + * bare "WHEN value" reparses by resolving "=" again, so + * we must emit an explicit USING OPERATOR() clause + * whenever that would not recover the same operator: i.e. + * the operator is not reachable by an unqualified lookup + * of its own name (then it appears schema-qualified), or + * it is named something other than "=". + */ + if (usingop) + { + bool needs_qual; + char *opname; + + opname = generate_operator_name_extended(usingop->opno, + exprType(linitial(usingop->args)), + exprType(lsecond(usingop->args)), + &needs_qual); + if (needs_qual || strcmp(opname, "=") != 0) + appendStringInfo(buf, " USING OPERATOR(%s)", opname); + } + appendStringInfoString(buf, " THEN "); get_rule_expr((Node *) when->result, context, true); } diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h index 7571c5cd08..79291aaaf5 100644 --- a/src/include/catalog/catversion.h +++ b/src/include/catalog/catversion.h @@ -57,6 +57,6 @@ */ /* yyyymmddN */ -#define CATALOG_VERSION_NO 202607061 +#define CATALOG_VERSION_NO 202607071 #endif diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index bf343cbec0..00f37c2295 100644 --- a/src/include/nodes/primnodes.h +++ b/src/include/nodes/primnodes.h @@ -1340,6 +1340,12 @@ typedef struct CaseExpr typedef struct CaseWhen { Expr xpr; + + /* + * possibly-qualified operator name given with USING OPERATOR(), or NIL; + * consumed by parse analysis, always NIL in analyzed trees + */ + List *opname pg_node_attr(query_jumble_ignore); Expr *expr; /* condition expression */ Expr *result; /* substitution result */ ParseLoc location; /* token location, or -1 if unknown */ diff --git a/src/test/regress/expected/operator_qualify.out b/src/test/regress/expected/operator_qualify.out index c0887e6311..f13496ef78 100644 --- a/src/test/regress/expected/operator_qualify.out +++ b/src/test/regress/expected/operator_qualify.out @@ -752,3 +752,142 @@ DROP VIEW oq_v_any_reload, oq_v_in_reload, oq_v_ne_all_reload, oq_v_in_mixed_reload, oq_v_any_mixed_reload; -- NOTE: the new oq_v_* base views are intentionally kept (like the others) -- so the pg_upgrade suite dump/restores them and exercises the deparse. +-- +-- Simple CASE: each "CASE x WHEN y" arm resolves its own "=" operator by +-- unqualified name, independently per WHEN clause. The comparison operator +-- can be pinned per arm with USING OPERATOR(); the deparse decorates only the +-- arms whose bare "=" would not recover the same operator at restore time. +-- +-- Built while alt_ops shadows pg_catalog: the implicit "=" resolves alt_ops.=. +SET search_path = alt_ops, pg_catalog; +CREATE VIEW public.oq_v_case AS + SELECT CASE a WHEN b THEN 'eq' ELSE 'ne' END AS r FROM public.oq_t; +RESET search_path; +-- Off the path, the arm's operator must be qualified: the bare form would +-- reparse "=" (pg_catalog.=), a different operator. +SELECT pg_get_viewdef('oq_v_case'::regclass, true); + pg_get_viewdef +-------------------------------------------------------------- + SELECT + + CASE a + + WHEN b USING OPERATOR(alt_ops.=) THEN 'eq'::text+ + ELSE 'ne'::text + + END AS r + + FROM oq_t; +(1 row) + +-- With alt_ops reachable again, the plain simple-CASE syntax comes back. +SET search_path = alt_ops, pg_catalog, public; +SELECT pg_get_viewdef('oq_v_case'::regclass, true); + pg_get_viewdef +------------------------------------ + SELECT + + CASE a + + WHEN b THEN 'eq'::text+ + ELSE 'ne'::text + + END AS r + + FROM oq_t; +(1 row) + +RESET search_path; +-- A bare-resolvable operator whose name is not "=" must still be decorated: +-- reparsing "WHEN b" would resolve "=", not this operator. pg_catalog.< is on +-- the default search_path, so its name prints unqualified, but it is wrapped in +-- OPERATOR() so the reparsed semantics stay identical. +CREATE VIEW public.oq_v_case_lt AS + SELECT CASE a WHEN b USING OPERATOR(<) THEN 'lt' ELSE 'ge' END AS r + FROM public.oq_t; +SELECT pg_get_viewdef('oq_v_case_lt'::regclass, true); + pg_get_viewdef +------------------------------------------------------ + SELECT + + CASE a + + WHEN b USING OPERATOR(<) THEN 'lt'::text+ + ELSE 'ge'::text + + END AS r + + FROM oq_t; +(1 row) + +-- Per-arm operators may differ within one CASE. Built under the alt_ops path: +-- the first arm's implicit "=" resolves alt_ops.= (off the default path -> must +-- be qualified), while the second arm pins pg_catalog.= explicitly (reachable +-- bare -> stays undecorated). The deparse decorates only the first arm. +SET search_path = alt_ops, pg_catalog; +CREATE VIEW public.oq_v_case_mixed AS + SELECT CASE a WHEN b THEN 'x' + WHEN id USING OPERATOR(pg_catalog.=) THEN 'y' + ELSE 'z' END AS r + FROM public.oq_t; +RESET search_path; +SELECT pg_get_viewdef('oq_v_case_mixed'::regclass, true); + pg_get_viewdef +------------------------------------------------------------- + SELECT + + CASE a + + WHEN b USING OPERATOR(alt_ops.=) THEN 'x'::text+ + WHEN id THEN 'y'::text + + ELSE 'z'::text + + END AS r + + FROM oq_t; +(1 row) + +-- The decorated syntax is directly acceptable, and it pins the given operator. +SELECT CASE 1 WHEN 2 USING OPERATOR(alt_ops.=) THEN 'x' ELSE 'y' END AS y; + y +--- + y +(1 row) + +SELECT CASE 1 WHEN 1 USING OPERATOR(pg_catalog.=) THEN 'x' ELSE 'y' END AS x; + x +--- + x +(1 row) + +-- an unqualified name inside the decoration is fine too +SELECT CASE 1 WHEN 1 USING OPERATOR(=) THEN 'x' ELSE 'y' END AS x; + x +--- + x +(1 row) + +-- USING OPERATOR() only makes sense for the simple form; the searched CASE +-- (no CASE test expression) rejects it, pointing at the offending WHEN. +SELECT CASE WHEN 1=1 USING OPERATOR(=) THEN 1 END; +ERROR: CASE/WHEN ... USING OPERATOR requires a CASE test expression +LINE 1: SELECT CASE WHEN 1=1 USING OPERATOR(=) THEN 1 END; + ^ +-- The deparsed simple-CASE definitions must reparse under a restricted +-- search_path (the pg_dump scenario), pinning the same operator OIDs. +BEGIN; SET LOCAL search_path = pg_catalog; +DO $$ BEGIN + EXECUTE 'CREATE VIEW public.oq_v_case_reload AS ' || pg_get_viewdef('public.oq_v_case'::regclass); + EXECUTE 'CREATE VIEW public.oq_v_case_lt_reload AS ' || pg_get_viewdef('public.oq_v_case_lt'::regclass); + EXECUTE 'CREATE VIEW public.oq_v_case_mixed_reload AS ' || pg_get_viewdef('public.oq_v_case_mixed'::regclass); +END $$; COMMIT; +-- The reloaded views pin alt_ops.= for the arms that were decorated: the whole +-- CASE view (arm 1) and the mixed view (arm 1). The simple CASE expands each +-- arm into an OpExpr, serialized with ":opno". +SELECT ev_class::regclass FROM pg_rewrite +WHERE ev_action LIKE '%:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %' + AND ev_class::regclass::text LIKE 'oq_v_case%reload' +ORDER BY 1; + ev_class +------------------------ + oq_v_case_reload + oq_v_case_mixed_reload +(2 rows) + +-- The non-"=" operator OPERATOR(<) pins int4lt across the reload. +SELECT ev_class::regclass FROM pg_rewrite +WHERE ev_action LIKE '%:opno ' || 'pg_catalog.<(int4,int4)'::regoperator::oid || ' %' + AND ev_class::regclass::text LIKE 'oq_v_case%lt_reload' +ORDER BY 1; + ev_class +--------------------- + oq_v_case_lt_reload +(1 row) + +DROP VIEW oq_v_case_reload, oq_v_case_lt_reload, oq_v_case_mixed_reload; +-- NOTE: the oq_v_case* base views are intentionally kept (like the others) so +-- the pg_upgrade suite dump/restores them and exercises the deparse. diff --git a/src/test/regress/sql/operator_qualify.sql b/src/test/regress/sql/operator_qualify.sql index f029d0eb73..a2b35e3b0b 100644 --- a/src/test/regress/sql/operator_qualify.sql +++ b/src/test/regress/sql/operator_qualify.sql @@ -424,3 +424,78 @@ DROP VIEW oq_v_any_reload, oq_v_in_reload, oq_v_ne_all_reload, oq_v_in_mixed_reload, oq_v_any_mixed_reload; -- NOTE: the new oq_v_* base views are intentionally kept (like the others) -- so the pg_upgrade suite dump/restores them and exercises the deparse. + +-- +-- Simple CASE: each "CASE x WHEN y" arm resolves its own "=" operator by +-- unqualified name, independently per WHEN clause. The comparison operator +-- can be pinned per arm with USING OPERATOR(); the deparse decorates only the +-- arms whose bare "=" would not recover the same operator at restore time. +-- +-- Built while alt_ops shadows pg_catalog: the implicit "=" resolves alt_ops.=. +SET search_path = alt_ops, pg_catalog; +CREATE VIEW public.oq_v_case AS + SELECT CASE a WHEN b THEN 'eq' ELSE 'ne' END AS r FROM public.oq_t; +RESET search_path; +-- Off the path, the arm's operator must be qualified: the bare form would +-- reparse "=" (pg_catalog.=), a different operator. +SELECT pg_get_viewdef('oq_v_case'::regclass, true); +-- With alt_ops reachable again, the plain simple-CASE syntax comes back. +SET search_path = alt_ops, pg_catalog, public; +SELECT pg_get_viewdef('oq_v_case'::regclass, true); +RESET search_path; + +-- A bare-resolvable operator whose name is not "=" must still be decorated: +-- reparsing "WHEN b" would resolve "=", not this operator. pg_catalog.< is on +-- the default search_path, so its name prints unqualified, but it is wrapped in +-- OPERATOR() so the reparsed semantics stay identical. +CREATE VIEW public.oq_v_case_lt AS + SELECT CASE a WHEN b USING OPERATOR(<) THEN 'lt' ELSE 'ge' END AS r + FROM public.oq_t; +SELECT pg_get_viewdef('oq_v_case_lt'::regclass, true); + +-- Per-arm operators may differ within one CASE. Built under the alt_ops path: +-- the first arm's implicit "=" resolves alt_ops.= (off the default path -> must +-- be qualified), while the second arm pins pg_catalog.= explicitly (reachable +-- bare -> stays undecorated). The deparse decorates only the first arm. +SET search_path = alt_ops, pg_catalog; +CREATE VIEW public.oq_v_case_mixed AS + SELECT CASE a WHEN b THEN 'x' + WHEN id USING OPERATOR(pg_catalog.=) THEN 'y' + ELSE 'z' END AS r + FROM public.oq_t; +RESET search_path; +SELECT pg_get_viewdef('oq_v_case_mixed'::regclass, true); + +-- The decorated syntax is directly acceptable, and it pins the given operator. +SELECT CASE 1 WHEN 2 USING OPERATOR(alt_ops.=) THEN 'x' ELSE 'y' END AS y; +SELECT CASE 1 WHEN 1 USING OPERATOR(pg_catalog.=) THEN 'x' ELSE 'y' END AS x; +-- an unqualified name inside the decoration is fine too +SELECT CASE 1 WHEN 1 USING OPERATOR(=) THEN 'x' ELSE 'y' END AS x; + +-- USING OPERATOR() only makes sense for the simple form; the searched CASE +-- (no CASE test expression) rejects it, pointing at the offending WHEN. +SELECT CASE WHEN 1=1 USING OPERATOR(=) THEN 1 END; + +-- The deparsed simple-CASE definitions must reparse under a restricted +-- search_path (the pg_dump scenario), pinning the same operator OIDs. +BEGIN; SET LOCAL search_path = pg_catalog; +DO $$ BEGIN + EXECUTE 'CREATE VIEW public.oq_v_case_reload AS ' || pg_get_viewdef('public.oq_v_case'::regclass); + EXECUTE 'CREATE VIEW public.oq_v_case_lt_reload AS ' || pg_get_viewdef('public.oq_v_case_lt'::regclass); + EXECUTE 'CREATE VIEW public.oq_v_case_mixed_reload AS ' || pg_get_viewdef('public.oq_v_case_mixed'::regclass); +END $$; COMMIT; +-- The reloaded views pin alt_ops.= for the arms that were decorated: the whole +-- CASE view (arm 1) and the mixed view (arm 1). The simple CASE expands each +-- arm into an OpExpr, serialized with ":opno". +SELECT ev_class::regclass FROM pg_rewrite +WHERE ev_action LIKE '%:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %' + AND ev_class::regclass::text LIKE 'oq_v_case%reload' +ORDER BY 1; +-- The non-"=" operator OPERATOR(<) pins int4lt across the reload. +SELECT ev_class::regclass FROM pg_rewrite +WHERE ev_action LIKE '%:opno ' || 'pg_catalog.<(int4,int4)'::regoperator::oid || ' %' + AND ev_class::regclass::text LIKE 'oq_v_case%lt_reload' +ORDER BY 1; +DROP VIEW oq_v_case_reload, oq_v_case_lt_reload, oq_v_case_mixed_reload; +-- NOTE: the oq_v_case* base views are intentionally kept (like the others) so +-- the pg_upgrade suite dump/restores them and exercises the deparse. -- 2.54.0