Skip to content

feat: Add server-side cursors for streaming SELECT results#247

Merged
roxblnfk merged 4 commits into
2.xfrom
cursors
May 12, 2026
Merged

feat: Add server-side cursors for streaming SELECT results#247
roxblnfk merged 4 commits into
2.xfrom
cursors

Conversation

@roxblnfk
Copy link
Copy Markdown
Member

@roxblnfk roxblnfk commented May 12, 2026

🔍 What was changed

Adds a server-side cursor API for streaming large SELECT results lazily, without buffering the full result set client-side and with snapshot consistency for the duration of the enclosing transaction.

New API:

  • Cycle\Database\Driver\CursorableInterface (extends DriverInterface) — capability marker. Implemented by PostgresDriver, SQLiteDriver, SQLServerDriver.
  • Cycle\Database\Driver\CursorOptions — empty base DTO, the polymorphic parameter for cursor().
  • Cycle\Database\Driver\Postgres\PostgresCursorOptionschunkSize (FETCH FORWARD N), withHold (cursor survives COMMIT).
  • Cycle\Database\Driver\SQLServer\SQLServerCursorOptions + CursorType enum — choose between STATIC / KEYSET / DYNAMIC / FAST_FORWARD.
  • Database::cursor(SelectQuery, CursorOptions, int $mode) — exposed via @method on DatabaseInterface for BC.

Driver implementations:

  • PostgresDECLARE <name> NO SCROLL CURSOR [WITH HOLD] FOR <sql>FETCH FORWARD N loop → CLOSE in finally. Randomized cursor name; prepared-statement cache cleaned per call.
  • SQLite — engine is natively row-oriented (sqlite3_step() per fetch()); snapshot is provided by the transaction (WAL reader snapshot or rollback-journal SHARED lock).
  • SQL ServerDECLARE [<name>] CURSOR GLOBAL FORWARD_ONLY <TYPE> READ_ONLY FOR <sql>, default STATIC. GLOBAL is required because each prepared statement in pdo_sqlsrv is its own batch.

All three require an active transaction and throw DriverException otherwise. There is intentionally no fallback for drivers without cursor support — silently substituting a paginator would change the consistency contract.

🤔 Why?

The use case is reading large tables (exports, migrations, bulk processing) without loading everything into memory. Database::query() / SelectQuery::run() returns a StatementInterface that for Postgres buffers the full result client-side in libpq before any row is available — iterating row-by-row gives no memory benefit.

A server-side cursor solves this and additionally offers snapshot consistency in the enclosing transaction: rows inserted/updated by other transactions after the cursor is opened do not appear in the stream. That property is what distinguishes a cursor from a keyset paginator or LIMIT/OFFSET walk — and it is what callers actually need for correct exports of moving tables.

The API exposes this honestly. CursorableInterface is implemented only by drivers that can offer the snapshot-consistency contract. MySQL is intentionally not included: SQL cursors live only inside stored procedures there, and unbuffered queries do not give snapshot guarantees on their own — only the isolation level does. Calling Database::cursor() on MySQL throws a clear DriverException.

Driver-specific knobs go through CursorOptions subclasses with a from(CursorOptions): self factory that falls back to defaults when a generic instance is passed. Cross-driver code stays clean; tuning (Postgres WITH HOLD, SQL Server cursor type) opts in by passing the matching DTO.

DatabaseInterface is unchanged — the method is exposed via @method (same pattern as withoutCache). DriverInterface is also unchanged — cursor() lives on the separate CursorableInterface that drivers opt into.

📝 Checklist

  • Closes #
  • How was this tested:
    • Tested manually
    • Unit tests added

Functional tests per driver:

  • Common abstract CursorTest — transaction requirement, small/large datasets, empty result, FETCH_NUM mode, bound parameters, early break (cursor must be released), clean completion (subsequent queries work in the same transaction).
  • Postgres CursorTest adds: multi-chunk FETCH FORWARD behavior, exact-chunk-boundary, chunkSize >= 1 validation, WITH HOLD (cursor survives COMMIT).
  • SQL Server CursorTest adds: parameterized test exercising all four SQLServerCursorType variants.

📃 Documentation

Docblocks on all new public classes describe the contracts: CursorableInterface (snapshot guarantee and no-fallback rationale), PostgresCursorOptions / SQLServerCursorOptions / SQLServerCursorType (underlying SQL clauses, trade-offs of each variant), Database::cursor() and driver-level cursor() (transaction requirements, cursor lifecycle, which DTO to pass per driver).

@codecov
Copy link
Copy Markdown

codecov Bot commented May 12, 2026

Codecov Report

❌ Patch coverage is 97.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.43%. Comparing base (279e3bb) to head (f9c9c49).

Files with missing lines Patch % Lines
src/Driver/SQLServer/SQLServerDriver.php 93.75% 2 Missing ⚠️
src/Driver/Postgres/PostgresDriver.php 96.29% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##                2.x     #247      +/-   ##
============================================
+ Coverage     95.40%   95.43%   +0.02%     
- Complexity     1913     1942      +29     
============================================
  Files           131      133       +2     
  Lines          5314     5414     +100     
============================================
+ Hits           5070     5167      +97     
- Misses          244      247       +3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@roxblnfk roxblnfk requested a review from a team as a code owner May 12, 2026 11:26
@roxblnfk roxblnfk merged commit 2acc10e into 2.x May 12, 2026
32 checks passed
@roxblnfk roxblnfk deleted the cursors branch May 12, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant