@@ -1456,6 +1456,15 @@ public function find(array $queries = [], ?string $type = null): array
14561456 return $ this ->findFromTable ($ queries , $ type );
14571457 }
14581458
1459+ // Cursor pagination is per-table — paginating across both events and
1460+ // gauges has no coherent ordering, so reject this combination upfront.
1461+ foreach ($ queries as $ query ) {
1462+ $ method = $ query ->getMethod ();
1463+ if ($ method === Query::TYPE_CURSOR_AFTER || $ method === Query::TYPE_CURSOR_BEFORE ) {
1464+ throw new Exception ('Cursor pagination requires an explicit $type (event or gauge) ' );
1465+ }
1466+ }
1467+
14591468 // Query both tables with UNION ALL
14601469 $ events = $ this ->findFromTable ($ queries , Usage::TYPE_EVENT );
14611470 $ gauges = $ this ->findFromTable ($ queries , Usage::TYPE_GAUGE );
@@ -1483,19 +1492,41 @@ private function findFromTable(array $queries, string $type): array
14831492
14841493 $ parsed = $ this ->parseQueries ($ queries , $ type );
14851494
1495+ // Cursor pagination is incompatible with time-bucketed aggregation —
1496+ // aggregated rows have no stable identity to anchor a keyset cursor on.
1497+ if (isset ($ parsed ['cursor ' ]) && isset ($ parsed ['groupByInterval ' ])) {
1498+ throw new Exception ('Cursor pagination cannot be combined with groupByInterval ' );
1499+ }
1500+
14861501 // Check if groupByInterval is requested
14871502 if (isset ($ parsed ['groupByInterval ' ])) {
14881503 return $ this ->findAggregatedFromTable ($ parsed , $ fromTable , $ type );
14891504 }
14901505
14911506 $ selectColumns = $ this ->getSelectColumns ($ type );
14921507
1493- $ whereData = $ this ->buildWhereClause ($ parsed ['filters ' ], $ parsed ['params ' ]);
1508+ $ filters = $ parsed ['filters ' ];
1509+ $ params = $ parsed ['params ' ];
1510+ $ orderAttributes = $ parsed ['orderAttributes ' ] ?? [];
1511+ $ cursorDirection = $ parsed ['cursorDirection ' ] ?? null ;
1512+
1513+ if (isset ($ parsed ['cursor ' ])) {
1514+ $ resolvedOrder = $ this ->resolveCursorOrder ($ orderAttributes );
1515+ $ cursorWhere = $ this ->buildCursorWhere ($ resolvedOrder , $ parsed ['cursor ' ], $ cursorDirection ?? 'after ' , $ params );
1516+ $ filters [] = $ cursorWhere ['clause ' ];
1517+ $ params = $ cursorWhere ['params ' ];
1518+ $ orderAttributes = $ resolvedOrder ;
1519+ }
1520+
1521+ $ whereData = $ this ->buildWhereClause ($ filters , $ params );
14941522 $ whereClause = $ whereData ['clause ' ];
1495- $ parsed [ ' params ' ] = $ whereData ['params ' ];
1523+ $ params = $ whereData ['params ' ];
14961524
14971525 $ orderClause = '' ;
1498- if (!empty ($ parsed ['orderBy ' ])) {
1526+ if (isset ($ parsed ['cursor ' ]) && !empty ($ orderAttributes )) {
1527+ $ orderSql = $ this ->buildOrderBySql ($ orderAttributes , flip: $ cursorDirection === 'before ' );
1528+ $ orderClause = ' ORDER BY ' . implode (', ' , $ orderSql );
1529+ } elseif (!empty ($ parsed ['orderBy ' ])) {
14991530 $ orderClause = ' ORDER BY ' . implode (', ' , $ parsed ['orderBy ' ]);
15001531 }
15011532
@@ -1508,9 +1539,15 @@ private function findFromTable(array $queries, string $type): array
15081539 FORMAT JSON
15091540 " ;
15101541
1511- $ result = $ this ->query ($ sql , $ parsed ['params ' ]);
1542+ $ result = $ this ->query ($ sql , $ params );
1543+
1544+ $ rows = $ this ->parseResults ($ result , $ type );
1545+
1546+ if ($ cursorDirection === 'before ' ) {
1547+ $ rows = array_reverse ($ rows );
1548+ }
15121549
1513- return $ this -> parseResults ( $ result , $ type ) ;
1550+ return $ rows ;
15141551 }
15151552
15161553 /**
@@ -2380,22 +2417,199 @@ private function getParamType(string $attribute): string
23802417 return $ attribute === 'value ' ? 'Int64 ' : 'String ' ;
23812418 }
23822419
2420+ /**
2421+ * Normalize a user-supplied cursor row into a column-keyed array.
2422+ *
2423+ * Accepts a `Metric` (or any `ArrayObject`) or a plain associative array.
2424+ * `Metric` stores its identifier under `$id` (Appwrite convention) while
2425+ * the underlying column is `id` — this remaps `$id` → `id` so cursor
2426+ * pagination can match the SQL column.
2427+ *
2428+ * @param mixed $rawCursor
2429+ * @return array<string, mixed>
2430+ * @throws Exception
2431+ */
2432+ private function normalizeCursorRow (mixed $ rawCursor ): array
2433+ {
2434+ if ($ rawCursor instanceof \ArrayObject) {
2435+ /** @var array<string, mixed> $row */
2436+ $ row = $ rawCursor ->getArrayCopy ();
2437+ } elseif (is_array ($ rawCursor )) {
2438+ /** @var array<string, mixed> $rawCursor */
2439+ $ row = $ rawCursor ;
2440+ } else {
2441+ throw new Exception (
2442+ 'Invalid cursor value: expected ArrayObject (Metric) or associative array, got '
2443+ . get_debug_type ($ rawCursor )
2444+ );
2445+ }
2446+
2447+ if (!array_key_exists ('id ' , $ row ) && array_key_exists ('$id ' , $ row )) {
2448+ $ row ['id ' ] = $ row ['$id ' ];
2449+ }
2450+
2451+ return $ row ;
2452+ }
2453+
2454+ /**
2455+ * Resolve the effective order attributes for cursor pagination.
2456+ *
2457+ * Auto-appends `id` as a tiebreaker when not already present so keyset
2458+ * pagination is deterministic on non-unique columns (e.g. time).
2459+ *
2460+ * @param array<int, array{attribute: string, direction: string}> $orderAttributes
2461+ * @return array<int, array{attribute: string, direction: string}>
2462+ */
2463+ private function resolveCursorOrder (array $ orderAttributes ): array
2464+ {
2465+ foreach ($ orderAttributes as $ entry ) {
2466+ if ($ entry ['attribute ' ] === 'id ' ) {
2467+ return $ orderAttributes ;
2468+ }
2469+ }
2470+
2471+ $ defaultDirection = 'ASC ' ;
2472+ if (!empty ($ orderAttributes )) {
2473+ $ last = $ orderAttributes [count ($ orderAttributes ) - 1 ];
2474+ $ defaultDirection = $ last ['direction ' ];
2475+ }
2476+
2477+ $ orderAttributes [] = ['attribute ' => 'id ' , 'direction ' => $ defaultDirection ];
2478+
2479+ return $ orderAttributes ;
2480+ }
2481+
2482+ /**
2483+ * Build keyset-pagination WHERE fragments for cursor support.
2484+ *
2485+ * Produces a tuple-compare clause across the order attributes:
2486+ * (a > A) OR (a = A AND b > B) OR ...
2487+ *
2488+ * For cursor `before`, the comparison directions are flipped relative to
2489+ * the requested ORDER BY (the caller is responsible for also flipping the
2490+ * actual ORDER BY at SQL build time so the page comes back from the right
2491+ * side, then reversing the rows post-fetch).
2492+ *
2493+ * @param array<int, array{attribute: string, direction: string}> $orderAttributes
2494+ * @param array<string, mixed> $cursor
2495+ * @param string $cursorDirection 'after' or 'before'
2496+ * @param array<string, mixed> $params Existing params (mutated by adding cursor binds)
2497+ * @return array{clause: string, params: array<string, mixed>}
2498+ * @throws Exception
2499+ */
2500+ private function buildCursorWhere (array $ orderAttributes , array $ cursor , string $ cursorDirection , array $ params ): array
2501+ {
2502+ $ orderAttributes = $ this ->resolveCursorOrder ($ orderAttributes );
2503+
2504+ $ tuples = [];
2505+ foreach ($ orderAttributes as $ i => $ entry ) {
2506+ $ attr = $ entry ['attribute ' ];
2507+ $ direction = $ entry ['direction ' ];
2508+
2509+ if (!array_key_exists ($ attr , $ cursor )) {
2510+ throw new \Exception ("Cursor is missing required attribute ' {$ attr }' " );
2511+ }
2512+
2513+ // Flip comparison direction for `before` so we paginate to the previous page.
2514+ if ($ cursorDirection === 'before ' ) {
2515+ $ direction = $ direction === 'DESC ' ? 'ASC ' : 'DESC ' ;
2516+ }
2517+
2518+ $ conditions = [];
2519+
2520+ for ($ j = 0 ; $ j < $ i ; $ j ++) {
2521+ $ prev = $ orderAttributes [$ j ];
2522+ $ prevAttr = $ prev ['attribute ' ];
2523+ if (!array_key_exists ($ prevAttr , $ cursor )) {
2524+ throw new \Exception ("Cursor is missing required attribute ' {$ prevAttr }' " );
2525+ }
2526+ $ prevValue = $ cursor [$ prevAttr ];
2527+ $ prevEscaped = $ this ->escapeIdentifier ($ prevAttr );
2528+ $ prevType = $ this ->getParamType ($ prevAttr );
2529+ $ paramName = "cursor_eq_ {$ i }_ {$ j }" ;
2530+
2531+ if ($ prevAttr === 'time ' ) {
2532+ /** @var \DateTime|string|null $timeValue */
2533+ $ timeValue = $ prevValue ;
2534+ $ conditions [] = "{$ prevEscaped } = { {$ paramName }:DateTime64(3)} " ;
2535+ $ params [$ paramName ] = $ this ->formatDateTime ($ timeValue );
2536+ } else {
2537+ /** @var bool|float|int|string|null $scalarValue */
2538+ $ scalarValue = $ prevValue ;
2539+ $ conditions [] = "{$ prevEscaped } = { {$ paramName }: {$ prevType }} " ;
2540+ $ params [$ paramName ] = $ this ->formatParamValue ($ scalarValue );
2541+ }
2542+ }
2543+
2544+ $ value = $ cursor [$ attr ];
2545+ $ escaped = $ this ->escapeIdentifier ($ attr );
2546+ $ chType = $ this ->getParamType ($ attr );
2547+ $ operator = $ direction === 'DESC ' ? '< ' : '> ' ;
2548+ $ paramName = "cursor_cmp_ {$ i }" ;
2549+
2550+ if ($ attr === 'time ' ) {
2551+ /** @var \DateTime|string|null $timeValue */
2552+ $ timeValue = $ value ;
2553+ $ conditions [] = "{$ escaped } {$ operator } { {$ paramName }:DateTime64(3)} " ;
2554+ $ params [$ paramName ] = $ this ->formatDateTime ($ timeValue );
2555+ } else {
2556+ /** @var bool|float|int|string|null $scalarValue */
2557+ $ scalarValue = $ value ;
2558+ $ conditions [] = "{$ escaped } {$ operator } { {$ paramName }: {$ chType }} " ;
2559+ $ params [$ paramName ] = $ this ->formatParamValue ($ scalarValue );
2560+ }
2561+
2562+ $ tuples [] = '( ' . implode (' AND ' , $ conditions ) . ') ' ;
2563+ }
2564+
2565+ return [
2566+ 'clause ' => '( ' . implode (' OR ' , $ tuples ) . ') ' ,
2567+ 'params ' => $ params ,
2568+ ];
2569+ }
2570+
2571+ /**
2572+ * Build the ORDER BY SQL fragment list, optionally flipping all directions.
2573+ *
2574+ * Used when cursor direction is `before` — we run the query in reverse to
2575+ * grab the previous-page rows, then `array_reverse` the result.
2576+ *
2577+ * @param array<int, array{attribute: string, direction: string}> $orderAttributes
2578+ * @param bool $flip Whether to flip ASC↔DESC
2579+ * @return array<string>
2580+ */
2581+ private function buildOrderBySql (array $ orderAttributes , bool $ flip = false ): array
2582+ {
2583+ $ sql = [];
2584+ foreach ($ orderAttributes as $ entry ) {
2585+ $ direction = $ entry ['direction ' ];
2586+ if ($ flip ) {
2587+ $ direction = $ direction === 'DESC ' ? 'ASC ' : 'DESC ' ;
2588+ }
2589+ $ sql [] = $ this ->escapeIdentifier ($ entry ['attribute ' ]) . ' ' . $ direction ;
2590+ }
2591+ return $ sql ;
2592+ }
2593+
23832594 /**
23842595 * Parse Query objects into SQL clauses.
23852596 *
23862597 * @param array<Query> $queries
23872598 * @param string $type 'event' or 'gauge' — used for attribute validation
2388- * @return array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, limit?: int, offset?: int, groupByInterval?: string}
2599+ * @return array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, orderAttributes?: array<int, array{attribute: string, direction: string}>, limit?: int, offset?: int, groupByInterval?: string, cursor?: array<string, mixed>, cursorDirection ?: string}
23892600 * @throws Exception
23902601 */
23912602 private function parseQueries (array $ queries , string $ type = 'event ' ): array
23922603 {
23932604 $ filters = [];
23942605 $ params = [];
23952606 $ orderBy = [];
2607+ $ orderAttributes = [];
23962608 $ limit = null ;
23972609 $ offset = null ;
23982610 $ groupByInterval = null ;
2611+ $ cursor = null ;
2612+ $ cursorDirection = null ;
23992613 $ paramCounter = 0 ;
24002614
24012615 foreach ($ queries as $ query ) {
@@ -2504,12 +2718,28 @@ private function parseQueries(array $queries, string $type = 'event'): array
25042718 $ this ->validateAttributeName ($ attribute , $ type );
25052719 $ escapedAttr = $ this ->escapeIdentifier ($ attribute );
25062720 $ orderBy [] = "{$ escapedAttr } DESC " ;
2721+ $ orderAttributes [] = ['attribute ' => $ attribute , 'direction ' => 'DESC ' ];
25072722 break ;
25082723
25092724 case Query::TYPE_ORDER_ASC :
25102725 $ this ->validateAttributeName ($ attribute , $ type );
25112726 $ escapedAttr = $ this ->escapeIdentifier ($ attribute );
25122727 $ orderBy [] = "{$ escapedAttr } ASC " ;
2728+ $ orderAttributes [] = ['attribute ' => $ attribute , 'direction ' => 'ASC ' ];
2729+ break ;
2730+
2731+ case Query::TYPE_CURSOR_AFTER :
2732+ case Query::TYPE_CURSOR_BEFORE :
2733+ if ($ cursor !== null ) {
2734+ // Keep the first cursor encountered (matches base groupByType semantics)
2735+ break ;
2736+ }
2737+ $ rawCursor = $ values [0 ] ?? null ;
2738+ if ($ rawCursor === null ) {
2739+ break ; // no-op cursor
2740+ }
2741+ $ cursor = $ this ->normalizeCursorRow ($ rawCursor );
2742+ $ cursorDirection = $ method === Query::TYPE_CURSOR_AFTER ? 'after ' : 'before ' ;
25132743 break ;
25142744
25152745 case Query::TYPE_CONTAINS :
@@ -2627,6 +2857,7 @@ private function parseQueries(array $queries, string $type = 'event'): array
26272857
26282858 if (!empty ($ orderBy )) {
26292859 $ result ['orderBy ' ] = $ orderBy ;
2860+ $ result ['orderAttributes ' ] = $ orderAttributes ;
26302861 }
26312862
26322863 if ($ limit !== null ) {
@@ -2641,6 +2872,11 @@ private function parseQueries(array $queries, string $type = 'event'): array
26412872 $ result ['groupByInterval ' ] = $ groupByInterval ;
26422873 }
26432874
2875+ if ($ cursor !== null && $ cursorDirection !== null ) {
2876+ $ result ['cursor ' ] = $ cursor ;
2877+ $ result ['cursorDirection ' ] = $ cursorDirection ;
2878+ }
2879+
26442880 return $ result ;
26452881 }
26462882
0 commit comments