1313
1414namespace Laudis \Neo4j \Bolt ;
1515
16+ use function array_key_exists ;
1617use function array_splice ;
1718
1819use Bolt \error \BoltException ;
2324use Generator ;
2425
2526use function in_array ;
27+ use function is_array ;
2628
2729use Iterator ;
2830use Laudis \Neo4j \Formatter \SummarizedResultFormatter ;
29- use RuntimeException ;
3031use Throwable ;
3132
3233/**
@@ -49,11 +50,37 @@ public function __construct(
4950 ) {
5051 }
5152
53+ /**
54+ * Remaining server pulls use PULL n=-1 (TestKit Optimization:ResultListFetchAll / list()).
55+ */
56+ private ?int $ pullOverrideSize = null ;
57+
58+ /**
59+ * True after at least one {@see fetchResults()} (network pull). Used so list() can reset a stale
60+ * cached generator before the first pull, but must not reset after next()+list() or rows replay.
61+ */
62+ private bool $ networkPullOccurred = false ;
63+
64+ public function prepareForResultListFetchAll (): void
65+ {
66+ $ this ->pullOverrideSize = -1 ;
67+ // Drop cached generator only if no pull ran yet (e.g. valid()/getIt() touched before list()).
68+ // If next() already ran, resetting would restart iterator() and duplicate records on list().
69+ if ($ this ->it !== null && !$ this ->networkPullOccurred ) {
70+ $ this ->it = null ;
71+ }
72+ }
73+
5274 public function getFetchSize (): int
5375 {
5476 return $ this ->fetchSize ;
5577 }
5678
79+ private function effectivePullSize (): int
80+ {
81+ return $ this ->pullOverrideSize ?? $ this ->fetchSize ;
82+ }
83+
5784 private ?Generator $ it = null ;
5885
5986 /**
@@ -116,51 +143,45 @@ public function consume(): array
116143
117144 private function fetchResults (): void
118145 {
146+ $ this ->networkPullOccurred = true ;
147+
119148 try {
120- $ meta = $ this ->connection ->pull ($ this ->qid , $ this ->fetchSize );
149+ $ meta = $ this ->connection ->pull ($ this ->qid , $ this ->effectivePullSize () );
121150 } catch (BoltConnectException |BoltException $ e ) {
122151 // Invalidate connection on socket/network errors so pool does not reuse it.
123152 // Rethrow as-is - Session retry logic inspects the actual exception via isConnectionError().
124153 $ this ->connection ->invalidate ();
125154 throw $ e ;
126155 }
127156 // Neo4jException and other Throwable propagate naturally - no invalidate needed for server errors
128-
129- // Safety check: ensure pull response $meta is not empty (pull() is typed non-empty-list but we defend against empty)
130- /** @psalm-suppress TypeDoesNotContainType */
131- if (empty ($ meta )) {
132- throw new RuntimeException ('Empty response from server ' );
133- }
157+ // $meta is non-empty: {@see BoltConnection::pull()} is contractually non-empty-list<list>.
134158
135159 /** @var list<list> $rows */
136160 $ rows = array_splice ($ meta , 0 , count ($ meta ) - 1 );
137161 $ this ->rows = $ rows ;
138162
139- /** @var array{0: array} $meta */
140- // Check if we have a valid summary
141- /** @psalm-suppress RedundantConditionGivenDocblockType */
142- if (count ($ meta ) > 0 && is_array ($ meta [0 ])) {
143- // If summary is empty array and we have no rows, it's a normal completion (no records)
144- // If summary is empty array but we have rows, it's a partial pull from disconnect
145- if (empty ($ meta [0 ]) && empty ($ rows )) {
146- // Normal completion with no records - mark as complete
147- $ this ->meta = [];
148- } elseif (!empty ($ meta [0 ])) {
149- // Valid summary with data
150- if (!array_key_exists ('has_more ' , $ meta [0 ]) || $ meta [0 ]['has_more ' ] === false ) {
151- $ this ->meta = $ meta [0 ];
152- }
153- } else {
154- // Empty summary but we have rows - partial result from disconnect
155- // Set $this->meta to null so the next fetchResults() will try to pull again
156- // This allows the first record to be consumed, and the next fetch will fail
157- // which is the expected behavior for tests like exit_after_record
158- $ this ->meta = null ;
159- }
160- } else {
163+ $ summarySlot = $ meta [0 ] ?? null ;
164+ if (!is_array ($ summarySlot )) {
161165 // No summary received (connection closed before summary)
162- // Set $this->meta to null so the next fetchResults() will try to pull again
163166 $ this ->meta = null ;
167+
168+ return ;
169+ }
170+
171+ $ summaryEmpty = $ summarySlot === [];
172+ $ hasDataRows = $ rows !== [];
173+
174+ if ($ summaryEmpty && !$ hasDataRows ) {
175+ // Normal completion with no records
176+ $ this ->meta = [];
177+ } elseif (!$ summaryEmpty ) {
178+ // Valid summary map (e.g. has_more, counters, db, …)
179+ if (!array_key_exists ('has_more ' , $ summarySlot ) || $ summarySlot ['has_more ' ] === false ) {
180+ $ this ->meta = $ summarySlot ;
181+ }
182+ } else {
183+ // Empty summary slot with data rows: Bolt SUCCESS {} after RECORDs — stream complete (no has_more keys).
184+ $ this ->meta = $ summarySlot ;
164185 }
165186 }
166187
0 commit comments