NebulaGraph Python Client 5.3.0
Loading...
Searching...
No Matches
result_set.py
1# Copyright 2025 vesoft-inc
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from collections.abc import Iterable, Iterator
16from logging import getLogger
17from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
18
19from nebulagraph_python.data import ExtraInfo, PlanInfoNode
20from nebulagraph_python.decoder.data_types import ResultGraphSchemas
21from nebulagraph_python.decoder.decode import Batch, BytesReader
22from nebulagraph_python.decoder.decode_utils import ByteOrder
23from nebulagraph_python.decoder.value_parser import (
24 DataType,
25 ValueParser,
26 ValueTypeParser,
27)
28from nebulagraph_python.error import ErrorCode, InternalError, NebulaGraphRemoteError
29from nebulagraph_python.proto.graph_pb2 import ExecuteResponse
30from nebulagraph_python.proto.vector_pb2 import VectorResultTable
31from nebulagraph_python.value_wrapper import Row, ValueWrapper
32
33if TYPE_CHECKING:
34 from pandas import DataFrame
35 from rich.console import Console
36
37logger = getLogger(__name__)
38
39
41 """Stateful iterator over batched result data, matching Java's ResultTable.next() semantics.
42
43 Rows are decoded on-demand as next() is called, rather than all at once.
44 """
45
46 def __init__(self, table: Any):
47 if not isinstance(table, VectorResultTable):
48 raise InternalError("table must be a VectorResultTable")
49
50 self.result_table = table
51
52 graph_schemas = ResultGraphSchemas(table.meta.graph_schema)
53 time_zone_offset = table.meta.time_zone_offset
54
55 if table.meta.is_little_endian:
56 self.byte_order = ByteOrder.LITTLE_ENDIAN
57 else:
58 self.byte_order = ByteOrder.BIG_ENDIAN
59
60 self.parser = ValueParser(graph_schemas, time_zone_offset, self.byte_order)
61 self.num_batches = table.meta.num_batches
62 if self.num_batches != len(table.batch):
63 raise RuntimeError("the number of batch is not equal to numBatches")
64
65 self.column_names = list(table.meta.row_type.column_names)
66 self.column_data_types: List[DataType] = []
67 value_type_parser = ValueTypeParser(self.byte_order)
68 for col_type in table.meta.row_type.column_types:
69 if not col_type.value_type:
70 raise ValueError("Invalid column type: empty value_type")
71 data_type = value_type_parser.get_data_type(
72 BytesReader(col_type.value_type),
73 )
74 self.column_data_types.append(data_type)
75
76 self.total_num_records = table.meta.num_records
77
78 # Iterator state (mirrors Java's batchIndex / currentBatchRowIndex)
79 self._batch_index: int = 0
80 self._current_batch_row_index: int = 0
81 self._current_batch: Optional[Batch] = (
82 Batch(table.batch[0], self.byte_order) if self.num_batches > 0 else None
83 )
84
85 def _get_row_by_index(self, batch: Batch, index: int) -> Row:
86 """Decode one row from the given batch at the specified index."""
87 row = Row()
88 decode_value_wrapper = self.parser.decode_value_wrapper
89 get_vectors = batch.get_vectors
90 column_data_types = self.column_data_types
91 for i in range(batch.get_vectors_count()):
92 value = decode_value_wrapper(
93 get_vectors(i),
94 column_data_types[i],
95 index,
96 )
97 row.add_value(value)
98 return row
99
100 def next(self) -> Row:
101 """Return the next decoded row, advancing internal state.
102
103 Mirrors Java ResultTable.next():
104 - Skip empty or exhausted batches automatically.
105 - Raises StopIteration when all rows have been consumed.
106 """
107 if self._current_batch is None:
108 raise StopIteration("no more batch data")
109
110 current_batch_row_size = 0
111 if self._current_batch.get_vectors_count() != 0:
112 current_batch_row_size = self._current_batch.get_batch_row_size()
113
114 # Current batch is empty or exhausted – advance to next non-empty batch
115 while (
116 self._current_batch.get_vectors_count() == 0
117 or self._current_batch_row_index >= current_batch_row_size
118 ):
119 self._batch_index += 1
120 if self._batch_index >= self.num_batches:
121 raise StopIteration("no more batch data")
123 self._current_batch = Batch(
124 self.result_table.batch[self._batch_index], self.byte_order
125 )
126 current_batch_row_size = self._current_batch.get_batch_row_size()
127
130 return row
131
132 def __iter__(self):
133 return self
134
135 def __next__(self) -> Row:
136 return self.next()
137
138
139class Record:
140 column_names: List[str]
141 col_values: List[ValueWrapper]
142 mapping: Optional[Dict[str, int]]
143
144 def __init__(self, column_names: Optional[List[str]], row: Row):
145 self.col_valuescol_values: List[ValueWrapper] = []
146 self.mappingmapping: Optional[Dict[str, int]] = None
147
148 if column_names is None or row is None or not row.values:
149 self.column_namescolumn_names: List[str] = []
150 self.mappingmapping = {}
151 return
152 self.column_namescolumn_names = column_names
153 self.col_valuescol_values = row.values
154
155 def _get_mapping(self) -> Dict[str, int]:
156 mapping = self.mappingmapping
157 if mapping is None:
158 mapping = {name: idx for idx, name in enumerate(self.column_namescolumn_names)}
159 self.mappingmapping = mapping
160 return mapping
161
162 def __iter__(self) -> Iterator[Tuple[str, ValueWrapper]]:
163 return self.items()
164
165 def __str__(self) -> str:
166 value_strs = [str(v.cast()) for v in self.col_valuescol_values]
167 return f"ColumnName: {self.column_names}, Values: {value_strs}"
168
169 def get(self, key: Union[int, str]) -> ValueWrapper:
170 if isinstance(key, str):
171 try:
172 key = self._get_mapping()[key]
173 except KeyError as e:
174 raise KeyError(
175 f"Cannot get field because the columnName '{key}' is not exists",
176 ) from e
177 try:
178 return self.col_valuescol_values[key]
179 except IndexError as e:
180 raise IndexError(
181 f"Cannot get field because the key '{key}' out of range",
182 ) from e
183
184 def __getitem__(self, key: Union[int, str]) -> ValueWrapper:
185 return self.get(key)
186
187 def values(self) -> List[ValueWrapper]:
188 return self.col_valuescol_values
189
190 def items(self) -> Iterator[Tuple[str, ValueWrapper]]:
191 for col in self.column_namescolumn_names:
192 yield col, self[col]
193
194 def size(self) -> int:
195 return len(self.column_namescolumn_names)
196
197 def contains(self, column_name: str) -> bool:
198 return column_name in self.column_namescolumn_names
199
200 def for_each(self, action):
201 for value in self.col_valuescol_values:
202 action(value)
203
204 def spliterator(self):
205 return self.col_valuescol_values.__iter__()
206
207 def as_primitive(self) -> Dict[str, Any]:
208 return {col: val.cast_primitive() for col, val in self.items()}
209
210
212 column_names: List[str]
213 result_table: Optional[ResultTable]
214 status_code: str
215 status_message: str
216 latency_us: int
217 plan_desc: PlanInfoNode
218 size: int
219 extra_info: ExtraInfo
220
221 def __init__(self, response: Any):
222 if not isinstance(response, ExecuteResponse):
223 raise InternalError(f"got {type(response)} object for server's response")
224
225 if response.HasField("result"):
226 self.result_tableresult_table = ResultTable(response.result)
228 self.sizesize = self.result_tableresult_table.total_num_records
229 else:
230 self.result_tableresult_table = None
232 self.sizesize = 0
233
234 self._is_empty: bool = self.sizesize == 0
235
236 # Cursor position (mirrors Java's AtomicInteger index)
237 self._index: int = 0
238
239 if not response.HasField("status"):
240 raise InternalError("status is not set in response")
241 self.status_codestatus_code = response.status.code.decode("utf-8")
242 self.status_messagestatus_message = response.status.message.decode("utf-8")
243 self.is_succeeded = self.status_codestatus_code == "00000"
244
245 if not response.HasField("summary"):
246 raise InternalError("summary is not set in response")
247 self.latency_uslatency_us = response.summary.elapsed_time.total_server_time_us
248 self.plan_descplan_desc = PlanInfoNode(response.summary.plan_info)
250 cursor=response.cursor.decode("utf-8"),
251 affected_nodes=response.summary.query_stats.num_affected_nodes,
252 affected_edges=response.summary.query_stats.num_affected_edges,
253 total_server_time_us=response.summary.elapsed_time.total_server_time_us,
254 build_time_us=response.summary.elapsed_time.build_time_us,
255 optimize_time_us=response.summary.elapsed_time.optimize_time_us,
256 serialize_time_us=response.summary.elapsed_time.serialize_time_us,
257 )
258
259 def raise_on_error(self) -> "ResultSet":
260 if not self.is_succeeded:
261 try:
262 error_code = ErrorCode(self.status_codestatus_code)
263 except ValueError:
264 logger.error("Unknown error code: %s", self.status_codestatus_code)
265 error_code = ErrorCode.UNKNOWN
267 code=error_code,
268 message=self.status_messagestatus_message,
269 result=self,
270 )
271 return self
272
273 def is_empty(self) -> bool:
274 """Return True when the result set contains no rows."""
275 return self._is_empty
276
277 # ------------------------------------------------------------------
278 # Java-style hasNext / next interface
279 # ------------------------------------------------------------------
280
281 def has_next(self) -> bool:
282 """Return True if there are more rows to consume.
283
284 Mirrors Java ResultSet.hasNext().
285 """
286 if self._is_empty:
287 return False
288 return self._index < self.sizesize
289
290 def next(self) -> Record:
291 """Decode and return the next row as a Record.
292
293 Mirrors Java ResultSet.next(): each call advances the internal
294 cursor and decodes exactly one row on demand.
295
296 Raises
297 ------
298 StopIteration: when no more rows are available.
299 """
300 if not self.has_next():
301 raise StopIteration("no more row record data")
302 if self.result_tableresult_table is None:
303 raise InternalError("result table is not initialized")
304 row = self.result_tableresult_table.next()
305 self._index += 1
306 return Record(self.column_namescolumn_names, row)
307
308 # ------------------------------------------------------------------
309 # Python iterator protocol (delegates to has_next / next)
310 # ------------------------------------------------------------------
311
312 def __iter__(self) -> "ResultSet":
313 return self
314
315 def __next__(self) -> Record:
316 if not self.has_next():
317 raise StopIteration
318 return self.next()
319
320 # ------------------------------------------------------------------
321 # Convenience helpers
322 # ------------------------------------------------------------------
323
324 def records(self) -> Iterator[Record]:
325 """Generator that yields all remaining Records.
326
327 Compatible with existing code that calls ``for record in result.records()``.
328 """
329 while self.has_next():
330 yield self.next()
331
332 def __str__(self) -> str:
333 if not self.is_succeeded:
335 return f"ColumnName: {self.column_names}, RowSize: {self.size}, Latency: {self.latency_us}"
336
337 def as_primitive_by_row(self) -> Iterable[Dict[str, Any]]:
338 for record in self.records():
339 yield record.as_primitive()
340
341 def as_primitive_by_column(self) -> Dict[str, List[Any]]:
342 answer = {col: [] for col in self.column_namescolumn_names}
343 for record in self.records():
344 for col, val in record.as_primitive().items():
345 answer[col].append(val)
346 return answer
347
348 def as_pandas_df(self) -> "DataFrame":
349 """Convert result set to pandas DataFrame."""
350 try:
351 import pandas as pd
352 except ImportError as e:
353 raise ImportError(
354 "pandas is required to use this method. Please install it using 'pip install pandas'",
355 ) from e
356
357 if not self.is_succeeded:
358 raise RuntimeError(f"Query failed: {self.status_message}")
359
360 if self.sizesize == 0:
361 return pd.DataFrame(columns=self.column_namescolumn_names)
362
363 rows = []
364 for record in self.records():
365 row = []
366 for val in record.values():
367 row.append(val.cast_primitive() if val is not None else None)
368 rows.append(row)
369
370 return pd.DataFrame(rows, columns=self.column_namescolumn_names)
371
373 self,
374 console: Optional["Console"] = None,
375 style: str = "table",
376 width: Optional[int] = None,
377 min_width: int = 8,
378 max_width: Optional[int] = None,
379 padding: int = 1,
380 collapse_padding: bool = False,
381 ) -> Optional[str]:
382 """Print query results in a formatted table or row-by-row format.
383
384 Return the string if console is not provided.
385 """
386 try:
387 from io import StringIO
388
389 from rich import box
390 from rich.console import Console
391 from rich.table import Table
392 except ImportError as e:
393 raise ImportError(
394 "The 'rich' library is required to use this method. Please install it using 'pip install rich'.",
395 ) from e
396
397 have_console: bool = console is not None
398 console = console or Console(file=StringIO(), force_terminal=False)
399
400 if not self.is_succeeded:
401 console.print(f"[bold red]Error:[/bold red] {self.status_message}")
402 if not have_console:
403 console.file.seek(0)
404 return console.file.read()
405
406 if self.sizesize == 0:
407 console.print("[yellow]Empty result set[/yellow]")
408 if not have_console:
409 console.file.seek(0)
410 return console.file.read()
411
412 if style == "rows":
413 row_num = 1
414 for record in self.records():
415 console.print(f"\n[bold blue]Row {row_num}[/bold blue]")
416 for col, val in zip(self.column_namescolumn_names, record.values(), strict=True):
417 console.print(f" [cyan]{col}:[/cyan] {val.cast_primitive()}")
418 row_num += 1
419 else:
420 table = Table(
421 box=box.DOUBLE_EDGE,
422 show_header=True,
423 header_style="bold cyan",
424 width=width,
425 min_width=min_width,
426 padding=padding,
427 collapse_padding=collapse_padding,
428 highlight=True,
429 )
430
431 for header in self.column_namescolumn_names:
432 table.add_column(header, max_width=max_width, overflow="fold")
433
434 for record in self.records():
435 table.add_row(*[str(v.cast_primitive()) for v in record.values()])
436
437 console.print(table)
438
439 console.print("\n[bold green]Summary[/bold green]")
440 console.print(f"├── [green]Rows:[/green] {self.size}")
441 console.print(f"└── [blue]Latency:[/blue] {self.latency_us}μs")
442
443 if not have_console:
444 console.file.seek(0)
445 return console.file.read()
446
447 def one(self) -> Record:
448 return self.next()
449
450 def one_or_none(self) -> Optional[Record]:
451 try:
452 return self.next()
453 except StopIteration:
454 return None
455
456 def print(
457 self,
458 style: str = "table",
459 width: Optional[int] = None,
460 min_width: int = 8,
461 max_width: Optional[int] = None,
462 padding: int = 1,
463 collapse_padding: bool = False,
464 ):
465 """Print the results directly to console with rich formatting."""
466 from rich.console import Console
467
468 console = Console()
469 self.as_ascii_table(
470 console,
471 style,
472 width,
473 min_width,
474 max_width,
475 padding,
476 collapse_padding,
477 )
ValueWrapper get(self, Union[int, str] key)
Iterator[Tuple[str, ValueWrapper]] items(self)
Dict[str, int] _get_mapping(self)
print(self, str style="table", Optional[int] width=None, int min_width=8, Optional[int] max_width=None, int padding=1, bool collapse_padding=False)
Optional[str] as_ascii_table(self, Optional["Console"] console=None, str style="table", Optional[int] width=None, int min_width=8, Optional[int] max_width=None, int padding=1, bool collapse_padding=False)
Row _get_row_by_index(self, Batch batch, int index)
Definition result_set.py:85