Skip to content

Commit f28642c

Browse files
committed
refactor: Add cursor name parameter to PG and MSSQL options
1 parent 608096a commit f28642c

7 files changed

Lines changed: 86 additions & 6 deletions

File tree

src/Driver/CursorableInterface.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ interface CursorableInterface extends DriverInterface
3737
*
3838
* @param iterable $parameters Query parameters (positional or named) bound to the SELECT.
3939
* @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()}).
40+
* type internally via their own `from()` factory (e.g. {@see Postgres\PostgresCursorOptions::from()}).
4241
* @param int $mode Row representation mode, one of {@see StatementInterface}::FETCH_*.
4342
* @psalm-param non-empty-string $statement
4443
*

src/Driver/Postgres/PostgresCursorOptions.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,25 @@ final class PostgresCursorOptions extends CursorOptions
2121
* the rest of its result on the server at `COMMIT` time and remains open for further
2222
* `FETCH` calls outside the transaction. Use for long-running exports that should
2323
* not keep a write-blocking transaction alive.
24+
* @param string|null $name Optional cursor name. When null, the driver generates a random
25+
* unique name per call (collision-free, suitable for reusing the same options DTO
26+
* across multiple invocations). When set, the value is used verbatim — caller is
27+
* responsible for uniqueness. Must be a valid PostgreSQL identifier
28+
* (letter/underscore start, alphanumerics/underscores, ≤63 chars).
2429
*/
2530
public function __construct(
2631
public readonly int $chunkSize = 1000,
2732
public readonly bool $withHold = false,
33+
public readonly ?string $name = null,
2834
) {
35+
if ($name !== null && (!\preg_match('/^[a-zA-Z_][\w]*$/', $name) || \strlen($name) > 63)) {
36+
throw new \InvalidArgumentException(\sprintf(
37+
'Cursor name `%s` must be a valid PostgreSQL identifier: start with letter or underscore, '
38+
. 'contain only alphanumerics and underscores, and be ≤63 characters.',
39+
$name,
40+
));
41+
}
42+
2943
parent::__construct();
3044
}
3145

src/Driver/Postgres/PostgresDriver.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,10 +214,11 @@ public function beginTransaction(?string $isolationLevel = null): bool
214214
*
215215
* @throws DriverException
216216
*/
217+
#[\Override]
217218
public function cursor(
218219
string $statement,
219220
iterable $parameters = [],
220-
CursorOptions $options = new CursorOptions(),
221+
CursorOptions $options = new PostgresCursorOptions(),
221222
int $mode = StatementInterface::FETCH_ASSOC,
222223
): \Generator {
223224
$opts = PostgresCursorOptions::from($options);
@@ -233,7 +234,7 @@ public function cursor(
233234
);
234235
}
235236

236-
$cursorName = '"c_' . \bin2hex(\random_bytes(8)) . '"';
237+
$cursorName = '"' . ($opts->name ?? 'c_' . \bin2hex(\random_bytes(8))) . '"';
237238
$holdClause = $opts->withHold ? ' WITH HOLD' : '';
238239
$declareSql = "DECLARE {$cursorName} NO SCROLL CURSOR{$holdClause} FOR {$statement}";
239240
$fetchSql = "FETCH FORWARD {$opts->chunkSize} FROM {$cursorName}";

src/Driver/SQLServer/SQLServerCursorOptions.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,24 @@ final class SQLServerCursorOptions extends CursorOptions
1818
* The default {@see CursorType::Static} fulfills the snapshot-consistency
1919
* contract of {@see \Cycle\Database\Driver\CursorableInterface}; other modes
2020
* trade snapshot guarantees for different visibility semantics.
21+
* @param string|null $name Optional cursor name. When null, the driver generates a random
22+
* unique name per call (collision-free, suitable for reusing the same options DTO
23+
* across multiple invocations). When set, the value is used verbatim — caller is
24+
* responsible for uniqueness. Must be a valid T-SQL identifier
25+
* (letter/underscore start, alphanumerics/underscores, ≤128 chars).
2126
*/
2227
public function __construct(
2328
public readonly CursorType $type = CursorType::Static,
29+
public readonly ?string $name = null,
2430
) {
31+
if ($name !== null && (!\preg_match('/^[a-zA-Z_][\w]*$/', $name) || \strlen($name) > 128)) {
32+
throw new \InvalidArgumentException(\sprintf(
33+
'Cursor name `%s` must be a valid T-SQL identifier: start with letter or underscore, '
34+
. 'contain only alphanumerics and underscores, and be ≤128 characters.',
35+
$name,
36+
));
37+
}
38+
2539
parent::__construct();
2640
}
2741

src/Driver/SQLServer/SQLServerDriver.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,11 @@ public function getType(): string
8787
*
8888
* @throws DriverException
8989
*/
90+
#[\Override]
9091
public function cursor(
9192
string $statement,
9293
iterable $parameters = [],
93-
CursorOptions $options = new CursorOptions(),
94+
CursorOptions $options = new SQLServerCursorOptions(),
9495
int $mode = StatementInterface::FETCH_ASSOC,
9596
): \Generator {
9697
$opts = SQLServerCursorOptions::from($options);
@@ -102,7 +103,7 @@ public function cursor(
102103
);
103104
}
104105

105-
$cursorName = 'c_' . \bin2hex(\random_bytes(8));
106+
$cursorName = $opts->name ?? 'c_' . \bin2hex(\random_bytes(8));
106107
$declareSql = "DECLARE [{$cursorName}] CURSOR GLOBAL FORWARD_ONLY {$opts->type->value} READ_ONLY FOR {$statement}";
107108
$openSql = "OPEN [{$cursorName}]";
108109
$fetchSql = "FETCH NEXT FROM [{$cursorName}]";

tests/Database/Functional/Driver/Postgres/Driver/CursorTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,32 @@ public function testCursorChunkSizeMustBePositive(): void
7373
);
7474
}
7575

76+
public function testCursorUsesExplicitName(): void
77+
{
78+
$this->fillRows(3);
79+
$name = 'my_export_cursor';
80+
81+
$rows = $this->database->transaction(
82+
fn() => \iterator_to_array(
83+
$this->database->cursor(
84+
$this->database->select()->from('sample_table')->orderBy('id'),
85+
new PostgresCursorOptions(name: $name),
86+
),
87+
false,
88+
),
89+
);
90+
91+
$this->assertCount(3, $rows);
92+
}
93+
94+
public function testCursorRejectsInvalidName(): void
95+
{
96+
$this->expectException(\InvalidArgumentException::class);
97+
$this->expectExceptionMessageMatches('/valid postgresql identifier/i');
98+
99+
new PostgresCursorOptions(name: 'bad"name; DROP TABLE');
100+
}
101+
76102
public function testCursorWithHoldSurvivesCommit(): void
77103
{
78104
$this->fillRows(5);

tests/Database/Functional/Driver/SQLServer/Driver/CursorTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,31 @@ public function testCursorRespectsTypeOption(CursorType $type): void
3939
$this->assertSame(40, (int) $rows[4]['value']);
4040
}
4141

42+
public function testCursorUsesExplicitName(): void
43+
{
44+
$this->fillRows(3);
45+
46+
$rows = $this->database->transaction(
47+
fn() => \iterator_to_array(
48+
$this->database->cursor(
49+
$this->database->select()->from('sample_table')->orderBy('id'),
50+
new SQLServerCursorOptions(name: 'my_export_cursor'),
51+
),
52+
false,
53+
),
54+
);
55+
56+
$this->assertCount(3, $rows);
57+
}
58+
59+
public function testCursorRejectsInvalidName(): void
60+
{
61+
$this->expectException(\InvalidArgumentException::class);
62+
$this->expectExceptionMessageMatches('/valid t-sql identifier/i');
63+
64+
new SQLServerCursorOptions(name: 'bad name');
65+
}
66+
4267
public function cursorTypes(): \Generator
4368
{
4469
yield 'STATIC' => [CursorType::Static];

0 commit comments

Comments
 (0)