Skip to content

Commit 4298db5

Browse files
lohanidamodarclaude
andcommitted
feat: add Query::cursor support to ClickHouse adapter
Adds keyset-pagination cursor support (cursorAfter / cursorBefore) to the ClickHouse adapter via parseQueries. Cursor values accept Metric/ArrayObject or plain associative arrays; an `id` tiebreaker is auto-appended to ORDER BY so pagination is deterministic on non-unique columns. cursorBefore flips direction at SQL build time and reverses results post-fetch. Rejects two unsafe combinations: cursor + groupByInterval (no stable identity on aggregated rows), and cursor + null type (paginating across events and gauges has no coherent ordering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 40398f5 commit 4298db5

2 files changed

Lines changed: 360 additions & 6 deletions

File tree

src/Usage/Adapter/ClickHouse.php

Lines changed: 242 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)