Skip to content

Commit 87512b0

Browse files
committed
feat: Add Driver CursorableInterface
1 parent 279e3bb commit 87512b0

15 files changed

Lines changed: 824 additions & 3 deletions

File tree

‎src/Database.php‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@
1313

1414
use Cycle\Database\Driver\Driver;
1515
use Cycle\Database\Driver\DriverInterface;
16+
use Cycle\Database\Driver\CursorableInterface;
17+
use Cycle\Database\Driver\CursorOptions;
18+
use Cycle\Database\Exception\DriverException;
1619
use Cycle\Database\Query\DeleteQuery;
1720
use Cycle\Database\Query\InsertQuery;
21+
use Cycle\Database\Query\QueryParameters;
1822
use Cycle\Database\Query\SelectQuery;
1923
use Cycle\Database\Query\UpdateQuery;
2024

@@ -131,6 +135,40 @@ public function query(string $query, array $parameters = []): StatementInterface
131135
->query($query, $parameters);
132136
}
133137

138+
/**
139+
* Open a server-side cursor for a compiled SELECT query and yield rows lazily.
140+
*
141+
* Not on {@see DatabaseInterface} for BC; exposed via `@method` annotation.
142+
* The read driver must implement {@see CursorableInterface} (Postgres, SQLite,
143+
* SQL Server); otherwise a {@see DriverException} is thrown. Cursor semantics —
144+
* including snapshot consistency within the transaction — are preserved:
145+
* this method intentionally has no fallback strategy.
146+
*
147+
* Driver-specific knobs are passed via {@see CursorOptions} subclasses
148+
* (e.g. {@see \Cycle\Database\Driver\Postgres\PostgresCursorOptions},
149+
* {@see \Cycle\Database\Driver\SQLServer\SQLServerCursorOptions}).
150+
*
151+
* @return \Generator<int, array<array-key, mixed>>
152+
*/
153+
public function cursor(
154+
SelectQuery $query,
155+
CursorOptions $options = new CursorOptions(),
156+
int $mode = StatementInterface::FETCH_ASSOC,
157+
): \Generator {
158+
$driver = $this->getDriver(self::READ);
159+
if (!$driver instanceof CursorableInterface) {
160+
throw new DriverException(\sprintf(
161+
'Server-side cursors are not supported for driver `%s`.',
162+
$driver->getType(),
163+
));
164+
}
165+
166+
$parameters = new QueryParameters();
167+
$sql = $query->sqlStatement($parameters);
168+
169+
return $driver->cursor($sql, $parameters->getParameters(), $options, $mode);
170+
}
171+
134172
public function insert(?string $table = null): InsertQuery
135173
{
136174
return $this->getDriver(self::WRITE)

‎src/DatabaseInterface.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
*
2626
* @method DatabaseInterface withoutCache() Get a new Database instance without query cache or the same instance
2727
* if no cache is used. Will be added the next major release.
28+
* @method \Generator cursor(SelectQuery $query, \Cycle\Database\Driver\CursorOptions $options = new \Cycle\Database\Driver\CursorOptions(), int $mode = StatementInterface::FETCH_ASSOC)
29+
* Open a server-side cursor for the SELECT query and yield rows lazily, with snapshot
30+
* consistency for the duration of the enclosing transaction. Requires the read driver to
31+
* implement {@see \Cycle\Database\Driver\CursorableInterface} (Postgres, SQLite, SQL Server)
32+
* and an active transaction. Driver-specific knobs (FETCH FORWARD batch, WITH HOLD,
33+
* SQL Server cursor type, …) are passed via driver-specific {@see CursorOptions} subclasses.
34+
* Throws {@see \Cycle\Database\Exception\DriverException} when the driver lacks cursor
35+
* support. Will be added to the interface in the next major release.
2836
*/
2937
interface DatabaseInterface
3038
{

‎src/Driver/CursorOptions.php‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cycle\Database\Driver;
6+
7+
/**
8+
* Cross-driver options for {@see CursorableInterface::cursor()}.
9+
*
10+
* The base class is intentionally empty — it serves as a typed null-object for
11+
* drivers that have no extra knobs (SQLite) and as a marker for the
12+
* polymorphic parameter accepted by {@see CursorableInterface::cursor()}.
13+
* Drivers that need additional configuration (FETCH FORWARD batch size,
14+
* snapshot type, WITH HOLD, etc.) extend this class with their own option
15+
* DTOs and narrow the generic instance via their `from()` factory.
16+
*/
17+
class CursorOptions
18+
{
19+
public function __construct() {}
20+
}

‎src/Driver/CursorableInterface.php‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cycle\Database\Driver;
6+
7+
use Cycle\Database\Exception\DriverException;
8+
use Cycle\Database\Exception\StatementException;
9+
use Cycle\Database\StatementInterface;
10+
11+
/**
12+
* Implemented by drivers capable of opening a true server-side cursor over a
13+
* SELECT result set.
14+
*
15+
* Unlike a generic "lazy iterator" abstraction, a server-side cursor offers
16+
* **snapshot consistency** for the duration of its enclosing transaction:
17+
* rows inserted or modified after the cursor is opened do not appear in the
18+
* stream, regardless of how long iteration takes. This is the property that
19+
* distinguishes a cursor from a paginator or an unbuffered fetch — and the
20+
* reason this interface exposes the feature **without any fallback**.
21+
*
22+
* Drivers that cannot offer those guarantees must not implement this
23+
* interface. Callers that want a portable lazy iterator should build their
24+
* own (e.g. keyset pagination) on top of plain queries instead.
25+
*/
26+
interface CursorableInterface extends DriverInterface
27+
{
28+
/**
29+
* Open a server-side cursor for the given SELECT statement and yield rows
30+
* from it lazily.
31+
*
32+
* Implementations require an active transaction on the connection for the
33+
* cursor's lifetime; they must throw a {@see DriverException} when the
34+
* preconditions are not met. The cursor is released when the generator is
35+
* fully consumed, garbage-collected, or the consumer breaks out of the
36+
* iteration.
37+
*
38+
* @param iterable $parameters Query parameters (positional or named) bound to the SELECT.
39+
* @param CursorOptions $options Driver-specific cursor configuration. Drivers narrow the
40+
* type internally via their own `from()` factory (e.g.
41+
* {@see Postgres\PostgresCursorOptions::from()}).
42+
* @param int $mode Row representation mode, one of {@see StatementInterface}::FETCH_*.
43+
* @psalm-param non-empty-string $statement
44+
*
45+
* @return \Generator<int, array<array-key, mixed>>
46+
*
47+
* @throws DriverException
48+
* @throws StatementException
49+
*/
50+
public function cursor(
51+
string $statement,
52+
iterable $parameters = [],
53+
CursorOptions $options = new CursorOptions(),
54+
int $mode = StatementInterface::FETCH_ASSOC,
55+
): \Generator;
56+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cycle\Database\Driver\Postgres;
6+
7+
use Cycle\Database\Driver\CursorOptions;
8+
9+
/**
10+
* Postgres-specific cursor options.
11+
*
12+
* Maps to clauses of `DECLARE … CURSOR …` syntax.
13+
*/
14+
final class PostgresCursorOptions extends CursorOptions
15+
{
16+
/**
17+
* @param int<1, max> $chunkSize Number of rows pulled by each `FETCH FORWARD N FROM cursor`
18+
* round-trip. Higher values reduce network overhead at the cost of buffering
19+
* a larger result chunk in client memory between yields.
20+
* @param bool $withHold When true, declares the cursor `WITH HOLD`. The cursor materializes
21+
* the rest of its result on the server at `COMMIT` time and remains open for further
22+
* `FETCH` calls outside the transaction. Use for long-running exports that should
23+
* not keep a write-blocking transaction alive.
24+
*/
25+
public function __construct(
26+
public readonly int $chunkSize = 1000,
27+
public readonly bool $withHold = false,
28+
) {
29+
parent::__construct();
30+
}
31+
32+
public static function from(CursorOptions $options): self
33+
{
34+
return $options instanceof self ? $options : new self();
35+
}
36+
}

‎src/Driver/Postgres/PostgresDriver.php‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,17 @@
1919
use Cycle\Database\Driver\Postgres\Query\PostgresInsertQuery;
2020
use Cycle\Database\Driver\Postgres\Query\PostgresSelectQuery;
2121
use Cycle\Database\Driver\Postgres\Query\PostgresUpdateQuery;
22+
use Cycle\Database\Driver\CursorableInterface;
23+
use Cycle\Database\Driver\CursorOptions;
2224
use Cycle\Database\Exception\DriverException;
2325
use Cycle\Database\Exception\StatementException;
2426
use Cycle\Database\Query\QueryBuilder;
27+
use Cycle\Database\StatementInterface;
2528

2629
/**
2730
* Talks to postgres databases.
2831
*/
29-
class PostgresDriver extends Driver
32+
class PostgresDriver extends Driver implements CursorableInterface
3033
{
3134
/**
3235
* Cached list of primary keys associated with their table names. Used by InsertBuilder to
@@ -196,6 +199,74 @@ public function beginTransaction(?string $isolationLevel = null): bool
196199
return true;
197200
}
198201

202+
/**
203+
* Open a Postgres server-side cursor for the given SELECT and yield rows
204+
* lazily. Provides snapshot consistency within the enclosing transaction.
205+
*
206+
* Requires an active transaction (cursor lifetime is bound to it unless
207+
* {@see PostgresCursorOptions::$withHold} is set, in which case the cursor
208+
* survives `COMMIT` and the result is materialized on the server). The
209+
* cursor is declared with NO SCROLL — only forward fetches are performed.
210+
* The cursor is closed when the generator is fully consumed or
211+
* garbage-collected.
212+
*
213+
* @return \Generator<int, array<array-key, mixed>>
214+
*
215+
* @throws DriverException
216+
*/
217+
public function cursor(
218+
string $statement,
219+
iterable $parameters = [],
220+
CursorOptions $options = new CursorOptions(),
221+
int $mode = StatementInterface::FETCH_ASSOC,
222+
): \Generator {
223+
$opts = PostgresCursorOptions::from($options);
224+
225+
if ($opts->chunkSize < 1) {
226+
throw new DriverException('Chunk size must be a positive integer.');
227+
}
228+
229+
if ($this->getTransactionLevel() === 0) {
230+
throw new DriverException(
231+
'Postgres server-side cursor requires an active transaction. '
232+
. 'Wrap the cursor iteration in Database::transaction() or call beginTransaction() before cursor().',
233+
);
234+
}
235+
236+
$cursorName = '"c_' . \bin2hex(\random_bytes(8)) . '"';
237+
$holdClause = $opts->withHold ? ' WITH HOLD' : '';
238+
$declareSql = "DECLARE {$cursorName} NO SCROLL CURSOR{$holdClause} FOR {$statement}";
239+
$fetchSql = "FETCH FORWARD {$opts->chunkSize} FROM {$cursorName}";
240+
$closeSql = "CLOSE {$cursorName}";
241+
242+
try {
243+
$this->statement($declareSql, $parameters);
244+
245+
do {
246+
$chunkStatement = $this->statement($fetchSql);
247+
$rows = $chunkStatement->fetchAll($mode);
248+
$chunkStatement->close();
249+
250+
foreach ($rows as $row) {
251+
yield $row;
252+
}
253+
} while (\count($rows) === $opts->chunkSize);
254+
} finally {
255+
try {
256+
$this->statement($closeSql);
257+
} catch (\Throwable) {
258+
// Cursor may already be gone (e.g. transaction was rolled back) — swallow.
259+
}
260+
261+
// Avoid polluting the prepared-statement cache with single-use SQL strings.
262+
unset(
263+
$this->queryCache[$declareSql],
264+
$this->queryCache[$fetchSql],
265+
$this->queryCache[$closeSql],
266+
);
267+
}
268+
}
269+
199270
/**
200271
* Parse the table name and extract the schema and table.
201272
*
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cycle\Database\Driver\SQLServer;
6+
7+
use Cycle\Database\Driver\CursorOptions;
8+
9+
/**
10+
* SQL Server-specific cursor options.
11+
*
12+
* Maps to clauses of T-SQL `DECLARE … CURSOR …` syntax.
13+
*/
14+
final class SQLServerCursorOptions extends CursorOptions
15+
{
16+
/**
17+
* @param SQLServerCursorType $type Cursor flavor (snapshot semantics — see {@see SQLServerCursorType}).
18+
* The default {@see SQLServerCursorType::Static} fulfills the snapshot-consistency
19+
* contract of {@see \Cycle\Database\Driver\CursorableInterface}; other modes
20+
* trade snapshot guarantees for different visibility semantics.
21+
*/
22+
public function __construct(
23+
public readonly SQLServerCursorType $type = SQLServerCursorType::Static,
24+
) {
25+
parent::__construct();
26+
}
27+
28+
public static function from(CursorOptions $options): self
29+
{
30+
return $options instanceof self ? $options : new self();
31+
}
32+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cycle\Database\Driver\SQLServer;
6+
7+
/**
8+
* Type of SQL Server server-side cursor.
9+
*
10+
* Controls what the cursor sees over its lifetime versus the underlying table state.
11+
* Only {@see self::Static} fulfills the snapshot-consistency contract of
12+
* {@see \Cycle\Database\Driver\CursorableInterface}; other modes are exposed for
13+
* advanced use cases that accept different consistency trade-offs.
14+
*/
15+
enum SQLServerCursorType: string
16+
{
17+
/**
18+
* `STATIC` — SQL Server copies the result set into `tempdb` at `OPEN` time;
19+
* the cursor reads from that snapshot and is unaffected by concurrent
20+
* INSERTs / UPDATEs / DELETEs.
21+
*/
22+
case Static = 'STATIC';
23+
24+
/**
25+
* `KEYSET` — keys are frozen at `OPEN` time, but column values are
26+
* re-read on each `FETCH`. Updates to existing rows ARE visible; inserts
27+
* are not. Deleted rows show up as missing.
28+
*
29+
* Not snapshot-consistent in the strict sense.
30+
*/
31+
case Keyset = 'KEYSET';
32+
33+
/**
34+
* `DYNAMIC` — the result set is fully re-evaluated on every `FETCH`. Inserts,
35+
* updates, and deletes by other transactions are all visible. No snapshot.
36+
*/
37+
case Dynamic = 'DYNAMIC';
38+
39+
/**
40+
* `FAST_FORWARD` — performance-optimized forward-only read-only cursor. Implemented
41+
* as a dynamic cursor under the hood, so it offers no snapshot. Useful only when
42+
* you trade consistency for throughput.
43+
*/
44+
case FastForward = 'FAST_FORWARD';
45+
}

0 commit comments

Comments
 (0)