Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔍 What was changed
Adds a server-side cursor API for streaming large
SELECTresults 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(extendsDriverInterface) — capability marker. Implemented byPostgresDriver,SQLiteDriver,SQLServerDriver.Cycle\Database\Driver\CursorOptions— empty base DTO, the polymorphic parameter forcursor().Cycle\Database\Driver\Postgres\PostgresCursorOptions—chunkSize(FETCH FORWARD N),withHold(cursor survivesCOMMIT).Cycle\Database\Driver\SQLServer\SQLServerCursorOptions+CursorTypeenum — choose betweenSTATIC/KEYSET/DYNAMIC/FAST_FORWARD.Database::cursor(SelectQuery, CursorOptions, int $mode)— exposed via@methodonDatabaseInterfacefor BC.Driver implementations:
DECLARE <name> NO SCROLL CURSOR [WITH HOLD] FOR <sql>→FETCH FORWARD Nloop →CLOSEinfinally. Randomized cursor name; prepared-statement cache cleaned per call.sqlite3_step()perfetch()); snapshot is provided by the transaction (WAL reader snapshot or rollback-journal SHARED lock).DECLARE [<name>] CURSOR GLOBAL FORWARD_ONLY <TYPE> READ_ONLY FOR <sql>, defaultSTATIC.GLOBALis required because each prepared statement inpdo_sqlsrvis its own batch.All three require an active transaction and throw
DriverExceptionotherwise. 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 aStatementInterfacethat for Postgres buffers the full result client-side inlibpqbefore 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/OFFSETwalk — and it is what callers actually need for correct exports of moving tables.The API exposes this honestly.
CursorableInterfaceis 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. CallingDatabase::cursor()on MySQL throws a clearDriverException.Driver-specific knobs go through
CursorOptionssubclasses with afrom(CursorOptions): selffactory that falls back to defaults when a generic instance is passed. Cross-driver code stays clean; tuning (PostgresWITH HOLD, SQL Server cursor type) opts in by passing the matching DTO.DatabaseInterfaceis unchanged — the method is exposed via@method(same pattern aswithoutCache).DriverInterfaceis also unchanged —cursor()lives on the separateCursorableInterfacethat drivers opt into.📝 Checklist
Functional tests per driver:
CursorTest— transaction requirement, small/large datasets, empty result,FETCH_NUMmode, bound parameters, earlybreak(cursor must be released), clean completion (subsequent queries work in the same transaction).CursorTestadds: multi-chunkFETCH FORWARDbehavior, exact-chunk-boundary,chunkSize >= 1validation,WITH HOLD(cursor survivesCOMMIT).CursorTestadds: parameterized test exercising all fourSQLServerCursorTypevariants.📃 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-levelcursor()(transaction requirements, cursor lifecycle, which DTO to pass per driver).