In SQL, OVER (...) follows the function’s closing parenthesis. DQL cannot parse anything after a function’s closing parenthesis, so in DQL OVER becomes a function around the call, with the window specification as its second argument:
SQL
DQL
COUNT(e.id) OVER ()
OVER(COUNT(e.id))
SUM(e.amount) OVER (PARTITION BY e.customer)
OVER(SUM(e.amount), PARTITION BY e.customer)
SUM(e.amount) OVER (PARTITION BY e.customer ORDER BY e.createdAt ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
OVER(SUM(e.amount), PARTITION BY e.customer ORDER BY e.createdAt ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
array_agg(e.tag) OVER (PARTITION BY e.post)
OVER(ARRAY_AGG(e.tag), PARTITION BY e.post)
SUM(e.amount) FILTER (WHERE e.refunded = false) OVER (PARTITION BY e.customer)
OVER(FILTER(SUM(e.amount), WHERE e.refunded = FALSE), PARTITION BY e.customer)
A comma separates the call from the window specification, and the specification has no parentheses around it.
Without a window specification the window is the whole result, so OVER(COUNT(e.id)) repeats the total row count on every row.
Any aggregate from this library, such as ARRAY_AGG, STRING_AGG or BOOL_AND, and a FILTER(...) around one.
A window-only function: the ranking functionsROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST and NTILE, the value functionsLAG, LEAD, FIRST_VALUE, LAST_VALUE and NTH_VALUE, or any other function implementing MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\WindowFunction. Implement it, or MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\AggregateFunction for an aggregate, on a function of your own to window that one too.
Anything else, including a scalar function or a nested OVER, throws a ParserException, as PostgreSQL would reject it.
Window Specification
[PARTITION BY expression [, ...]] [ORDER BY expression [ASC | DESC] [, ...]] [frame clause]
PARTITION BY takes one or more comma-separated expressions: fields, arithmetic, function calls, literals and parameters.
ORDER BY takes the same items as a DQL ORDER BY, each with an optional ASC / DESC.
Every clause is optional, and they come in the order PARTITION BY, ORDER BY, frame.
Frame Clauses
A frame narrows the rows of the partition a function reads for the current row. It follows ORDER BY (or stands on its own) and is written exactly as in SQL:
{ROWS | RANGE | GROUPS} frame_start [exclusion]
{ROWS | RANGE | GROUPS} BETWEEN frame_start AND frame_end [exclusion]
frame_start / frame_end
Meaning
UNBOUNDED PRECEDING
The first row of the partition
offset PRECEDING
offset rows, peer groups or values before the current row
CURRENT ROW
The current row, or its peer group in RANGE and GROUPS mode
offset FOLLOWING
offset rows, peer groups or values after the current row
UNBOUNDED FOLLOWING
The last row of the partition
Exclusion
Leaves out
EXCLUDE CURRENT ROW
The current row
EXCLUDE GROUP
The current row and its peers
EXCLUDE TIES
The peers of the current row, keeping the row itself
EXCLUDE NO OTHERS
Nothing (the default)
offset is an integer, a decimal, a string literal or a parameter. A string literal serves RANGE over dates and timestamps, e.g. RANGE BETWEEN '7 days' PRECEDING AND CURRENT ROW.
The keywords are case-insensitive.
Only the syntax is checked in DQL. PostgreSQL rejects the combinations it does not allow, such as UNBOUNDED FOLLOWING as the start, a frame end before its start, or an offset RANGE without exactly one ORDER BY column.
Ranking Functions
These are window-only functions: they exist only inside OVER, and number or rank each row within its partition in the order of the window’s ORDER BY. Rows with equal ORDER BY values are peers.
ROW_NUMBER numbers the rows 1, 2, 3, … with no ties; peers are numbered in an unspecified order.
RANK gives peers the same rank and leaves a gap after them (1, 1, 3); DENSE_RANK leaves none (1, 1, 2).
PERCENT_RANK is (rank - 1) / (rows in the partition - 1), or 0.0 for a single-row partition, and CUME_DIST is the fraction of rows in the partition that precede the current row or are its peers. Both return double precision.
NTILE(n) divides the partition into n buckets as equally as possible and returns the current row’s bucket, from 1 to n. The bucket count is an integer, a field, an arithmetic expression or a parameter.
Frame clauses do not affect ranking functions; PostgreSQL ignores them.
-- Number each customer's sales, newest firstSELECTs.id,OVER(ROW_NUMBER(),PARTITIONBYs.customerORDERBYs.placedAtDESC)ASsaleNumberFROMApp\Entity\Sales-- A leaderboard: RANK skips after ties (1, 1, 3), DENSE_RANK does not (1, 1, 2)SELECTp.name,p.score,OVER(RANK(),ORDERBYp.scoreDESC)ASscoreRank,OVER(DENSE_RANK(),ORDERBYp.scoreDESC)ASdenseScoreRankFROMApp\Entity\Playerp-- Split each region's sales into quartilesSELECTs.id,OVER(NTILE(4),PARTITIONBYs.regionORDERBYs.amount)ASquartileFROMApp\Entity\Sales-- Where each score sits in the distribution, from 0 to 1SELECTp.name,OVER(PERCENT_RANK(),ORDERBYp.score)ASpercentileFROMApp\Entity\Playerp
Value Functions
A value function returns a value read from another row of the window.
lag(e.price) OVER (PARTITION BY e.product ORDER BY e.day)
OVER(LAG(e.price), PARTITION BY e.product ORDER BY e.day)
lag(e.price, 2, 0) OVER (ORDER BY e.day)
OVER(LAG(e.price, 2, 0), ORDER BY e.day)
lead(e.price, 1, NULL) OVER (ORDER BY e.day)
OVER(LEAD(e.price, 1, NULL), ORDER BY e.day)
first_value(e.price) OVER (ORDER BY e.day)
OVER(FIRST_VALUE(e.price), ORDER BY e.day)
last_value(e.price) OVER (ORDER BY e.day ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
OVER(LAST_VALUE(e.price), ORDER BY e.day ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
nth_value(e.price, 2) OVER (ORDER BY e.day)
OVER(NTH_VALUE(e.price, 2), ORDER BY e.day)
LAG and LEAD read the row offset rows before or after the current one within the partition. offset defaults to 1, and default (NULL unless given) is returned when that row does not exist. default may be a literal NULL.
FIRST_VALUE, LAST_VALUE and NTH_VALUE read the window frame, not the whole partition. With an ORDER BY the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which ends at the current row’s last peer (the last row with the same ORDER BY values). So LAST_VALUE returns that peer’s value, which differs from the current row’s when peers hold different values, and NTH_VALUE returns NULL until the frame reaches its n-th row. Add a tie-breaker to ORDER BY (e.g. the id) or use an explicit ROWS frame to make the result deterministic, and ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to read the whole partition.
The value argument is polymorphic, so PostgreSQL cannot resolve a bare string literal such as LAG('none'), nor a parameter, and fails with could not determine polymorphic type because input has type unknown. Pass a field, an expression over one, or a numeric literal. A string literal or a parameter as default is fine, as the value argument already decides the type.
Usage Examples
-- Running total per customer, in the order they were placedSELECTs.id,OVER(SUM(s.amount),PARTITIONBYs.customerORDERBYs.placedAtROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW)ASrunningTotalFROMApp\Entity\Sales-- Seven-day moving average over datesSELECTd.day,OVER(AVG(d.visits),ORDERBYd.dayRANGEBETWEEN'6 days'PRECEDINGANDCURRENTROW)ASweeklyAverageFROMApp\Entity\DailyStatd-- Each sale next to its region's totalSELECTs.id,s.amount,OVER(SUM(s.amount),PARTITIONBYs.region)ASregionTotalFROMApp\Entity\Sales-- Next to a selected entity: each result row is [0 => Sale, 'runningTotal' => ...]SELECTs,OVER(SUM(s.amount),ORDERBYs.placedAt)ASrunningTotalFROMApp\Entity\SalesORDERBYs.placedAt-- Day-over-day change per product, 0 on each product's first daySELECTp.day,p.price-OVER(LAG(p.price,1,p.price),PARTITIONBYp.productORDERBYp.day)ASchangeFROMApp\Entity\DailyPricep-- Each sale next to the highest amount in its regionSELECTs.id,s.amount,OVER(LAST_VALUE(s.amount),PARTITIONBYs.regionORDERBYs.amountROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING)ASregionHighestFROMApp\Entity\Sales
Limitations
You cannot filter on a window result in DQL. PostgreSQL computes window functions after WHERE, GROUP BY and HAVING, so WHERE runningTotal > 100 is invalid SQL, not just invalid DQL. The standard fix - wrapping the query in a subquery in FROM and filtering outside it - is something DQL cannot express. Use a native query with a ResultSetMapping, or filter the rows in PHP.
A window-only function outside OVER parses, but PostgreSQL rejects it when the query runs (window function row_number requires an OVER clause).
No named windows. There is no WINDOW w AS (...) clause; every call spells out its own specification, even when several calls share it.
No NULLS FIRST / NULLS LAST. The window ORDER BY reuses DQL’s ORDER BY items, which do not support them.
No DISTINCT in a window aggregate. PostgreSQL rejects OVER(COUNT(DISTINCT e.id)).
Results hydrate as scalars. A window result is a scalar column, hydrated as the driver returns the aggregate’s or function’s type. Selected next to an entity, each result row is a mixed array such as [0 => $entity, 'runningTotal' => '42.00'].
Paginator cannot sort by a window result by default. With its defaults (fetchJoinCollection: true and output walkers enabled), Doctrine’s Paginator rewrites the query’s outer ORDER BY into its own ROW_NUMBER() OVER (ORDER BY ...). Ordering by a window result alias then nests one window function inside another, which PostgreSQL rejects with window functions are not allowed in window definitions. Ordering by entity fields works. To order by the window result, construct the paginator with new Paginator($query, false) or call $paginator->setUseOutputWalkers(false).