@@ -1134,12 +1134,8 @@ def _build_pipeline(self, source: "PipelineSource"): |
1134 | 1134 | """ |
1135 | 1135 | Convert this query into a Pipeline |
1136 | 1136 |
|
1137 | | - Queries containing a `cursor` or `limit_to_last` are not currently supported |
1138 | | -
|
1139 | 1137 | Args: |
1140 | 1138 | source: the PipelineSource to build the pipeline off of |
1141 | | - Raises: |
1142 | | - - NotImplementedError: raised if the query contains a `cursor` or `limit_to_last` |
1143 | 1139 | Returns: |
1144 | 1140 | a Pipeline representing the query |
1145 | 1141 | """ |
@@ -1161,39 +1157,61 @@ def _build_pipeline(self, source: "PipelineSource"): |
1161 | 1157 | ppl = ppl.select(*[field.field_path for field in self._projection.fields]) |
1162 | 1158 |
|
1163 | 1159 | # 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)) |
1177 | 1160 |
|
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) |
1183 | 1196 |
|
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: |
1185 | 1202 | ppl = ppl.sort(*orderings) |
1186 | 1203 |
|
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) |
1197 | 1215 |
|
1198 | 1216 | return ppl |
1199 | 1217 |
|
@@ -1366,6 +1384,91 @@ def _cursor_pb(cursor_pair: Optional[Tuple[list, bool]]) -> Optional[Cursor]: |
1366 | 1384 | return None |
1367 | 1385 |
|
1368 | 1386 |
|
| 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 | + |
1369 | 1472 | def _query_response_to_snapshot( |
1370 | 1473 | response_pb: RunQueryResponse, collection, expected_prefix: str |
1371 | 1474 | ) -> Optional[document.DocumentSnapshot]: |
|