@@ -1134,12 +1134,8 @@ def _build_pipeline(self, source: "PipelineSource"):
11341134 """
11351135 Convert this query into a Pipeline
11361136
1137- Queries containing a `cursor` or `limit_to_last` are not currently supported
1138-
11391137 Args:
11401138 source: the PipelineSource to build the pipeline off of
1141- Raises:
1142- - NotImplementedError: raised if the query contains a `cursor` or `limit_to_last`
11431139 Returns:
11441140 a Pipeline representing the query
11451141 """
@@ -1161,39 +1157,61 @@ def _build_pipeline(self, source: "PipelineSource"):
11611157 ppl = ppl .select (* [field .field_path for field in self ._projection .fields ])
11621158
11631159 # Orders
1164- orders = self ._normalize_orders ()
1165- if orders :
1166- exists = []
1167- orderings = []
1168- for order in orders :
1169- field = pipeline_expressions .Field .of (order .field .field_path )
1170- exists .append (field .exists ())
1171- direction = (
1172- "ascending"
1173- if order .direction == StructuredQuery .Direction .ASCENDING
1174- else "descending"
1175- )
1176- orderings .append (pipeline_expressions .Ordering (field , direction ))
11771160
1178- # Add exists filters to match Query's implicit orderby semantics.
1179- if len (exists ) == 1 :
1180- ppl = ppl .where (exists [0 ])
1181- else :
1182- ppl = ppl .where (pipeline_expressions .And (* exists ))
1161+ # "explicit_orders" are only those explicitly added by the user via order_by().
1162+ # We only generate existence filters for these fields.
1163+ if self ._orders :
1164+ exists = [
1165+ pipeline_expressions .Field .of (o .field .field_path ).exists ()
1166+ for o in self ._orders
1167+ ]
1168+ ppl = ppl .where (
1169+ pipeline_expressions .And (* exists ) if len (exists ) > 1 else exists [0 ]
1170+ )
1171+
1172+ # "normalized_orders" includes both user-specified orders and implicit orders
1173+ # (e.g. __name__ or inequality fields) required by Firestore semantics.
1174+ normalized_orders = self ._normalize_orders ()
1175+ orderings = [
1176+ pipeline_expressions .Ordering (
1177+ pipeline_expressions .Field .of (o .field .field_path ),
1178+ "ascending"
1179+ if o .direction == StructuredQuery .Direction .ASCENDING
1180+ else "descending" ,
1181+ )
1182+ for o in normalized_orders
1183+ ]
1184+
1185+ # Apply cursors as filters.
1186+ if orderings :
1187+ for cursor , is_start in [(self ._start_at , True ), (self ._end_at , False )]:
1188+ cursor = self ._normalize_cursor (cursor , normalized_orders )
1189+ if cursor :
1190+ ppl = ppl .where (
1191+ _where_conditions_from_cursor (cursor , orderings , is_start )
1192+ )
1193+
1194+ # Handle sort and limit, including limit_to_last semantics.
1195+ is_limit_to_last = self ._limit_to_last and bool (orderings )
11831196
1184- # Add sort orderings
1197+ if is_limit_to_last :
1198+ # If limit_to_last is set, we need to reverse the orderings to find the
1199+ # "last" N documents (which effectively become the "first" N in reverse order).
1200+ ppl = ppl .sort (* _reverse_orderings (orderings ))
1201+ elif orderings :
11851202 ppl = ppl .sort (* orderings )
11861203
1187- # Cursors, Limit and Offset
1188- if self ._start_at or self ._end_at or self ._limit_to_last :
1189- raise NotImplementedError (
1190- "Query to Pipeline conversion: cursors and limit_to_last is not supported yet."
1191- )
1192- else : # Limit & Offset without cursors
1193- if self ._offset :
1194- ppl = ppl .offset (self ._offset )
1195- if self ._limit :
1196- ppl = ppl .limit (self ._limit )
1204+ if self ._limit is not None and (not self ._limit_to_last or orderings ):
1205+ ppl = ppl .limit (self ._limit )
1206+
1207+ if is_limit_to_last :
1208+ # If we reversed the orderings for limit_to_last, we must now re-sort
1209+ # using the original orderings to return the results in the user-requested order.
1210+ ppl = ppl .sort (* orderings )
1211+
1212+ # Offset
1213+ if self ._offset :
1214+ ppl = ppl .offset (self ._offset )
11971215
11981216 return ppl
11991217
@@ -1366,6 +1384,91 @@ def _cursor_pb(cursor_pair: Optional[Tuple[list, bool]]) -> Optional[Cursor]:
13661384 return None
13671385
13681386
1387+ def _get_cursor_exclusive_condition (
1388+ is_start_cursor : bool ,
1389+ ordering : pipeline_expressions .Ordering ,
1390+ value : pipeline_expressions .Constant ,
1391+ ) -> pipeline_expressions .BooleanExpression :
1392+ """
1393+ Helper to determine the correct comparison operator (greater_than or less_than)
1394+ based on the cursor type (start/end) and the sort direction (ascending/descending).
1395+ """
1396+ field = ordering .expr
1397+ if (
1398+ is_start_cursor
1399+ and ordering .order_dir == pipeline_expressions .Ordering .Direction .ASCENDING
1400+ ) or (
1401+ not is_start_cursor
1402+ and ordering .order_dir == pipeline_expressions .Ordering .Direction .DESCENDING
1403+ ):
1404+ return field .greater_than (value )
1405+ else :
1406+ return field .less_than (value )
1407+
1408+
1409+ def _where_conditions_from_cursor (
1410+ cursor : Tuple [List , bool ],
1411+ orderings : List [pipeline_expressions .Ordering ],
1412+ is_start_cursor : bool ,
1413+ ) -> pipeline_expressions .BooleanExpression :
1414+ """
1415+ Converts a cursor into a filter condition for the pipeline.
1416+
1417+ Args:
1418+ cursor: The cursor values and the 'before' flag.
1419+ orderings: The list of ordering expressions used in the query.
1420+ is_start_cursor: True if this is a start_at/start_after cursor, False if it is an end_at/end_before cursor.
1421+ Returns:
1422+ A BooleanExpression representing the cursor condition.
1423+ """
1424+ cursor_values , before = cursor
1425+ size = len (cursor_values )
1426+
1427+ ordering = orderings [size - 1 ]
1428+ field = ordering .expr
1429+ value = pipeline_expressions .Constant (cursor_values [size - 1 ])
1430+
1431+ # Add condition for last bound
1432+ condition = _get_cursor_exclusive_condition (is_start_cursor , ordering , value )
1433+
1434+ if (is_start_cursor and before ) or (not is_start_cursor and not before ):
1435+ # When the cursor bound is inclusive, then the last bound
1436+ # can be equal to the value, otherwise it's not equal
1437+ condition = pipeline_expressions .Or (condition , field .equal (value ))
1438+
1439+ # Iterate backwards over the remaining bounds, adding a condition for each one
1440+ for i in range (size - 2 , - 1 , - 1 ):
1441+ ordering = orderings [i ]
1442+ field = ordering .expr
1443+ value = pipeline_expressions .Constant (cursor_values [i ])
1444+
1445+ # For each field in the orderings, the condition is either
1446+ # a) lessThan|greaterThan the cursor value,
1447+ # b) or equal the cursor value and lessThan|greaterThan the cursor values for other fields
1448+ exclusive_condition = _get_cursor_exclusive_condition (
1449+ is_start_cursor , ordering , value
1450+ )
1451+ condition = pipeline_expressions .Or (
1452+ exclusive_condition ,
1453+ pipeline_expressions .And (field .equal (value ), condition ),
1454+ )
1455+
1456+ return condition
1457+
1458+
1459+ def _reverse_orderings (
1460+ orderings : List [pipeline_expressions .Ordering ],
1461+ ) -> List [pipeline_expressions .Ordering ]:
1462+ reversed_orderings = []
1463+ for o in orderings :
1464+ if o .order_dir == pipeline_expressions .Ordering .Direction .ASCENDING :
1465+ new_dir = "descending"
1466+ else :
1467+ new_dir = "ascending"
1468+ reversed_orderings .append (pipeline_expressions .Ordering (o .expr , new_dir ))
1469+ return reversed_orderings
1470+
1471+
13691472def _query_response_to_snapshot (
13701473 response_pb : RunQueryResponse , collection , expected_prefix : str
13711474) -> Optional [document .DocumentSnapshot ]:
0 commit comments