|
21 | 21 | within a batch when ``n`` is smaller than the kernel's natural |
22 | 22 | batch size; ``fetchall`` drains the whole stream. |
23 | 23 |
|
| 24 | +When a cursor has ``row_limit`` set, this class caps the logical stream |
| 25 | +before rows reach any of the row or Arrow fetch APIs. |
| 26 | +
|
24 | 27 | Note: ``buffer_size_bytes`` is accepted by the constructor for |
25 | 28 | contract compatibility with the base ``ResultSet`` but is not |
26 | 29 | consulted — the kernel backend currently caps buffering by rows |
@@ -67,6 +70,7 @@ def __init__( |
67 | 70 | command_id: CommandId, |
68 | 71 | arraysize: int, |
69 | 72 | buffer_size_bytes: int, |
| 73 | + row_limit: Optional[int] = None, |
70 | 74 | ): |
71 | 75 | try: |
72 | 76 | schema = kernel_handle.arrow_schema() |
@@ -100,27 +104,55 @@ def __init__( |
100 | 104 | # stays O(1) instead of walking the deque. |
101 | 105 | self._buffered_count: int = 0 |
102 | 106 | self._exhausted: bool = False |
| 107 | + # The PyO3 kernel surface does not currently expose the core |
| 108 | + # StatementSpec row_limit setter. Enforce the cursor contract at |
| 109 | + # this streaming boundary until it does. Negative values retain the |
| 110 | + # existing unlimited behaviour; zero is a real zero-row limit. |
| 111 | + self._row_limit: Optional[int] = ( |
| 112 | + row_limit if row_limit is not None and row_limit >= 0 else None |
| 113 | + ) |
| 114 | + if self._row_limit == 0: |
| 115 | + self._mark_exhausted() |
103 | 116 |
|
104 | 117 | # ----- internal helpers ----- |
105 | 118 |
|
| 119 | + def _mark_exhausted(self) -> None: |
| 120 | + self._exhausted = True |
| 121 | + self.has_more_rows = False |
| 122 | + self.status = CommandState.SUCCEEDED |
| 123 | + |
| 124 | + def _remaining_row_limit(self) -> Optional[int]: |
| 125 | + if self._row_limit is None: |
| 126 | + return None |
| 127 | + return max( |
| 128 | + 0, |
| 129 | + self._row_limit - self._next_row_index - self._buffered_count, |
| 130 | + ) |
| 131 | + |
106 | 132 | def _pull_one_batch(self) -> bool: |
107 | 133 | """Pull the next batch from the kernel into the local buffer. |
108 | 134 | Returns True if a batch was added; False if the kernel side |
109 | 135 | is exhausted.""" |
110 | 136 | if self._exhausted: |
111 | 137 | return False |
| 138 | + remaining_limit = self._remaining_row_limit() |
| 139 | + if remaining_limit == 0: |
| 140 | + self._mark_exhausted() |
| 141 | + return False |
112 | 142 | try: |
113 | 143 | batch = self._kernel_handle.fetch_next_batch() |
114 | 144 | except Exception as exc: |
115 | 145 | raise wrap_kernel_exception("fetch_next_batch", exc) from exc |
116 | 146 | if batch is None: |
117 | | - self._exhausted = True |
118 | | - self.has_more_rows = False |
119 | | - self.status = CommandState.SUCCEEDED |
| 147 | + self._mark_exhausted() |
120 | 148 | return False |
| 149 | + if remaining_limit is not None and batch.num_rows > remaining_limit: |
| 150 | + batch = batch.slice(0, remaining_limit) |
121 | 151 | if batch.num_rows > 0: |
122 | 152 | self._buffer.append(batch) |
123 | 153 | self._buffered_count += batch.num_rows |
| 154 | + if remaining_limit is not None and batch.num_rows >= remaining_limit: |
| 155 | + self._mark_exhausted() |
124 | 156 | return True |
125 | 157 |
|
126 | 158 | def _ensure_buffered(self, n_rows: int) -> int: |
@@ -156,36 +188,10 @@ def _take_buffered(self, n: int) -> pyarrow.Table: |
156 | 188 | return pyarrow.Table.from_batches(slices, schema=self._schema) |
157 | 189 |
|
158 | 190 | def _drain(self) -> pyarrow.Table: |
159 | | - """Consume everything left in the buffer + kernel stream |
160 | | - and return as a single Table.""" |
161 | | - chunks: List[pyarrow.RecordBatch] = [] |
162 | | - if self._buffer and self._buffer_offset > 0: |
163 | | - head = self._buffer.popleft() |
164 | | - chunks.append( |
165 | | - head.slice(self._buffer_offset, head.num_rows - self._buffer_offset) |
166 | | - ) |
167 | | - self._buffer_offset = 0 |
168 | | - while self._buffer: |
169 | | - chunks.append(self._buffer.popleft()) |
170 | | - if not self._exhausted: |
171 | | - while True: |
172 | | - try: |
173 | | - batch = self._kernel_handle.fetch_next_batch() |
174 | | - except Exception as exc: |
175 | | - raise wrap_kernel_exception("fetch_next_batch", exc) from exc |
176 | | - if batch is None: |
177 | | - self._exhausted = True |
178 | | - self.has_more_rows = False |
179 | | - self.status = CommandState.SUCCEEDED |
180 | | - break |
181 | | - if batch.num_rows > 0: |
182 | | - chunks.append(batch) |
183 | | - rows = sum(c.num_rows for c in chunks) |
184 | | - self._buffered_count = 0 |
185 | | - self._next_row_index += rows |
186 | | - if not chunks: |
187 | | - return pyarrow.Table.from_batches([], schema=self._schema) |
188 | | - return pyarrow.Table.from_batches(chunks, schema=self._schema) |
| 191 | + """Consume the remaining logical stream into one table.""" |
| 192 | + while not self._exhausted: |
| 193 | + self._pull_one_batch() |
| 194 | + return self._take_buffered(self._buffered_count) |
189 | 195 |
|
190 | 196 | # ----- Arrow fetches ----- |
191 | 197 |
|
|
0 commit comments