Proposal: QUALIFY clause

Started by Matheus Alcantara6 months ago37 messages
#1Matheus Alcantara
matheusssilv97@gmail.com
1 attachment(s)

Hi all,

I'm sending a proof-of-concept patch to add support for the QUALIFY
clause in Postgres. This feature allows filtering rows after window
functions are computed, using a syntax similar to the WHERE or HAVING
clauses.

The idea for this came from a discussion and suggestion by Peter
Eisentraut (thanks, Peter!).

The `QUALIFY` clause is not part of the SQL standard, but it is
implemented by some major DBMSs, including Snowflake [1]https://docs.snowflake.com/en/sql-reference/constructs/qualify, BigQuery
[2]: https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#qualify_clause

The goal is to provide a more ergonomic way to filter on window function
results without needing to nest subqueries or CTEs.

Simple example (see window.sql for more):

SELECT depname,
empno,
salary,
RANK() OVER (PARTITION BY depname ORDER BY salary DESC) AS rnk
FROM empsalary
QUALIFY rnk = 1;

Please note that this is a proof-of-concept patch, I’m still working on
determining the best locations in the code to implement each part of the
logic for QUALIFY. I'm just sending this WIP to collect feedback and then
continue to work on the feature. Additionally, the current patch does not
handle yet expressions using AND/OR when referencing multiple window
function aliases (e.g., QUALIFY rnk = 1 AND rnk2 = 2).

Thoughts?

[1]: https://docs.snowflake.com/en/sql-reference/constructs/qualify
[2]: https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#qualify_clause
[3]: https://duckdb.org/docs/stable/sql/query_syntax/qualify.html

--
Matheus Alcantara

Attachments:

v0-0001-QUALIFY-clause.patchapplication/octet-stream; name=v0-0001-QUALIFY-clause.patchDownload
From cc323df48e7ccd50dae97419430741acd6eba24d Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Wed, 4 Jun 2025 15:56:59 -0300
Subject: [PATCH v0] QUALIFY clause

---
 src/backend/optimizer/plan/planner.c |   5 ++
 src/backend/parser/analyze.c         |   8 +-
 src/backend/parser/gram.y            |  21 +++--
 src/backend/parser/parse_agg.c       |   7 +-
 src/backend/parser/parse_clause.c    |  57 +++++++++++-
 src/backend/parser/parse_expr.c      |   4 +
 src/backend/parser/parse_func.c      |   3 +
 src/backend/parser/parse_relation.c  |  18 ++++
 src/include/nodes/parsenodes.h       |   5 ++
 src/include/parser/kwlist.h          |   1 +
 src/include/parser/parse_clause.h    |   5 +-
 src/include/parser/parse_node.h      |   3 +
 src/test/regress/expected/window.out | 125 +++++++++++++++++++++++++++
 src/test/regress/sql/window.sql      |  51 +++++++++++
 14 files changed, 300 insertions(+), 13 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 549aedcfa99..1a8fb387e47 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4710,6 +4710,11 @@ create_one_window_path(PlannerInfo *root,
 				if (!topwindow)
 					topqual = lappend(topqual, opexpr);
 			}
+
+			/*  Add QUALIFY qual */
+			if (wc->qualifyQual != NULL)
+				topqual = lappend(topqual, (Expr *) wc->qualifyQual);
+
 		}
 
 		path = (Path *)
diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c
index 34f7c17f576..dd27fd3730b 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -1481,9 +1481,12 @@ transformSelectStmt(ParseState *pstate, SelectStmt *stmt)
 	qry->limitOption = stmt->limitOption;
 
 	/* transform window clauses after we have seen all window functions */
+	pstate->p_targetList = qry->targetList;
 	qry->windowClause = transformWindowDefinitions(pstate,
 												   pstate->p_windowdefs,
-												   &qry->targetList);
+												   &qry->targetList,
+												   stmt->qualifyClause);
+
 
 	/* resolve any still-unresolved output columns as being type text */
 	if (pstate->p_resolve_unknowns)
@@ -2975,7 +2978,8 @@ transformPLAssignStmt(ParseState *pstate, PLAssignStmt *stmt)
 	/* transform window clauses after we have seen all window functions */
 	qry->windowClause = transformWindowDefinitions(pstate,
 												   pstate->p_windowdefs,
-												   &qry->targetList);
+												   &qry->targetList,
+												   NULL);	/* FIXME(matheus) */
 
 	qry->rtable = pstate->p_rtable;
 	qry->rteperminfos = pstate->p_rteperminfos;
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 73345bb3c70..63c0da99256 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -520,7 +520,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 %type <node>	TableElement TypedTableElement ConstraintElem DomainConstraintElem TableFuncElement
 %type <node>	columnDef columnOptions optionalPeriodName
 %type <defelt>	def_elem reloption_elem old_aggr_elem operator_def_elem
-%type <node>	def_arg columnElem where_clause where_or_current_clause
+%type <node>	def_arg columnElem where_clause qualify_clause where_or_current_clause
 				a_expr b_expr c_expr AexprConst indirection_el opt_slice_bound
 				columnref having_clause func_table xmltable array_expr
 				OptWhereClause operator_def_arg
@@ -760,7 +760,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	POSITION PRECEDING PRECISION PRESERVE PREPARE PREPARED PRIMARY
 	PRIOR PRIVILEGES PROCEDURAL PROCEDURE PROCEDURES PROGRAM PUBLICATION
 
-	QUOTE QUOTES
+	QUALIFY QUOTE QUOTES
 
 	RANGE READ REAL REASSIGN RECURSIVE REF_P REFERENCES REFERENCING
 	REFRESH REINDEX RELATIVE_P RELEASE RENAME REPEATABLE REPLACE REPLICA
@@ -12993,7 +12993,7 @@ select_clause:
  */
 simple_select:
 			SELECT opt_all_clause opt_target_list
-			into_clause from_clause where_clause
+			into_clause from_clause where_clause qualify_clause
 			group_clause having_clause window_clause
 				{
 					SelectStmt *n = makeNode(SelectStmt);
@@ -13002,10 +13002,11 @@ simple_select:
 					n->intoClause = $4;
 					n->fromClause = $5;
 					n->whereClause = $6;
-					n->groupClause = ($7)->list;
-					n->groupDistinct = ($7)->distinct;
-					n->havingClause = $8;
-					n->windowClause = $9;
+					n->qualifyClause = $7;
+					n->groupClause = ($8)->list;
+					n->groupDistinct = ($8)->distinct;
+					n->havingClause = $9;
+					n->windowClause = $10;
 					$$ = (Node *) n;
 				}
 			| SELECT distinct_clause target_list
@@ -14135,6 +14136,11 @@ where_clause:
 			| /*EMPTY*/								{ $$ = NULL; }
 		;
 
+qualify_clause:
+			QUALIFY a_expr							{ $$ = $2; }
+			| /*EMPTY*/								{ $$ = NULL; }
+		;
+
 /* variant for UPDATE and DELETE */
 where_or_current_clause:
 			WHERE a_expr							{ $$ = $2; }
@@ -18250,6 +18256,7 @@ reserved_keyword:
 			| ORDER
 			| PLACING
 			| PRIMARY
+			| QUALIFY
 			| REFERENCES
 			| RETURNING
 			| SELECT
diff --git a/src/backend/parser/parse_agg.c b/src/backend/parser/parse_agg.c
index 0ac8966e30f..deeb3584f97 100644
--- a/src/backend/parser/parse_agg.c
+++ b/src/backend/parser/parse_agg.c
@@ -402,6 +402,9 @@ check_agglevels_and_constraints(ParseState *pstate, Node *expr)
 		case EXPR_KIND_WHERE:
 			errkind = true;
 			break;
+		case EXPR_KIND_QUALIFY:
+			errkind = true;
+			break;
 		case EXPR_KIND_POLICY:
 			if (isAgg)
 				err = _("aggregate functions are not allowed in policy expressions");
@@ -878,8 +881,6 @@ transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc,
 		case EXPR_KIND_WHERE:
 			errkind = true;
 			break;
-		case EXPR_KIND_POLICY:
-			err = _("window functions are not allowed in policy expressions");
 			break;
 		case EXPR_KIND_HAVING:
 			errkind = true;
@@ -895,6 +896,8 @@ transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc,
 			err = _("window functions are not allowed in window definitions");
 			break;
 		case EXPR_KIND_SELECT_TARGET:
+		case EXPR_KIND_POLICY:
+		case EXPR_KIND_QUALIFY:
 			/* okay */
 			break;
 		case EXPR_KIND_INSERT_TARGET:
diff --git a/src/backend/parser/parse_clause.c b/src/backend/parser/parse_clause.c
index 9f20a70ce13..75f5ab727f9 100644
--- a/src/backend/parser/parse_clause.c
+++ b/src/backend/parser/parse_clause.c
@@ -1842,6 +1842,51 @@ setNamespaceLateralState(List *namespace, bool lateral_only, bool lateral_ok)
 	}
 }
 
+Node *
+transformQualifyClause(ParseState *pstate, List *targetlist, Node *qualify)
+{
+	Node	   *where;
+
+	if (qualify == NULL)
+		return NULL;
+
+	where = transformWhereClause(pstate,
+								 qualify,
+								 EXPR_KIND_QUALIFY,
+								 "QUALIFY");
+
+	/*
+	 *  Transform any Var referencing a WindowFunc into a real WindowFunc de
+	 * fact.
+	 */
+	if (IsA(where, OpExpr))
+	{
+		ListCell   *lc;
+		OpExpr	   *op = (OpExpr *) where;
+		List	   *newArgs = NIL;
+
+		foreach(lc, op->args)
+		{
+			Node	   *node = lfirst(lc);
+
+			if (IsA(node, Var))
+			{
+				Var		   *var = (Var *) node;
+				TargetEntry *tle = (TargetEntry *) lfirst(&targetlist->elements[var->varattno - 1]);
+
+
+				Assert(IsA(tle->expr, WindowFunc));
+				newArgs = lappend(newArgs, (Node *) tle->expr);
+			}
+			else
+				newArgs = lappend(newArgs, node);
+		}
+		op->args = newArgs;
+	}
+
+	return where;
+}
+
 
 /*
  * transformWhereClause -
@@ -2764,7 +2809,8 @@ transformSortClause(ParseState *pstate,
 List *
 transformWindowDefinitions(ParseState *pstate,
 						   List *windowdefs,
-						   List **targetlist)
+						   List **targetlist,
+						   Node *qualify)
 {
 	List	   *result = NIL;
 	Index		winref = 0;
@@ -2776,6 +2822,7 @@ transformWindowDefinitions(ParseState *pstate,
 		WindowClause *refwc = NULL;
 		List	   *partitionClause;
 		List	   *orderClause;
+		Node	   *qualifyClause;
 		Oid			rangeopfamily = InvalidOid;
 		Oid			rangeopcintype = InvalidOid;
 		WindowClause *wc;
@@ -2824,12 +2871,20 @@ transformWindowDefinitions(ParseState *pstate,
 											   EXPR_KIND_WINDOW_PARTITION,
 											   true /* force SQL99 rules */ );
 
+		/*
+		 * transform QUALIFY. targetlist is used find the window function
+		 * reference.
+		 *
+		 */
+		qualifyClause = transformQualifyClause(pstate, *targetlist, qualify);
+
 		/*
 		 * And prepare the new WindowClause.
 		 */
 		wc = makeNode(WindowClause);
 		wc->name = windef->name;
 		wc->refname = windef->refname;
+		wc->qualifyQual = qualifyClause;
 
 		/*
 		 * Per spec, a windowdef that references a previous one copies the
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index d66276801c6..d30661e8da5 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -575,6 +575,7 @@ transformColumnRef(ParseState *pstate, ColumnRef *cref)
 		case EXPR_KIND_COPY_WHERE:
 		case EXPR_KIND_GENERATED_COLUMN:
 		case EXPR_KIND_CYCLE_MARK:
+		case EXPR_KIND_QUALIFY:
 			/* okay */
 			break;
 
@@ -1794,6 +1795,7 @@ transformSubLink(ParseState *pstate, SubLink *sublink)
 		case EXPR_KIND_FROM_SUBSELECT:
 		case EXPR_KIND_FROM_FUNCTION:
 		case EXPR_KIND_WHERE:
+		case EXPR_KIND_QUALIFY:
 		case EXPR_KIND_POLICY:
 		case EXPR_KIND_HAVING:
 		case EXPR_KIND_FILTER:
@@ -3219,6 +3221,8 @@ ParseExprKindName(ParseExprKind exprKind)
 			return "GENERATED AS";
 		case EXPR_KIND_CYCLE_MARK:
 			return "CYCLE";
+		case EXPR_KIND_QUALIFY:
+			return "QUALIFY";
 
 			/*
 			 * There is intentionally no default: case here, so that the
diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c
index 583bbbf232f..4e202949bfd 100644
--- a/src/backend/parser/parse_func.c
+++ b/src/backend/parser/parse_func.c
@@ -2557,6 +2557,9 @@ check_srf_call_placement(ParseState *pstate, Node *last_srf, int location)
 		case EXPR_KIND_WHERE:
 			errkind = true;
 			break;
+		case EXPR_KIND_QUALIFY:
+			errkind = true;
+			break;
 		case EXPR_KIND_POLICY:
 			err = _("set-returning functions are not allowed in policy expressions");
 			break;
diff --git a/src/backend/parser/parse_relation.c b/src/backend/parser/parse_relation.c
index 04ecf64b1fc..d3bb9346dec 100644
--- a/src/backend/parser/parse_relation.c
+++ b/src/backend/parser/parse_relation.c
@@ -922,6 +922,24 @@ colNameToVar(ParseState *pstate, const char *colname, bool localonly,
 			newresult = scanNSItemForColumn(orig_pstate, nsitem, sublevels_up,
 											colname, location);
 
+			/*
+			 * If we are parsing a QUALIFY expression try to search the window
+			 * function reference on target list
+			 */
+			if (newresult == NULL && orig_pstate->p_hasWindowFuncs
+				&& pstate->p_expr_kind == EXPR_KIND_QUALIFY)
+			{
+				ListCell   *lc;
+
+				foreach(lc, pstate->p_targetList)
+				{
+					TargetEntry *tle = (TargetEntry *) lfirst(lc);
+
+					if (strcmp(tle->resname, colname) == 0)
+						newresult = (Node *) makeVarFromTargetEntry(OUTER_VAR, tle);
+				}
+			}
+
 			if (newresult)
 			{
 				if (result)
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 86a236bd58b..824b7238ca9 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -220,6 +220,8 @@ typedef struct Query
 
 	Node	   *havingQual;		/* qualifications applied to groups */
 
+	Node 		*qualifyQual;	/* qualifications applied to window functions */
+
 	List	   *windowClause;	/* a list of WindowClause's */
 
 	List	   *distinctClause; /* a list of SortGroupClause's */
@@ -1575,6 +1577,8 @@ typedef struct WindowClause
 	int			frameOptions;	/* frame_clause options, see WindowDef */
 	Node	   *startOffset;	/* expression for starting bound, if any */
 	Node	   *endOffset;		/* expression for ending bound, if any */
+	/* QUALIFY clause */
+	Node 	   *qualifyQual;
 	/* in_range function for startOffset */
 	Oid			startInRangeFunc pg_node_attr(query_jumble_ignore);
 	/* in_range function for endOffset */
@@ -2190,6 +2194,7 @@ typedef struct SelectStmt
 	List	   *targetList;		/* the target list (of ResTarget) */
 	List	   *fromClause;		/* the FROM clause */
 	Node	   *whereClause;	/* WHERE qualification */
+	Node       *qualifyClause;	/* QUALIFY qualification */
 	List	   *groupClause;	/* GROUP BY clauses */
 	bool		groupDistinct;	/* Is this GROUP BY DISTINCT? */
 	Node	   *havingClause;	/* HAVING conditional-expression */
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index a4af3f717a1..f7e267dd241 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -359,6 +359,7 @@ PG_KEYWORD("procedure", PROCEDURE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("procedures", PROCEDURES, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("program", PROGRAM, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("publication", PUBLICATION, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("qualify", QUALIFY, RESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("quote", QUOTE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("quotes", QUOTES, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("range", RANGE, UNRESERVED_KEYWORD, BARE_LABEL)
diff --git a/src/include/parser/parse_clause.h b/src/include/parser/parse_clause.h
index 3e9894926de..5580842ee33 100644
--- a/src/include/parser/parse_clause.h
+++ b/src/include/parser/parse_clause.h
@@ -20,6 +20,8 @@ extern void transformFromClause(ParseState *pstate, List *frmList);
 extern int	setTargetTable(ParseState *pstate, RangeVar *relation,
 						   bool inh, bool alsoSource, AclMode requiredPerms);
 
+
+extern Node * transformQualifyClause(ParseState *pstate, List *targetlist, Node *qualify);
 extern Node *transformWhereClause(ParseState *pstate, Node *clause,
 								  ParseExprKind exprKind, const char *constructName);
 extern Node *transformLimitClause(ParseState *pstate, Node *clause,
@@ -35,7 +37,8 @@ extern List *transformSortClause(ParseState *pstate, List *orderlist,
 
 extern List *transformWindowDefinitions(ParseState *pstate,
 										List *windowdefs,
-										List **targetlist);
+										List **targetlist,
+										Node *qualify);
 
 extern List *transformDistinctClause(ParseState *pstate,
 									 List **targetlist, List *sortClause, bool is_agg);
diff --git a/src/include/parser/parse_node.h b/src/include/parser/parse_node.h
index f7d07c84542..05f0f17ba3d 100644
--- a/src/include/parser/parse_node.h
+++ b/src/include/parser/parse_node.h
@@ -45,6 +45,7 @@ typedef enum ParseExprKind
 	EXPR_KIND_FROM_FUNCTION,	/* function in FROM clause */
 	EXPR_KIND_WHERE,			/* WHERE */
 	EXPR_KIND_HAVING,			/* HAVING */
+	EXPR_KIND_QUALIFY,			/* QUALIFY */
 	EXPR_KIND_FILTER,			/* FILTER */
 	EXPR_KIND_WINDOW_PARTITION, /* window definition PARTITION BY */
 	EXPR_KIND_WINDOW_ORDER,		/* window definition ORDER BY */
@@ -231,6 +232,8 @@ struct ParseState
 
 	Node	   *p_last_srf;		/* most recent set-returning func/op found */
 
+	List	   *p_targetList;		/* target list (of TargetEntry) */
+
 	/*
 	 * Optional hook functions for parser callbacks.  These are null unless
 	 * set up by the caller of make_parsestate.
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index b86b668f433..5c886c06141 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4537,6 +4537,131 @@ WHERE first_emp = 1 OR last_emp = 1;
  sales     |     4 |   4800 | 08-08-2007  |         3 |        1
 (6 rows)
 
+-- Test QUALIFY clause
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY RANK() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2;
+  depname  | empno | salary | enroll_date | rank 
+-----------+-------+--------+-------------+------
+ develop   |     8 |   6000 | 10-01-2006  |    1
+ develop   |    10 |   5200 | 08-01-2007  |    2
+ develop   |    11 |   5200 | 08-15-2007  |    2
+ personnel |     2 |   3900 | 12-23-2006  |    1
+ personnel |     5 |   3500 | 12-10-2007  |    2
+ sales     |     1 |   5000 | 10-01-2006  |    1
+ sales     |     4 |   4800 | 08-08-2007  |    2
+ sales     |     3 |   4800 | 08-01-2007  |    2
+(8 rows)
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date DESC)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date DESC) = 1;
+  depname  | empno | salary | enroll_date | row_number 
+-----------+-------+--------+-------------+------------
+ develop   |     7 |   4200 | 01-01-2008  |          1
+ personnel |     5 |   3500 | 12-10-2007  |          1
+ sales     |     4 |   4800 | 08-08-2007  |          1
+(3 rows)
+
+SELECT *,
+       AVG(salary) OVER (PARTITION BY depname) AS avg_salary
+FROM empsalary
+QUALIFY salary > avg_salary;
+  depname  | empno | salary | enroll_date |      avg_salary       
+-----------+-------+--------+-------------+-----------------------
+ develop   |    11 |   5200 | 08-15-2007  | 5020.0000000000000000
+ develop   |     8 |   6000 | 10-01-2006  | 5020.0000000000000000
+ develop   |    10 |   5200 | 08-01-2007  | 5020.0000000000000000
+ personnel |     2 |   3900 | 12-23-2006  | 3700.0000000000000000
+ sales     |     1 |   5000 | 10-01-2006  | 4866.6666666666666667
+(5 rows)
+
+SELECT *,
+       COUNT(*) OVER (PARTITION BY depname, salary)
+FROM empsalary
+QUALIFY COUNT(*) OVER (PARTITION BY depname, salary) = 1;
+  depname  | empno | salary | enroll_date | count 
+-----------+-------+--------+-------------+-------
+ develop   |     7 |   4200 | 01-01-2008  |     1
+ develop   |     9 |   4500 | 01-01-2008  |     1
+ develop   |     8 |   6000 | 10-01-2006  |     1
+ personnel |     5 |   3500 | 12-10-2007  |     1
+ personnel |     2 |   3900 | 12-23-2006  |     1
+ sales     |     1 |   5000 | 10-01-2006  |     1
+(6 rows)
+
+SELECT *,
+       RANK() OVER (ORDER BY salary DESC) as rank
+FROM empsalary
+QUALIFY rank = 2;
+ depname | empno | salary | enroll_date | rank 
+---------+-------+--------+-------------+------
+ develop |    10 |   5200 | 08-01-2007  |    2
+ develop |    11 |   5200 | 08-15-2007  |    2
+(2 rows)
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date) <= 3;
+  depname  | empno | salary | enroll_date | row_number 
+-----------+-------+--------+-------------+------------
+ develop   |     8 |   6000 | 10-01-2006  |          1
+ develop   |    10 |   5200 | 08-01-2007  |          2
+ develop   |    11 |   5200 | 08-15-2007  |          3
+ personnel |     2 |   3900 | 12-23-2006  |          1
+ personnel |     5 |   3500 | 12-10-2007  |          2
+ sales     |     1 |   5000 | 10-01-2006  |          1
+ sales     |     3 |   4800 | 08-01-2007  |          2
+ sales     |     4 |   4800 | 08-08-2007  |          3
+(8 rows)
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2
+ORDER BY depname, salary DESC;
+  depname  | empno | salary | enroll_date | row_number 
+-----------+-------+--------+-------------+------------
+ develop   |     8 |   6000 | 10-01-2006  |          1
+ develop   |    10 |   5200 | 08-01-2007  |          2
+ personnel |     2 |   3900 | 12-23-2006  |          1
+ personnel |     5 |   3500 | 12-10-2007  |          2
+ sales     |     1 |   5000 | 10-01-2006  |          1
+ sales     |     4 |   4800 | 08-08-2007  |          2
+(6 rows)
+
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY
+    RANK() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2
+    AND enroll_date > DATE '2007-01-01';
+  depname  | empno | salary | enroll_date | rank 
+-----------+-------+--------+-------------+------
+ develop   |    10 |   5200 | 08-01-2007  |    2
+ develop   |    11 |   5200 | 08-15-2007  |    2
+ personnel |     5 |   3500 | 12-10-2007  |    2
+ sales     |     4 |   4800 | 08-08-2007  |    2
+ sales     |     3 |   4800 | 08-01-2007  |    2
+(5 rows)
+
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY
+    RANK() OVER (PARTITION BY depname ORDER BY salary DESC) = 1
+    OR salary < 4000;
+  depname  | empno | salary | enroll_date | rank 
+-----------+-------+--------+-------------+------
+ develop   |     8 |   6000 | 10-01-2006  |    1
+ personnel |     2 |   3900 | 12-23-2006  |    1
+ personnel |     5 |   3500 | 12-10-2007  |    2
+ sales     |     1 |   5000 | 10-01-2006  |    1
+(4 rows)
+
 -- cleanup
 DROP TABLE empsalary;
 -- test user-defined window function with named args and default args
diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql
index 02f105f070e..4f376d1b459 100644
--- a/src/test/regress/sql/window.sql
+++ b/src/test/regress/sql/window.sql
@@ -1522,6 +1522,57 @@ SELECT * FROM
    FROM empsalary) emp
 WHERE first_emp = 1 OR last_emp = 1;
 
+-- Test QUALIFY clause
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY RANK() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2;
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date DESC)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date DESC) = 1;
+
+SELECT *,
+       AVG(salary) OVER (PARTITION BY depname) AS avg_salary
+FROM empsalary
+QUALIFY salary > avg_salary;
+
+SELECT *,
+       COUNT(*) OVER (PARTITION BY depname, salary)
+FROM empsalary
+QUALIFY COUNT(*) OVER (PARTITION BY depname, salary) = 1;
+
+SELECT *,
+       RANK() OVER (ORDER BY salary DESC) as rank
+FROM empsalary
+QUALIFY rank = 2;
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date) <= 3;
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2
+ORDER BY depname, salary DESC;
+
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY
+    RANK() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2
+    AND enroll_date > DATE '2007-01-01';
+
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY
+    RANK() OVER (PARTITION BY depname ORDER BY salary DESC) = 1
+    OR salary < 4000;
+
 -- cleanup
 DROP TABLE empsalary;
 
-- 
2.39.5 (Apple Git-154)

#2Mike Artz
michaeleartz@gmail.com
In reply to: Matheus Alcantara (#1)
Re: Proposal: QUALIFY clause

Many times I have thought it would be nice if there was a QUALIFY clause in
Postgres!

Just would like to add that including your list, Teradata, Redshift, SAP
HANA, HP Vertica, and Trino all support the QUALIFY clause.

Also it seems Postgres would be the first leading RDBMS - meaning like
traditional, multipurpose RDMBS - to support QUALIFY, which would be pretty
cool.

On Mon, Jul 21, 2025 at 7:47 AM Matheus Alcantara <matheusssilv97@gmail.com>
wrote:

Show quoted text

Hi all,

I'm sending a proof-of-concept patch to add support for the QUALIFY
clause in Postgres. This feature allows filtering rows after window
functions are computed, using a syntax similar to the WHERE or HAVING
clauses.

The idea for this came from a discussion and suggestion by Peter
Eisentraut (thanks, Peter!).

The `QUALIFY` clause is not part of the SQL standard, but it is
implemented by some major DBMSs, including Snowflake [1], BigQuery
[2] and DuckDB [3].

The goal is to provide a more ergonomic way to filter on window function
results without needing to nest subqueries or CTEs.

Simple example (see window.sql for more):

SELECT depname,
empno,
salary,
RANK() OVER (PARTITION BY depname ORDER BY salary DESC) AS rnk
FROM empsalary
QUALIFY rnk = 1;

Please note that this is a proof-of-concept patch, I’m still working on
determining the best locations in the code to implement each part of the
logic for QUALIFY. I'm just sending this WIP to collect feedback and then
continue to work on the feature. Additionally, the current patch does not
handle yet expressions using AND/OR when referencing multiple window
function aliases (e.g., QUALIFY rnk = 1 AND rnk2 = 2).

Thoughts?

[1] https://docs.snowflake.com/en/sql-reference/constructs/qualify
[2]
https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#qualify_clause
[3] https://duckdb.org/docs/stable/sql/query_syntax/qualify.html

--
Matheus Alcantara

#3Isaac Morland
isaac.morland@gmail.com
In reply to: Mike Artz (#2)
Re: Proposal: QUALIFY clause

On Mon, 21 Jul 2025 at 10:19, Mike Artz <michaeleartz@gmail.com> wrote:

Many times I have thought it would be nice if there was a QUALIFY clause
in Postgres!

Just would like to add that including your list, Teradata, Redshift, SAP
HANA, HP Vertica, and Trino all support the QUALIFY clause.

Also it seems Postgres would be the first leading RDBMS - meaning like
traditional, multipurpose RDMBS - to support QUALIFY, which would be pretty
cool.

Is this different from using the window functions in a subquery and then
applying a WHERE clause on the outer query?

SELECT … FROM (SELECT … [including window functions] FROM …) WHERE [stuff
that would be in QUALIFY]

I'll be honest, I'm skeptical that we need another keyword that basically
means “WHERE, but applied at a different point in the query processing”.
I'm not even convinced that HAVING was a good idea (although obviously I
would not propose removal).

#4Tom Lane
tgl@sss.pgh.pa.us
In reply to: Isaac Morland (#3)
Re: Proposal: QUALIFY clause

Isaac Morland <isaac.morland@gmail.com> writes:

I'll be honest, I'm skeptical that we need another keyword that basically
means “WHERE, but applied at a different point in the query processing”.

That was my reaction too. I'm especially skeptical that getting out
front of the SQL standards committee is a good thing to do. If and
when this shows up in the standard, then sure.

regards, tom lane

#5Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Isaac Morland (#3)
Re: Proposal: QUALIFY clause

On Mon Jul 21, 2025 at 11:29 AM -03, Isaac Morland wrote:

Is this different from using the window functions in a subquery and then
applying a WHERE clause on the outer query?

SELECT … FROM (SELECT … [including window functions] FROM …) WHERE [stuff
that would be in QUALIFY]

I'll be honest, I'm skeptical that we need another keyword that basically
means “WHERE, but applied at a different point in the query processing”.
I'm not even convinced that HAVING was a good idea (although obviously I
would not propose removal).

Thanks for sharing your thoughts!

You're right — semantically, using QUALIFY is equivalent to wrapping the
query in a subquery and applying a WHERE clause to the result. The main
motivation here is to provide a more ergonomic and readable syntax.

While I understand the hesitation around introducing another keyword
that effectively acts like WHERE at a different stage, I believe QUALIFY
improves clarity in many use cases, by avoiding the boilerplate and
visual noise of nested subqueries making it easier to write and reason
about.

--
Matheus Alcantara

#6Tom Lane
tgl@sss.pgh.pa.us
In reply to: Matheus Alcantara (#5)
Re: Proposal: QUALIFY clause

"Matheus Alcantara" <matheusssilv97@gmail.com> writes:

You're right — semantically, using QUALIFY is equivalent to wrapping the
query in a subquery and applying a WHERE clause to the result. The main
motivation here is to provide a more ergonomic and readable syntax.

While I understand the hesitation around introducing another keyword
that effectively acts like WHERE at a different stage, I believe QUALIFY
improves clarity in many use cases, by avoiding the boilerplate and
visual noise of nested subqueries making it easier to write and reason
about.

There are concrete reasons not to do this until/unless it becomes
standardized:

* If the syntax is like WHERE, there will be no way to do it without
making QUALIFY a fully-reserved word. That will inevitably break
more than zero applications. It's a lot easier to justify that
sort of breakage if we can say "QUALIFY is reserved according to
SQL:20xx, so don't blame us".

* I'm not exactly convinced that the committee would standardize
it just like this. For one thing, QUALIFY is not even the right
part of speech: it's a verb, and thus more fit to be a primary
statement keyword. What you need here is an adverb (I think ...
been a long time since high school English, but my dictionary
says WHERE is an adverb). Maybe they'd be persuaded to do what
the existing implementations did, but I wouldn't be at all surprised
if they choose a different keyword.

regards, tom lane

#7Thom Brown
thom@linux.com
In reply to: Tom Lane (#6)
Re: Proposal: QUALIFY clause

On Mon, 21 Jul 2025, 18:31 Tom Lane, <tgl@sss.pgh.pa.us> wrote:

"Matheus Alcantara" <matheusssilv97@gmail.com> writes:

You're right — semantically, using QUALIFY is equivalent to wrapping the
query in a subquery and applying a WHERE clause to the result. The main
motivation here is to provide a more ergonomic and readable syntax.

While I understand the hesitation around introducing another keyword
that effectively acts like WHERE at a different stage, I believe QUALIFY
improves clarity in many use cases, by avoiding the boilerplate and
visual noise of nested subqueries making it easier to write and reason
about.

There are concrete reasons not to do this until/unless it becomes
standardized:

* If the syntax is like WHERE, there will be no way to do it without
making QUALIFY a fully-reserved word. That will inevitably break
more than zero applications. It's a lot easier to justify that
sort of breakage if we can say "QUALIFY is reserved according to
SQL:20xx, so don't blame us".

* I'm not exactly convinced that the committee would standardize
it just like this. For one thing, QUALIFY is not even the right
part of speech: it's a verb, and thus more fit to be a primary
statement keyword. What you need here is an adverb (I think ...
been a long time since high school English, but my dictionary
says WHERE is an adverb). Maybe they'd be persuaded to do what
the existing implementations did, but I wouldn't be at all surprised
if they choose a different keyword.

I know we're not bikeshedding, but the word REFINE might be more
appropriate.

Thom

Show quoted text
#8Marko Tiikkaja
marko@joh.to
In reply to: Thom Brown (#7)
Re: Proposal: QUALIFY clause

On Mon, Jul 21, 2025 at 9:32 PM Thom Brown <thom@linux.com> wrote:

* I'm not exactly convinced that the committee would standardize
it just like this. For one thing, QUALIFY is not even the right
part of speech: it's a verb, and thus more fit to be a primary
statement keyword. What you need here is an adverb (I think ...
been a long time since high school English, but my dictionary
says WHERE is an adverb). Maybe they'd be persuaded to do what
the existing implementations did, but I wouldn't be at all surprised
if they choose a different keyword.

I know we're not bikeshedding, but the word REFINE might be more appropriate.

I vote for DONTGIMMEDAT.

.m

#9Vik Fearing
vik@postgresfriends.org
In reply to: Tom Lane (#4)
Re: Proposal: QUALIFY clause

On 21/07/2025 16:41, Tom Lane wrote:

Isaac Morland <isaac.morland@gmail.com> writes:

I'll be honest, I'm skeptical that we need another keyword that basically
means “WHERE, but applied at a different point in the query processing”.

That was my reaction too. I'm especially skeptical that getting out
front of the SQL standards committee is a good thing to do. If and
when this shows up in the standard, then sure.

It's "when", not "if".  I submitted a paper for this to the committee
two years ago, but it was just a discussion paper and not an actual
change proposal.  I have recently revived that paper so hopefully it
will be accepted within the next year.  I would even like to push so
that we have it in 19.

--

Vik Fearing

#10Vik Fearing
vik@postgresfriends.org
In reply to: Matheus Alcantara (#1)
Re: Proposal: QUALIFY clause

On 21/07/2025 14:47, Matheus Alcantara wrote:

Hi all,

I'm sending a proof-of-concept patch to add support for the QUALIFY
clause in Postgres. This feature allows filtering rows after window
functions are computed, using a syntax similar to the WHERE or HAVING
clauses.

I took a very brief look at this, and I think your grammar is wrong. 
The QUALIFY clause should go after the WINDOW clause, just like
FROM/WHERE and GROUP BY/HAVING.

That is what I am proposing to the standards committee, and I already
have some buy-in for that.

--

Vik Fearing

#11Vik Fearing
vik@postgresfriends.org
In reply to: Tom Lane (#6)
Re: Proposal: QUALIFY clause

On 21/07/2025 19:30, Tom Lane wrote:

"Matheus Alcantara" <matheusssilv97@gmail.com> writes:

You're right — semantically, using QUALIFY is equivalent to wrapping the
query in a subquery and applying a WHERE clause to the result. The main
motivation here is to provide a more ergonomic and readable syntax.
While I understand the hesitation around introducing another keyword
that effectively acts like WHERE at a different stage, I believe QUALIFY
improves clarity in many use cases, by avoiding the boilerplate and
visual noise of nested subqueries making it easier to write and reason
about.

There are concrete reasons not to do this until/unless it becomes
standardized:

* If the syntax is like WHERE, there will be no way to do it without
making QUALIFY a fully-reserved word. That will inevitably break
more than zero applications. It's a lot easier to justify that
sort of breakage if we can say "QUALIFY is reserved according to
SQL:20xx, so don't blame us".

Yes, it will need to be reserved.

* I'm not exactly convinced that the committee would standardize
it just like this. For one thing, QUALIFY is not even the right
part of speech: it's a verb, and thus more fit to be a primary
statement keyword. What you need here is an adverb (I think ...
been a long time since high school English, but my dictionary
says WHERE is an adverb). Maybe they'd be persuaded to do what
the existing implementations did, but I wouldn't be at all surprised
if they choose a different keyword.

I am pretty sure that the keyword will be QUALIFY. There are just too
many existing implementations for the standard to go against them all. 
(Also, another rdbms just implemented it that way in their upcoming
product.)

I agree that we should hold back until the standard accepts it, but
having a working patch ready to go seems like a good idea.

--

Vik Fearing

#12Nico Williams
nico@cryptonector.com
In reply to: Vik Fearing (#11)
Re: Proposal: QUALIFY clause

On Mon, Jul 21, 2025 at 10:26:51PM +0200, Vik Fearing wrote:

On 21/07/2025 19:30, Tom Lane wrote:

* I'm not exactly convinced that the committee would standardize
it just like this. For one thing, QUALIFY is not even the right
part of speech: it's a verb, and thus more fit to be a primary
statement keyword. What you need here is an adverb (I think ...
been a long time since high school English, but my dictionary
says WHERE is an adverb). Maybe they'd be persuaded to do what
the existing implementations did, but I wouldn't be at all surprised
if they choose a different keyword.

Or a gerund, which is what HAVING is. Or a conjugated verb or something
like QUALIFIED BY, though really "qualif*" seems just wrong. This is
just another name for a WHERE that, like HAVING is paired with some
other language feature (like GROUP BY) and applies to that clause. I
don't have a better keyword(s) to offer, just sadness.

I am pretty sure that the keyword will be QUALIFY. There are just too many
existing implementations for the standard to go against them all.  (Also,
another rdbms just implemented it that way in their upcoming product.)

Bummer.

#13Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Vik Fearing (#10)
1 attachment(s)
Re: Proposal: QUALIFY clause

On Mon Jul 21, 2025 at 5:23 PM -03, Vik Fearing wrote:

On 21/07/2025 14:47, Matheus Alcantara wrote:

Hi all,

I'm sending a proof-of-concept patch to add support for the QUALIFY
clause in Postgres. This feature allows filtering rows after window
functions are computed, using a syntax similar to the WHERE or HAVING
clauses.

I took a very brief look at this, and I think your grammar is wrong. 
The QUALIFY clause should go after the WINDOW clause, just like
FROM/WHERE and GROUP BY/HAVING.

That is what I am proposing to the standards committee, and I already
have some buy-in for that.

Thank you for the brief review and for the comments!

I'm not sure if I fully understand but please see the new attached
version.

Thanks,

--
Matheus Alcantara

Attachments:

v1-0001-QUALIFY-clause.patchtext/plain; charset=utf-8; name=v1-0001-QUALIFY-clause.patchDownload
From 28a4d49c27ba039518d3272aa35cb0176bab7750 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Wed, 4 Jun 2025 15:56:59 -0300
Subject: [PATCH v1] QUALIFY clause

---
 src/backend/optimizer/plan/planner.c |   5 ++
 src/backend/parser/analyze.c         |   8 +-
 src/backend/parser/gram.y            |  13 ++-
 src/backend/parser/parse_agg.c       |   7 +-
 src/backend/parser/parse_clause.c    |  57 +++++++++++-
 src/backend/parser/parse_expr.c      |   4 +
 src/backend/parser/parse_func.c      |   3 +
 src/backend/parser/parse_relation.c  |  18 ++++
 src/include/nodes/parsenodes.h       |   5 ++
 src/include/parser/kwlist.h          |   1 +
 src/include/parser/parse_clause.h    |   5 +-
 src/include/parser/parse_node.h      |   3 +
 src/test/regress/expected/window.out | 125 +++++++++++++++++++++++++++
 src/test/regress/sql/window.sql      |  51 +++++++++++
 14 files changed, 296 insertions(+), 9 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 549aedcfa99..1a8fb387e47 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -4710,6 +4710,11 @@ create_one_window_path(PlannerInfo *root,
 				if (!topwindow)
 					topqual = lappend(topqual, opexpr);
 			}
+
+			/*  Add QUALIFY qual */
+			if (wc->qualifyQual != NULL)
+				topqual = lappend(topqual, (Expr *) wc->qualifyQual);
+
 		}
 
 		path = (Path *)
diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c
index 34f7c17f576..dd27fd3730b 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -1481,9 +1481,12 @@ transformSelectStmt(ParseState *pstate, SelectStmt *stmt)
 	qry->limitOption = stmt->limitOption;
 
 	/* transform window clauses after we have seen all window functions */
+	pstate->p_targetList = qry->targetList;
 	qry->windowClause = transformWindowDefinitions(pstate,
 												   pstate->p_windowdefs,
-												   &qry->targetList);
+												   &qry->targetList,
+												   stmt->qualifyClause);
+
 
 	/* resolve any still-unresolved output columns as being type text */
 	if (pstate->p_resolve_unknowns)
@@ -2975,7 +2978,8 @@ transformPLAssignStmt(ParseState *pstate, PLAssignStmt *stmt)
 	/* transform window clauses after we have seen all window functions */
 	qry->windowClause = transformWindowDefinitions(pstate,
 												   pstate->p_windowdefs,
-												   &qry->targetList);
+												   &qry->targetList,
+												   NULL);	/* FIXME(matheus) */
 
 	qry->rtable = pstate->p_rtable;
 	qry->rteperminfos = pstate->p_rteperminfos;
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 73345bb3c70..117f20c8fa2 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -520,7 +520,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 %type <node>	TableElement TypedTableElement ConstraintElem DomainConstraintElem TableFuncElement
 %type <node>	columnDef columnOptions optionalPeriodName
 %type <defelt>	def_elem reloption_elem old_aggr_elem operator_def_elem
-%type <node>	def_arg columnElem where_clause where_or_current_clause
+%type <node>	def_arg columnElem where_clause qualify_clause where_or_current_clause
 				a_expr b_expr c_expr AexprConst indirection_el opt_slice_bound
 				columnref having_clause func_table xmltable array_expr
 				OptWhereClause operator_def_arg
@@ -760,7 +760,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	POSITION PRECEDING PRECISION PRESERVE PREPARE PREPARED PRIMARY
 	PRIOR PRIVILEGES PROCEDURAL PROCEDURE PROCEDURES PROGRAM PUBLICATION
 
-	QUOTE QUOTES
+	QUALIFY QUOTE QUOTES
 
 	RANGE READ REAL REASSIGN RECURSIVE REF_P REFERENCES REFERENCING
 	REFRESH REINDEX RELATIVE_P RELEASE RENAME REPEATABLE REPLACE REPLICA
@@ -12994,7 +12994,7 @@ select_clause:
 simple_select:
 			SELECT opt_all_clause opt_target_list
 			into_clause from_clause where_clause
-			group_clause having_clause window_clause
+			group_clause having_clause window_clause qualify_clause
 				{
 					SelectStmt *n = makeNode(SelectStmt);
 
@@ -13006,6 +13006,7 @@ simple_select:
 					n->groupDistinct = ($7)->distinct;
 					n->havingClause = $8;
 					n->windowClause = $9;
+					n->qualifyClause = $10;
 					$$ = (Node *) n;
 				}
 			| SELECT distinct_clause target_list
@@ -14135,6 +14136,11 @@ where_clause:
 			| /*EMPTY*/								{ $$ = NULL; }
 		;
 
+qualify_clause:
+			QUALIFY a_expr							{ $$ = $2; }
+			| /*EMPTY*/								{ $$ = NULL; }
+		;
+
 /* variant for UPDATE and DELETE */
 where_or_current_clause:
 			WHERE a_expr							{ $$ = $2; }
@@ -18250,6 +18256,7 @@ reserved_keyword:
 			| ORDER
 			| PLACING
 			| PRIMARY
+			| QUALIFY
 			| REFERENCES
 			| RETURNING
 			| SELECT
diff --git a/src/backend/parser/parse_agg.c b/src/backend/parser/parse_agg.c
index 0ac8966e30f..deeb3584f97 100644
--- a/src/backend/parser/parse_agg.c
+++ b/src/backend/parser/parse_agg.c
@@ -402,6 +402,9 @@ check_agglevels_and_constraints(ParseState *pstate, Node *expr)
 		case EXPR_KIND_WHERE:
 			errkind = true;
 			break;
+		case EXPR_KIND_QUALIFY:
+			errkind = true;
+			break;
 		case EXPR_KIND_POLICY:
 			if (isAgg)
 				err = _("aggregate functions are not allowed in policy expressions");
@@ -878,8 +881,6 @@ transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc,
 		case EXPR_KIND_WHERE:
 			errkind = true;
 			break;
-		case EXPR_KIND_POLICY:
-			err = _("window functions are not allowed in policy expressions");
 			break;
 		case EXPR_KIND_HAVING:
 			errkind = true;
@@ -895,6 +896,8 @@ transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc,
 			err = _("window functions are not allowed in window definitions");
 			break;
 		case EXPR_KIND_SELECT_TARGET:
+		case EXPR_KIND_POLICY:
+		case EXPR_KIND_QUALIFY:
 			/* okay */
 			break;
 		case EXPR_KIND_INSERT_TARGET:
diff --git a/src/backend/parser/parse_clause.c b/src/backend/parser/parse_clause.c
index 9f20a70ce13..75f5ab727f9 100644
--- a/src/backend/parser/parse_clause.c
+++ b/src/backend/parser/parse_clause.c
@@ -1842,6 +1842,51 @@ setNamespaceLateralState(List *namespace, bool lateral_only, bool lateral_ok)
 	}
 }
 
+Node *
+transformQualifyClause(ParseState *pstate, List *targetlist, Node *qualify)
+{
+	Node	   *where;
+
+	if (qualify == NULL)
+		return NULL;
+
+	where = transformWhereClause(pstate,
+								 qualify,
+								 EXPR_KIND_QUALIFY,
+								 "QUALIFY");
+
+	/*
+	 *  Transform any Var referencing a WindowFunc into a real WindowFunc de
+	 * fact.
+	 */
+	if (IsA(where, OpExpr))
+	{
+		ListCell   *lc;
+		OpExpr	   *op = (OpExpr *) where;
+		List	   *newArgs = NIL;
+
+		foreach(lc, op->args)
+		{
+			Node	   *node = lfirst(lc);
+
+			if (IsA(node, Var))
+			{
+				Var		   *var = (Var *) node;
+				TargetEntry *tle = (TargetEntry *) lfirst(&targetlist->elements[var->varattno - 1]);
+
+
+				Assert(IsA(tle->expr, WindowFunc));
+				newArgs = lappend(newArgs, (Node *) tle->expr);
+			}
+			else
+				newArgs = lappend(newArgs, node);
+		}
+		op->args = newArgs;
+	}
+
+	return where;
+}
+
 
 /*
  * transformWhereClause -
@@ -2764,7 +2809,8 @@ transformSortClause(ParseState *pstate,
 List *
 transformWindowDefinitions(ParseState *pstate,
 						   List *windowdefs,
-						   List **targetlist)
+						   List **targetlist,
+						   Node *qualify)
 {
 	List	   *result = NIL;
 	Index		winref = 0;
@@ -2776,6 +2822,7 @@ transformWindowDefinitions(ParseState *pstate,
 		WindowClause *refwc = NULL;
 		List	   *partitionClause;
 		List	   *orderClause;
+		Node	   *qualifyClause;
 		Oid			rangeopfamily = InvalidOid;
 		Oid			rangeopcintype = InvalidOid;
 		WindowClause *wc;
@@ -2824,12 +2871,20 @@ transformWindowDefinitions(ParseState *pstate,
 											   EXPR_KIND_WINDOW_PARTITION,
 											   true /* force SQL99 rules */ );
 
+		/*
+		 * transform QUALIFY. targetlist is used find the window function
+		 * reference.
+		 *
+		 */
+		qualifyClause = transformQualifyClause(pstate, *targetlist, qualify);
+
 		/*
 		 * And prepare the new WindowClause.
 		 */
 		wc = makeNode(WindowClause);
 		wc->name = windef->name;
 		wc->refname = windef->refname;
+		wc->qualifyQual = qualifyClause;
 
 		/*
 		 * Per spec, a windowdef that references a previous one copies the
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index d66276801c6..d30661e8da5 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -575,6 +575,7 @@ transformColumnRef(ParseState *pstate, ColumnRef *cref)
 		case EXPR_KIND_COPY_WHERE:
 		case EXPR_KIND_GENERATED_COLUMN:
 		case EXPR_KIND_CYCLE_MARK:
+		case EXPR_KIND_QUALIFY:
 			/* okay */
 			break;
 
@@ -1794,6 +1795,7 @@ transformSubLink(ParseState *pstate, SubLink *sublink)
 		case EXPR_KIND_FROM_SUBSELECT:
 		case EXPR_KIND_FROM_FUNCTION:
 		case EXPR_KIND_WHERE:
+		case EXPR_KIND_QUALIFY:
 		case EXPR_KIND_POLICY:
 		case EXPR_KIND_HAVING:
 		case EXPR_KIND_FILTER:
@@ -3219,6 +3221,8 @@ ParseExprKindName(ParseExprKind exprKind)
 			return "GENERATED AS";
 		case EXPR_KIND_CYCLE_MARK:
 			return "CYCLE";
+		case EXPR_KIND_QUALIFY:
+			return "QUALIFY";
 
 			/*
 			 * There is intentionally no default: case here, so that the
diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c
index 583bbbf232f..4e202949bfd 100644
--- a/src/backend/parser/parse_func.c
+++ b/src/backend/parser/parse_func.c
@@ -2557,6 +2557,9 @@ check_srf_call_placement(ParseState *pstate, Node *last_srf, int location)
 		case EXPR_KIND_WHERE:
 			errkind = true;
 			break;
+		case EXPR_KIND_QUALIFY:
+			errkind = true;
+			break;
 		case EXPR_KIND_POLICY:
 			err = _("set-returning functions are not allowed in policy expressions");
 			break;
diff --git a/src/backend/parser/parse_relation.c b/src/backend/parser/parse_relation.c
index 04ecf64b1fc..d3bb9346dec 100644
--- a/src/backend/parser/parse_relation.c
+++ b/src/backend/parser/parse_relation.c
@@ -922,6 +922,24 @@ colNameToVar(ParseState *pstate, const char *colname, bool localonly,
 			newresult = scanNSItemForColumn(orig_pstate, nsitem, sublevels_up,
 											colname, location);
 
+			/*
+			 * If we are parsing a QUALIFY expression try to search the window
+			 * function reference on target list
+			 */
+			if (newresult == NULL && orig_pstate->p_hasWindowFuncs
+				&& pstate->p_expr_kind == EXPR_KIND_QUALIFY)
+			{
+				ListCell   *lc;
+
+				foreach(lc, pstate->p_targetList)
+				{
+					TargetEntry *tle = (TargetEntry *) lfirst(lc);
+
+					if (strcmp(tle->resname, colname) == 0)
+						newresult = (Node *) makeVarFromTargetEntry(OUTER_VAR, tle);
+				}
+			}
+
 			if (newresult)
 			{
 				if (result)
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 86a236bd58b..824b7238ca9 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -220,6 +220,8 @@ typedef struct Query
 
 	Node	   *havingQual;		/* qualifications applied to groups */
 
+	Node 		*qualifyQual;	/* qualifications applied to window functions */
+
 	List	   *windowClause;	/* a list of WindowClause's */
 
 	List	   *distinctClause; /* a list of SortGroupClause's */
@@ -1575,6 +1577,8 @@ typedef struct WindowClause
 	int			frameOptions;	/* frame_clause options, see WindowDef */
 	Node	   *startOffset;	/* expression for starting bound, if any */
 	Node	   *endOffset;		/* expression for ending bound, if any */
+	/* QUALIFY clause */
+	Node 	   *qualifyQual;
 	/* in_range function for startOffset */
 	Oid			startInRangeFunc pg_node_attr(query_jumble_ignore);
 	/* in_range function for endOffset */
@@ -2190,6 +2194,7 @@ typedef struct SelectStmt
 	List	   *targetList;		/* the target list (of ResTarget) */
 	List	   *fromClause;		/* the FROM clause */
 	Node	   *whereClause;	/* WHERE qualification */
+	Node       *qualifyClause;	/* QUALIFY qualification */
 	List	   *groupClause;	/* GROUP BY clauses */
 	bool		groupDistinct;	/* Is this GROUP BY DISTINCT? */
 	Node	   *havingClause;	/* HAVING conditional-expression */
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index a4af3f717a1..f7e267dd241 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -359,6 +359,7 @@ PG_KEYWORD("procedure", PROCEDURE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("procedures", PROCEDURES, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("program", PROGRAM, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("publication", PUBLICATION, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("qualify", QUALIFY, RESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("quote", QUOTE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("quotes", QUOTES, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("range", RANGE, UNRESERVED_KEYWORD, BARE_LABEL)
diff --git a/src/include/parser/parse_clause.h b/src/include/parser/parse_clause.h
index 3e9894926de..5580842ee33 100644
--- a/src/include/parser/parse_clause.h
+++ b/src/include/parser/parse_clause.h
@@ -20,6 +20,8 @@ extern void transformFromClause(ParseState *pstate, List *frmList);
 extern int	setTargetTable(ParseState *pstate, RangeVar *relation,
 						   bool inh, bool alsoSource, AclMode requiredPerms);
 
+
+extern Node * transformQualifyClause(ParseState *pstate, List *targetlist, Node *qualify);
 extern Node *transformWhereClause(ParseState *pstate, Node *clause,
 								  ParseExprKind exprKind, const char *constructName);
 extern Node *transformLimitClause(ParseState *pstate, Node *clause,
@@ -35,7 +37,8 @@ extern List *transformSortClause(ParseState *pstate, List *orderlist,
 
 extern List *transformWindowDefinitions(ParseState *pstate,
 										List *windowdefs,
-										List **targetlist);
+										List **targetlist,
+										Node *qualify);
 
 extern List *transformDistinctClause(ParseState *pstate,
 									 List **targetlist, List *sortClause, bool is_agg);
diff --git a/src/include/parser/parse_node.h b/src/include/parser/parse_node.h
index f7d07c84542..05f0f17ba3d 100644
--- a/src/include/parser/parse_node.h
+++ b/src/include/parser/parse_node.h
@@ -45,6 +45,7 @@ typedef enum ParseExprKind
 	EXPR_KIND_FROM_FUNCTION,	/* function in FROM clause */
 	EXPR_KIND_WHERE,			/* WHERE */
 	EXPR_KIND_HAVING,			/* HAVING */
+	EXPR_KIND_QUALIFY,			/* QUALIFY */
 	EXPR_KIND_FILTER,			/* FILTER */
 	EXPR_KIND_WINDOW_PARTITION, /* window definition PARTITION BY */
 	EXPR_KIND_WINDOW_ORDER,		/* window definition ORDER BY */
@@ -231,6 +232,8 @@ struct ParseState
 
 	Node	   *p_last_srf;		/* most recent set-returning func/op found */
 
+	List	   *p_targetList;		/* target list (of TargetEntry) */
+
 	/*
 	 * Optional hook functions for parser callbacks.  These are null unless
 	 * set up by the caller of make_parsestate.
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index b86b668f433..5c886c06141 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4537,6 +4537,131 @@ WHERE first_emp = 1 OR last_emp = 1;
  sales     |     4 |   4800 | 08-08-2007  |         3 |        1
 (6 rows)
 
+-- Test QUALIFY clause
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY RANK() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2;
+  depname  | empno | salary | enroll_date | rank 
+-----------+-------+--------+-------------+------
+ develop   |     8 |   6000 | 10-01-2006  |    1
+ develop   |    10 |   5200 | 08-01-2007  |    2
+ develop   |    11 |   5200 | 08-15-2007  |    2
+ personnel |     2 |   3900 | 12-23-2006  |    1
+ personnel |     5 |   3500 | 12-10-2007  |    2
+ sales     |     1 |   5000 | 10-01-2006  |    1
+ sales     |     4 |   4800 | 08-08-2007  |    2
+ sales     |     3 |   4800 | 08-01-2007  |    2
+(8 rows)
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date DESC)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date DESC) = 1;
+  depname  | empno | salary | enroll_date | row_number 
+-----------+-------+--------+-------------+------------
+ develop   |     7 |   4200 | 01-01-2008  |          1
+ personnel |     5 |   3500 | 12-10-2007  |          1
+ sales     |     4 |   4800 | 08-08-2007  |          1
+(3 rows)
+
+SELECT *,
+       AVG(salary) OVER (PARTITION BY depname) AS avg_salary
+FROM empsalary
+QUALIFY salary > avg_salary;
+  depname  | empno | salary | enroll_date |      avg_salary       
+-----------+-------+--------+-------------+-----------------------
+ develop   |    11 |   5200 | 08-15-2007  | 5020.0000000000000000
+ develop   |     8 |   6000 | 10-01-2006  | 5020.0000000000000000
+ develop   |    10 |   5200 | 08-01-2007  | 5020.0000000000000000
+ personnel |     2 |   3900 | 12-23-2006  | 3700.0000000000000000
+ sales     |     1 |   5000 | 10-01-2006  | 4866.6666666666666667
+(5 rows)
+
+SELECT *,
+       COUNT(*) OVER (PARTITION BY depname, salary)
+FROM empsalary
+QUALIFY COUNT(*) OVER (PARTITION BY depname, salary) = 1;
+  depname  | empno | salary | enroll_date | count 
+-----------+-------+--------+-------------+-------
+ develop   |     7 |   4200 | 01-01-2008  |     1
+ develop   |     9 |   4500 | 01-01-2008  |     1
+ develop   |     8 |   6000 | 10-01-2006  |     1
+ personnel |     5 |   3500 | 12-10-2007  |     1
+ personnel |     2 |   3900 | 12-23-2006  |     1
+ sales     |     1 |   5000 | 10-01-2006  |     1
+(6 rows)
+
+SELECT *,
+       RANK() OVER (ORDER BY salary DESC) as rank
+FROM empsalary
+QUALIFY rank = 2;
+ depname | empno | salary | enroll_date | rank 
+---------+-------+--------+-------------+------
+ develop |    10 |   5200 | 08-01-2007  |    2
+ develop |    11 |   5200 | 08-15-2007  |    2
+(2 rows)
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date) <= 3;
+  depname  | empno | salary | enroll_date | row_number 
+-----------+-------+--------+-------------+------------
+ develop   |     8 |   6000 | 10-01-2006  |          1
+ develop   |    10 |   5200 | 08-01-2007  |          2
+ develop   |    11 |   5200 | 08-15-2007  |          3
+ personnel |     2 |   3900 | 12-23-2006  |          1
+ personnel |     5 |   3500 | 12-10-2007  |          2
+ sales     |     1 |   5000 | 10-01-2006  |          1
+ sales     |     3 |   4800 | 08-01-2007  |          2
+ sales     |     4 |   4800 | 08-08-2007  |          3
+(8 rows)
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2
+ORDER BY depname, salary DESC;
+  depname  | empno | salary | enroll_date | row_number 
+-----------+-------+--------+-------------+------------
+ develop   |     8 |   6000 | 10-01-2006  |          1
+ develop   |    10 |   5200 | 08-01-2007  |          2
+ personnel |     2 |   3900 | 12-23-2006  |          1
+ personnel |     5 |   3500 | 12-10-2007  |          2
+ sales     |     1 |   5000 | 10-01-2006  |          1
+ sales     |     4 |   4800 | 08-08-2007  |          2
+(6 rows)
+
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY
+    RANK() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2
+    AND enroll_date > DATE '2007-01-01';
+  depname  | empno | salary | enroll_date | rank 
+-----------+-------+--------+-------------+------
+ develop   |    10 |   5200 | 08-01-2007  |    2
+ develop   |    11 |   5200 | 08-15-2007  |    2
+ personnel |     5 |   3500 | 12-10-2007  |    2
+ sales     |     4 |   4800 | 08-08-2007  |    2
+ sales     |     3 |   4800 | 08-01-2007  |    2
+(5 rows)
+
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY
+    RANK() OVER (PARTITION BY depname ORDER BY salary DESC) = 1
+    OR salary < 4000;
+  depname  | empno | salary | enroll_date | rank 
+-----------+-------+--------+-------------+------
+ develop   |     8 |   6000 | 10-01-2006  |    1
+ personnel |     2 |   3900 | 12-23-2006  |    1
+ personnel |     5 |   3500 | 12-10-2007  |    2
+ sales     |     1 |   5000 | 10-01-2006  |    1
+(4 rows)
+
 -- cleanup
 DROP TABLE empsalary;
 -- test user-defined window function with named args and default args
diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql
index 02f105f070e..4f376d1b459 100644
--- a/src/test/regress/sql/window.sql
+++ b/src/test/regress/sql/window.sql
@@ -1522,6 +1522,57 @@ SELECT * FROM
    FROM empsalary) emp
 WHERE first_emp = 1 OR last_emp = 1;
 
+-- Test QUALIFY clause
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY RANK() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2;
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date DESC)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date DESC) = 1;
+
+SELECT *,
+       AVG(salary) OVER (PARTITION BY depname) AS avg_salary
+FROM empsalary
+QUALIFY salary > avg_salary;
+
+SELECT *,
+       COUNT(*) OVER (PARTITION BY depname, salary)
+FROM empsalary
+QUALIFY COUNT(*) OVER (PARTITION BY depname, salary) = 1;
+
+SELECT *,
+       RANK() OVER (ORDER BY salary DESC) as rank
+FROM empsalary
+QUALIFY rank = 2;
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY enroll_date) <= 3;
+
+SELECT *,
+       ROW_NUMBER() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY ROW_NUMBER() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2
+ORDER BY depname, salary DESC;
+
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY
+    RANK() OVER (PARTITION BY depname ORDER BY salary DESC) <= 2
+    AND enroll_date > DATE '2007-01-01';
+
+SELECT *,
+       RANK() OVER (PARTITION BY depname ORDER BY salary DESC)
+FROM empsalary
+QUALIFY
+    RANK() OVER (PARTITION BY depname ORDER BY salary DESC) = 1
+    OR salary < 4000;
+
 -- cleanup
 DROP TABLE empsalary;
 
-- 
2.39.5 (Apple Git-154)

#14Vik Fearing
vik@postgresfriends.org
In reply to: Matheus Alcantara (#13)
Re: Proposal: QUALIFY clause

On 21/07/2025 23:29, Matheus Alcantara wrote:

On Mon Jul 21, 2025 at 5:23 PM -03, Vik Fearing wrote:

On 21/07/2025 14:47, Matheus Alcantara wrote:

Hi all,

I'm sending a proof-of-concept patch to add support for the QUALIFY
clause in Postgres. This feature allows filtering rows after window
functions are computed, using a syntax similar to the WHERE or HAVING
clauses.

I took a very brief look at this, and I think your grammar is wrong.
The QUALIFY clause should go after the WINDOW clause, just like
FROM/WHERE and GROUP BY/HAVING.

That is what I am proposing to the standards committee, and I already
have some buy-in for that.

Thank you for the brief review and for the comments!

I'm not sure if I fully understand but please see the new attached
version.

That is my preferred grammar, thank you.  I have not looked at the C
code by this can be obtained with a syntax transformation. To wit:

SELECT a, b, c
FROM tab
QUALIFY wf() OVER () = ?

can be rewritten as:

SELECT a, b, c
FROM (
    SELECT a, b, c, wf() OVER () = ? AS qc
    FROM tab
) AS q
WHERE qc

and then let the optimizer take over.  The standard does this kind of
thing all over the place; I don't know what the postgres project's
position on doing things like this are.

--

Vik Fearing

#15Tom Lane
tgl@sss.pgh.pa.us
In reply to: Vik Fearing (#14)
Re: Proposal: QUALIFY clause

Vik Fearing <vik@postgresfriends.org> writes:

That is my preferred grammar, thank you. I have not looked at the C
code by this can be obtained with a syntax transformation. To wit:

SELECT a, b, c
FROM tab
QUALIFY wf() OVER () = ?

can be rewritten as:

SELECT a, b, c
FROM (
    SELECT a, b, c, wf() OVER () = ? AS qc
    FROM tab
) AS q
WHERE qc

That answers another question I was going to raise. Matheus's
opening example was

SELECT depname,
empno,
salary,
RANK() OVER (PARTITION BY depname ORDER BY salary DESC) AS rnk
FROM empsalary
QUALIFY rnk = 1;

which implies that the QUALIFY clause sees the SELECT output columns,
and hence that it can't use any values not emitted by the SELECT list.
Your transformation implies that it sees the same namespace as the
SELECT list, which seems like a much better and less confusing
definition to me.

regards, tom lane

#16Pavel Stehule
pavel.stehule@gmail.com
In reply to: Vik Fearing (#14)
Re: Proposal: QUALIFY clause

Hi

út 22. 7. 2025 v 0:12 odesílatel Vik Fearing <vik@postgresfriends.org>
napsal:

On 21/07/2025 23:29, Matheus Alcantara wrote:

On Mon Jul 21, 2025 at 5:23 PM -03, Vik Fearing wrote:

On 21/07/2025 14:47, Matheus Alcantara wrote:

Hi all,

I'm sending a proof-of-concept patch to add support for the QUALIFY
clause in Postgres. This feature allows filtering rows after window
functions are computed, using a syntax similar to the WHERE or HAVING
clauses.

I took a very brief look at this, and I think your grammar is wrong.
The QUALIFY clause should go after the WINDOW clause, just like
FROM/WHERE and GROUP BY/HAVING.

That is what I am proposing to the standards committee, and I already
have some buy-in for that.

Thank you for the brief review and for the comments!

I'm not sure if I fully understand but please see the new attached
version.

That is my preferred grammar, thank you. I have not looked at the C
code by this can be obtained with a syntax transformation. To wit:

SELECT a, b, c
FROM tab
QUALIFY wf() OVER () = ?

can be rewritten as:

SELECT a, b, c
FROM (
SELECT a, b, c, wf() OVER () = ? AS qc
FROM tab
) AS q
WHERE qc

and then let the optimizer take over. The standard does this kind of
thing all over the place; I don't know what the postgres project's
position on doing things like this are.

just for curiosity - why the HAVING clause was not used?

Any window functions are +/- an "aggregate" function, and then HAVING looks
more natural to me.

Regards

Pavel

Show quoted text

--

Vik Fearing

#17Merlin Moncure
mmoncure@gmail.com
In reply to: Pavel Stehule (#16)
Re: Proposal: QUALIFY clause

On Mon, Jul 21, 2025 at 9:19 PM Pavel Stehule <pavel.stehule@gmail.com>
wrote:

just for curiosity - why the HAVING clause was not used?

Any window functions are +/- an "aggregate" function, and then HAVING
looks more natural to me.

Hm, HAVING requires to apply 'group by' which windows functions do not
require (unlike aggregates).

superuser@postgres=# select * from (select 1 as v) q having true limit 1;
ERROR: column "q.v" must appear in the GROUP BY clause or be used in an
aggregate function
LINE 1: select * from (select 1 as v) q having true limit 1;

If a query has both window function and grouped aggregate, HAVING would be
applying at different grains potentially? If so, seems sus.

merlin

#18Nico Williams
nico@cryptonector.com
In reply to: Merlin Moncure (#17)
Re: Proposal: QUALIFY clause

On Mon, Jul 21, 2025 at 09:43:15PM -0600, Merlin Moncure wrote:

On Mon, Jul 21, 2025 at 9:19 PM Pavel Stehule <pavel.stehule@gmail.com>
wrote:

just for curiosity - why the HAVING clause was not used?

Any window functions are +/- an "aggregate" function, and then HAVING
looks more natural to me.

Hm, HAVING requires to apply 'group by' which windows functions do not
require (unlike aggregates).

Pavel's point is precisely to allow HAVING w/o a GROUP BY when there are
window functions since window functions are "+/-" ("more or less")
aggregate functions. That makes sense to me.

superuser@postgres=# select * from (select 1 as v) q having true limit 1;
ERROR: column "q.v" must appear in the GROUP BY clause or be used in an
aggregate function
LINE 1: select * from (select 1 as v) q having true limit 1;

If a query has both window function and grouped aggregate, HAVING would be
applying at different grains potentially? If so, seems sus.

I would have a HAVING clause that comes _before_ GROUP BY apply to
window functions and a second one that comes _after_ GROUP BY apply to
the grouping.

Nico
--

#19Merlin Moncure
mmoncure@gmail.com
In reply to: Nico Williams (#18)
Re: Proposal: QUALIFY clause

On Mon, Jul 21, 2025 at 10:08 PM Nico Williams <nico@cryptonector.com>
wrote:

On Mon, Jul 21, 2025 at 09:43:15PM -0600, Merlin Moncure wrote:

On Mon, Jul 21, 2025 at 9:19 PM Pavel Stehule <pavel.stehule@gmail.com>
wrote:

just for curiosity - why the HAVING clause was not used?

Any window functions are +/- an "aggregate" function, and then HAVING
looks more natural to me.

Hm, HAVING requires to apply 'group by' which windows functions do not
require (unlike aggregates).

Pavel's point is precisely to allow HAVING w/o a GROUP BY when there are
window functions since window functions are "+/-" ("more or less")
aggregate functions. That makes sense to me.

superuser@postgres=# select * from (select 1 as v) q having true limit

1;

ERROR: column "q.v" must appear in the GROUP BY clause or be used in an
aggregate function
LINE 1: select * from (select 1 as v) q having true limit 1;

If a query has both window function and grouped aggregate, HAVING would

be

applying at different grains potentially? If so, seems sus.

I would have a HAVING clause that comes _before_ GROUP BY apply to
window functions and a second one that comes _after_ GROUP BY apply to
the grouping.

I don't know...consider:
#1 SELECT lag(1) OVER(), max(v) FROM (SELECT 1 AS v) q HAVING true ;
...
#2 SELECT lag(1) OVER(), max(v) FROM (SELECT 1 AS v) q HAVING true HAVING
lag(1) OVER() IS NULL;

What does the HAVING clause apply to in #1? I think you might be in
trouble with the standard here. 2nd clause doesn't feel right in #2. The
basic problem is that HAVING does more than just 'syntax sugar subquery /
WHERE' and it just can't be hijacked to do something else IMO.

Syntax simplifying
SELECT * FROM (<window function query>) WHERE col = x

Does have some merit, but implementing non-standard syntax has risks,
especially in this area of the grammar. If you did do it, I'd vote for
QUALIFY since implementation consensus seems to influence the standard to
some degree, but I have to unfortunately +1 the 'reserved word' warning.
You could probably work around that with more complex syntax but that kind
of defeats the point.

merlin

#20Tom Lane
tgl@sss.pgh.pa.us
In reply to: Nico Williams (#18)
Re: Proposal: QUALIFY clause

Nico Williams <nico@cryptonector.com> writes:

On Mon, Jul 21, 2025 at 09:43:15PM -0600, Merlin Moncure wrote:

Hm, HAVING requires to apply 'group by' which windows functions do not
require (unlike aggregates).

Pavel's point is precisely to allow HAVING w/o a GROUP BY when there are
window functions since window functions are "+/-" ("more or less")
aggregate functions. That makes sense to me.

No, it's really quite wrong. Aggregate functions are not equivalent
to window functions: if you have both in a query, they execute in
separate passes, with the window functions operating on the grouped
rows output by the aggregation step (and then filtered by HAVING,
if any).

If we're going to support this, it does need to be its own clause.

regards, tom lane

#21Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Vik Fearing (#14)
Re: Proposal: QUALIFY clause

On Mon Jul 21, 2025 at 7:11 PM -03, Vik Fearing wrote:

That is my preferred grammar, thank you. 

Thanks for confirming!

I have not looked at the C code by this can be obtained with a syntax
transformation. To wit:
SELECT a, b, c
FROM tab
QUALIFY wf() OVER () = ?

can be rewritten as:

SELECT a, b, c
FROM (
    SELECT a, b, c, wf() OVER () = ? AS qc
    FROM tab
) AS q
WHERE qc

and then let the optimizer take over.  The standard does this kind of
thing all over the place; I don't know what the postgres project's
position on doing things like this are.

The current patch supports the following syntaxes:
SELECT a, b, c
wf() OVER () as d
FROM tab
QUALIFY d = 1

and
SELECT a, b, c
wf() OVER ()
FROM tab
QUALIFY wf() OVER () = 1

When using the "QUALIFY d = 1" form, I currently rewrite the expression
as "wf() OVER () = 1" by searching the targetlist for the matching
alias, replacing the Var with the corresponding WindowFunc. Then I
append this clause to the topqual, which is later assigned to
WindowAggPath.plan->qual in create_one_window_path().

Besides this approach works I'm almost sure that this is not correct
because searching the window function on targetlist doesen't seems
correct to me. Tom also pointed out that this design could be confusing,
which reinforces the need to rethink it.

This transformation that you've suggested seems a better approach to
handle the QUALIFY clause to me as well. Unless anyone objects, I'll
prepare the next patch version based on that strategy.

Thanks very much for the comments!

--
Matheus Alcantara

#22Nico Williams
nico@cryptonector.com
In reply to: Merlin Moncure (#19)
Re: Proposal: QUALIFY clause

On Mon, Jul 21, 2025 at 11:02:36PM -0600, Merlin Moncure wrote:

On Mon, Jul 21, 2025 at 10:08 PM Nico Williams <nico@cryptonector.com>
wrote:

I would have a HAVING clause that comes _before_ GROUP BY apply to
window functions and a second one that comes _after_ GROUP BY apply to
the grouping.

I don't know...consider:
#1 SELECT lag(1) OVER(), max(v) FROM (SELECT 1 AS v) q HAVING true ;
...
#2 SELECT lag(1) OVER(), max(v) FROM (SELECT 1 AS v) q HAVING true HAVING
lag(1) OVER() IS NULL;

What does the HAVING clause apply to in #1? I think you might be in
trouble with the standard here. 2nd clause doesn't feel right in #2. The
basic problem is that HAVING does more than just 'syntax sugar subquery /
WHERE' and it just can't be hijacked to do something else IMO.

#2 would be a syntax error because the second HAVING did not come after
a GROUP BY. #1 would not be a syntax error only because of the use of
window functions before the HAVING.

Syntax simplifying
SELECT * FROM (<window function query>) WHERE col = x

Yes. I'd rather that than QUALIFY. QUALIFY only makes sense because so
many other RDBMSes have it and it's likely to get standardized.

Nico
--

#23Nico Williams
nico@cryptonector.com
In reply to: Tom Lane (#20)
Re: Proposal: QUALIFY clause

On Tue, Jul 22, 2025 at 01:14:20AM -0400, Tom Lane wrote:

Nico Williams <nico@cryptonector.com> writes:

On Mon, Jul 21, 2025 at 09:43:15PM -0600, Merlin Moncure wrote:

Hm, HAVING requires to apply 'group by' which windows functions do not
require (unlike aggregates).

Pavel's point is precisely to allow HAVING w/o a GROUP BY when there are
window functions since window functions are "+/-" ("more or less")
aggregate functions. That makes sense to me.

No, it's really quite wrong. Aggregate functions are not equivalent
to window functions: if you have both in a query, they execute in
separate passes, with the window functions operating on the grouped
rows output by the aggregation step (and then filtered by HAVING,
if any).

Pavel doesn't say that window functions are aggregate functions. Pavel
said they are +/- (more or less, really, just similar to) aggregate
functions. There is a similarity. But I appreciate the point about
which passes get which, and that definitely makes the two-HAVING-
clauses concept much more unwieldy.

If we're going to support this, it does need to be its own clause.

I agree that its own clause is best; I just greatly dislike QUALIFY.

#24Nico Williams
nico@cryptonector.com
In reply to: Merlin Moncure (#19)
Re: Proposal: QUALIFY clause

I often accidentally write

SELECT .. WHERE .. WHERE ..;

which is obviously wrong, but what I mean when I do this is

SELECT .. WHERE .. AND ..;

and if I wrote GROUP BY .. HAVING queries as often as I do ones that
don't GROUP BY then I'd probably also accidentally use extra HAVINGs as
ANDs.

It doesn't seem too crazy that extra WHEREs in WHERE clauses should some
day function as ANDs, and ditto HAVINGs, which is another reason not to
reuse HAVING for this: just to leave that a possibility, remote though
it might be.

My advice is to wait till QUALIFY is standardized, then hold your nose
and adopt it, or maybe sooner when it becomes clear that it will be
standardized (because so many other RDBMSes have it too).

#25Vik Fearing
vik@postgresfriends.org
In reply to: Nico Williams (#23)
Re: Proposal: QUALIFY clause

On 22/07/2025 17:07, Nico Williams wrote:

On Tue, Jul 22, 2025 at 01:14:20AM -0400, Tom Lane wrote:

Nico Williams <nico@cryptonector.com> writes:

On Mon, Jul 21, 2025 at 09:43:15PM -0600, Merlin Moncure wrote:

Hm, HAVING requires to apply 'group by' which windows functions do not
require (unlike aggregates).

Pavel's point is precisely to allow HAVING w/o a GROUP BY when there are
window functions since window functions are "+/-" ("more or less")
aggregate functions. That makes sense to me.

No, it's really quite wrong. Aggregate functions are not equivalent
to window functions: if you have both in a query, they execute in
separate passes, with the window functions operating on the grouped
rows output by the aggregation step (and then filtered by HAVING,
if any).

Pavel doesn't say that window functions are aggregate functions. Pavel
said they are +/- (more or less, really, just similar to) aggregate
functions. There is a similarity. But I appreciate the point about
which passes get which, and that definitely makes the two-HAVING-
clauses concept much more unwieldy.

Window functions and aggregates have only one thing in common, and that
is that they can both operate on a window frame. Otherwise the
difference is night and day.  Especially when you consider nested window
clauses (that postgres does not support yet).

I agree that its own clause is best; I just greatly dislike QUALIFY.

Sorry.

--

Vik Fearing

#26Vik Fearing
vik@postgresfriends.org
In reply to: Nico Williams (#24)
Re: Proposal: QUALIFY clause

On 22/07/2025 17:14, Nico Williams wrote:

It doesn't seem too crazy that extra WHEREs in WHERE clauses should some
day function as ANDs, and ditto HAVINGs, which is another reason not to
reuse HAVING for this: just to leave that a possibility, remote though
it might be.

I have a firm finger on the pulse of the standards committee, and I can
guarantee that multiple WHERE clauses will never replace AND until
certain people cross the river Styx.  Myself included.

My advice is to wait till QUALIFY is standardized, then hold your nose
and adopt it, or maybe sooner when it becomes clear that it will be
standardized (because so many other RDBMSes have it too).

Good advice.

--

Vik Fearing

#27Andrew Dunstan
andrew@dunslane.net
In reply to: Vik Fearing (#25)
Re: Proposal: QUALIFY clause

On 2025-07-22 Tu 11:14 AM, Vik Fearing wrote:

I agree that its own clause is best; I just greatly dislike QUALIFY.

Sorry.

If we were making up our own syntax this would be a sensible thing to
debate. If we're talking about implementing something we expect to be in
the standard, I think we will have to live with what the standards
committee decides, regardless of our preference. We've almost certainly
been preempted here by other RDBMSs using QUALIFY, heedless of English
grammar.

cheers

andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com

#28Marcos Pegoraro
marcos@f10.com.br
In reply to: Matheus Alcantara (#21)
Re: Proposal: QUALIFY clause

Em ter., 22 de jul. de 2025 às 08:56, Matheus Alcantara <
matheusssilv97@gmail.com> escreveu:

The current patch supports the following syntaxes:
SELECT a, b, c
wf() OVER () as d
FROM tab
QUALIFY d = 1
When using the "QUALIFY d = 1" form, I currently rewrite the expression
as "wf() OVER () = 1" by searching the targetlist for the matching
alias, replacing the Var with the corresponding WindowFunc.

Not related to $subject but that way you did,
that replacement alias for wf expression, is cool.
With that would it be possible to have where replacements too ? Maybe.

select a+b as ab from t where ab = 5

regards
Marcos

#29Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Marcos Pegoraro (#28)
Re: Proposal: QUALIFY clause

On Tue Jul 22, 2025 at 3:11 PM -03, Marcos Pegoraro wrote:

Em ter., 22 de jul. de 2025 às 08:56, Matheus Alcantara <
matheusssilv97@gmail.com> escreveu:

The current patch supports the following syntaxes:
SELECT a, b, c
wf() OVER () as d
FROM tab
QUALIFY d = 1
When using the "QUALIFY d = 1" form, I currently rewrite the expression
as "wf() OVER () = 1" by searching the targetlist for the matching
alias, replacing the Var with the corresponding WindowFunc.

Not related to $subject but that way you did,
that replacement alias for wf expression, is cool.
With that would it be possible to have where replacements too ? Maybe.

select a+b as ab from t where ab = 5

Do you mean instead of reference the "ab" using a Var it replaces with a
OpExpr of "a+b"? I think that it would be possible, but the current
implementation is not fully correct, it only works for OpExpr's. Using
AND/OR operators does not work and I think that to make it fully correct
is more complex, so I think that rewriting the query to use a subquery
as Vik suggested on [1]/messages/by-id/6c998e4f-f6f2-43c2-8b67-cfff360ef241@postgresfriends.org fixes this issue and also makes it easier to
understand.

(this happens on transformQualifyClause() if you want to take a look)

[1]: /messages/by-id/6c998e4f-f6f2-43c2-8b67-cfff360ef241@postgresfriends.org

--
Matheus Alcantara

#30Vik Fearing
vik@postgresfriends.org
In reply to: Matheus Alcantara (#29)
Re: Proposal: QUALIFY clause

On 22/07/2025 20:54, Matheus Alcantara wrote:

(this happens on transformQualifyClause() if you want to take a look)

I took a quick look at the patch (without applying and testing it) and
it seems to me that parse analysis is the wrong place to do this. We
want ruleutils to be able to spew out the QUALIFY clause as written in a
view and not as transformed.  If we are going to go down the syntax
transformation route, that should happen in the rewriter at
planning/execution time.

--

Vik Fearing

#31David Rowley
dgrowleyml@gmail.com
In reply to: Vik Fearing (#30)
Re: Proposal: QUALIFY clause

On Wed, 23 Jul 2025 at 09:21, Vik Fearing <vik@postgresfriends.org> wrote:

I took a quick look at the patch (without applying and testing it) and
it seems to me that parse analysis is the wrong place to do this. We
want ruleutils to be able to spew out the QUALIFY clause as written in a
view and not as transformed. If we are going to go down the syntax
transformation route, that should happen in the rewriter at
planning/execution time.

I agree with Tom on not jumping the gun on the standard thing, but if
that does materialise one day, then whichever method is used, you'd
still want the same pushdown optimisations to happen that currently
happen with qual pushdown into subqueries.

Looking at the latest patch I see that pushdowns don't work:

# explain select row_number() over (order by oid) rb from pg_Class
qualify row_number () over (order by oid)=1;

# explain (analyze, costs off, buffers off, summary off) select
row_number() over (order by oid) rb from pg_Class qualify row_number
() over (order by oid)=1;

WindowAgg (actual time=0.041..0.273 rows=1.00 loops=1)
Window: w1 AS (ORDER BY oid ROWS UNBOUNDED PRECEDING)
Filter: (row_number() OVER w1 = 1)
Rows Removed by Filter: 415
Storage: Memory Maximum Storage: 17kB
-> Index Only Scan using pg_class_oid_index on pg_class (actual
time=0.032..0.125 rows=416.00 loops=1)
Heap Fetches: 0
Index Searches: 1

Whereas, with a subquery we get:

# explain (analyze, costs off, buffers off, summary off)
select * from (select row_number() over (order by oid) rn from
pg_class) r where r.rn=1;

Subquery Scan on r (actual time=0.042..0.044 rows=1.00 loops=1)
Filter: (r.rn = 1)
-> WindowAgg (actual time=0.041..0.043 rows=1.00 loops=1)
Window: w1 AS (ORDER BY pg_class.oid ROWS UNBOUNDED PRECEDING)
Run Condition: (row_number() OVER w1 <= 1)
Storage: Memory Maximum Storage: 17kB
-> Index Only Scan using pg_class_oid_index on pg_class
(actual time=0.030..0.031 rows=2.00 loops=1)
Heap Fetches: 0
Index Searches: 1

Also, this seems busted:

# select row_number() over (order by oid) rn from pg_class qualify rn=1;
server closed the connection unexpectedly

David

#32Álvaro Herrera
alvherre@kurilemu.de
In reply to: Andrew Dunstan (#27)
Re: Proposal: QUALIFY clause

On 2025-Jul-22, Andrew Dunstan wrote:

If we were making up our own syntax this would be a sensible thing to
debate. If we're talking about implementing something we expect to be in the
standard, I think we will have to live with what the standards committee
decides, regardless of our preference. We've almost certainly been preempted
here by other RDBMSs using QUALIFY, heedless of English grammar.

The Romans, the Vikings, the Normans, all have influenced the English
language. Why not SQL?

--
Álvaro Herrera Breisgau, Deutschland — https://www.EnterpriseDB.com/
“Cuando no hay humildad las personas se degradan” (A. Christie)

#33Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Vik Fearing (#14)
Re: Proposal: QUALIFY clause

On Mon Jul 21, 2025 at 7:11 PM -03, Vik Fearing wrote:

That is my preferred grammar, thank you.  I have not looked at the C
code by this can be obtained with a syntax transformation. To wit:

SELECT a, b, c
FROM tab
QUALIFY wf() OVER () = ?

can be rewritten as:

SELECT a, b, c
FROM (
    SELECT a, b, c, wf() OVER () = ? AS qc
    FROM tab
) AS q
WHERE qc

and then let the optimizer take over.  The standard does this kind of
thing all over the place; I don't know what the postgres project's
position on doing things like this are.

With this transformation users will see a Subquery plan node even if
it's not present on the original query, is that expected or it can be
confusing to users?

--
Matheus Alcantara

#34Vik Fearing
vik@postgresfriends.org
In reply to: Matheus Alcantara (#33)
Re: Proposal: QUALIFY clause

On 25/07/2025 14:55, Matheus Alcantara wrote:

On Mon Jul 21, 2025 at 7:11 PM -03, Vik Fearing wrote:

SELECT a, b, c
FROM tab
QUALIFY wf() OVER () = ?

can be rewritten as:

SELECT a, b, c
FROM (
    SELECT a, b, c, wf() OVER () = ? AS qc
    FROM tab
) AS q
WHERE qc

and then let the optimizer take over.  The standard does this kind of
thing all over the place; I don't know what the postgres project's
position on doing things like this are.

With this transformation users will see a Subquery plan node even if
it's not present on the original query, is that expected or it can be
confusing to users?

This is a definition technique, it does not need to be implemented as a
subquery.

--

Vik Fearing

#35Matheus Alcantara
matheusssilv97@gmail.com
In reply to: David Rowley (#31)
Re: Proposal: QUALIFY clause

On 22/07/25 19:32, David Rowley wrote:

Looking at the latest patch I see that pushdowns don't work:

# explain select row_number() over (order by oid) rb from pg_Class
qualify row_number () over (order by oid)=1;

# explain (analyze, costs off, buffers off, summary off) select
row_number() over (order by oid) rb from pg_Class qualify row_number
() over (order by oid)=1;

WindowAgg (actual time=0.041..0.273 rows=1.00 loops=1)
Window: w1 AS (ORDER BY oid ROWS UNBOUNDED PRECEDING)
Filter: (row_number() OVER w1 = 1)
Rows Removed by Filter: 415
Storage: Memory Maximum Storage: 17kB
-> Index Only Scan using pg_class_oid_index on pg_class (actual
time=0.032..0.125 rows=416.00 loops=1)
Heap Fetches: 0
Index Searches: 1

Whereas, with a subquery we get:

# explain (analyze, costs off, buffers off, summary off)
select * from (select row_number() over (order by oid) rn from
pg_class) r where r.rn=1;

Subquery Scan on r (actual time=0.042..0.044 rows=1.00 loops=1)
Filter: (r.rn = 1)
-> WindowAgg (actual time=0.041..0.043 rows=1.00 loops=1)
Window: w1 AS (ORDER BY pg_class.oid ROWS UNBOUNDED PRECEDING)
Run Condition: (row_number() OVER w1 <= 1)
Storage: Memory Maximum Storage: 17kB
-> Index Only Scan using pg_class_oid_index on pg_class
(actual time=0.030..0.031 rows=2.00 loops=1)
Heap Fetches: 0
Index Searches: 1

By "pushdowns" you mean missing the Run Conditions on the QUALIFY
example? IIUC the Run Condition is only created if it's a subquery. I've
checked this on set_rel_size() -> set_subquery_pathlist() ->
check_and_push_window_quals().

Also, this seems busted:

# select row_number() over (order by oid) rn from pg_class qualify rn=1;
server closed the connection unexpectedly

Thanks for testing! I'm working on this and some other issues.

--
Matheus Alcantara

#36David Rowley
dgrowleyml@gmail.com
In reply to: Matheus Alcantara (#35)
Re: Proposal: QUALIFY clause

On Tue, 29 Jul 2025 at 12:11, Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

By "pushdowns" you mean missing the Run Conditions on the QUALIFY
example? IIUC the Run Condition is only created if it's a subquery. I've
checked this on set_rel_size() -> set_subquery_pathlist() ->
check_and_push_window_quals().

Yes, but not only Run Conditions, it's subquery pushdown quals in
general. There are various rules to what is allowed and what must be
disallowed. See check_output_expressions().

You should be pushing the qual to the lowest level that it's valid to
evaluate it at. We do this already for HAVING quals where those will
effectively be "transferred" to the WHERE clause when it's valid to do
so. I'd expect the same for QUALIFY. I'm unsure which parts of
subquery pushdown could be made more reusable to help you with this.
Ideally we'd not have to duplicate lots of logic in various places. If
you do manage to get around the whole SQL standard issue around
QUALIFY, then a large portion of a patch like this being acceptable
will largely depend on how much code gets reused vs how much you have
to rewrite from scratch. It's not that fun to have to duplicate logic
in multiple places when new optimisations are added. See d222585a9 for
an example of an optimisation that would likely have to be duplicated
if QUALIFY existed.

David

#37Richard Guo
guofenglinux@gmail.com
In reply to: David Rowley (#36)
Re: Proposal: QUALIFY clause

On Tue, Jul 29, 2025 at 9:47 AM David Rowley <dgrowleyml@gmail.com> wrote:

You should be pushing the qual to the lowest level that it's valid to
evaluate it at. We do this already for HAVING quals where those will
effectively be "transferred" to the WHERE clause when it's valid to do
so. I'd expect the same for QUALIFY.

Yeah, we should have the same kind of optimization for a QUALIFY
clause as we do for HAVING - pushing it down to WHERE when possible.
One condition for doing this is that the QUALIFY clause does not
reference any columns that are not present in the window PARTITION BY
clauses. There may be other conditions under which we can have
other optimizations, such as pushing down a QUALIFY clause as run
conditions.

I think we should at a minimum inherit all existing optimizations for
window functions used in subqueries; otherwise, it will be difficult
to convince others to accept this patch.

Thanks
Richard