File tree Expand file tree Collapse file tree
tests/Database/Functional/Driver Expand file tree Collapse file tree Original file line number Diff line number Diff 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 *
Original file line number Diff line number Diff 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
Original file line number Diff line number Diff 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 }" ;
Original file line number Diff line number Diff 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
Original file line number Diff line number Diff 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 }] " ;
Original file line number Diff line number Diff 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 );
Original file line number Diff line number Diff 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];
You can’t perform that action at this time.
0 commit comments