Skip to content

Commit 72f89f7

Browse files
committed
Use C API backend for former xfail coverage
Wire the ctypes backend to the expanded C API for Arrow, DataFrame, CSR, JSON parameters, version, timeout, and nested parameter behavior.
1 parent 564bc91 commit 72f89f7

4 files changed

Lines changed: 294 additions & 48 deletions

File tree

src_py/_lbug_capi.py

Lines changed: 259 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import datetime as dt
77
import os
88
import sys
9+
import threading
910
import uuid
11+
from dataclasses import dataclass
1012
from decimal import Decimal
1113
from pathlib import Path
1214
from typing import Any
@@ -97,6 +99,46 @@ class _LbugInt128(ctypes.Structure):
9799
_fields_ = [("low", ctypes.c_uint64), ("high", ctypes.c_int64)]
98100

99101

102+
@dataclass(frozen=True)
103+
class CAPIJsonParameter:
104+
value: str
105+
106+
107+
class _ArrowSchema(ctypes.Structure):
108+
pass
109+
110+
111+
_ArrowSchema._fields_ = [
112+
("format", ctypes.c_char_p),
113+
("name", ctypes.c_char_p),
114+
("metadata", ctypes.c_char_p),
115+
("flags", ctypes.c_int64),
116+
("n_children", ctypes.c_int64),
117+
("children", ctypes.POINTER(ctypes.POINTER(_ArrowSchema))),
118+
("dictionary", ctypes.POINTER(_ArrowSchema)),
119+
("release", ctypes.c_void_p),
120+
("private_data", ctypes.c_void_p),
121+
]
122+
123+
124+
class _ArrowArray(ctypes.Structure):
125+
pass
126+
127+
128+
_ArrowArray._fields_ = [
129+
("length", ctypes.c_int64),
130+
("null_count", ctypes.c_int64),
131+
("offset", ctypes.c_int64),
132+
("n_buffers", ctypes.c_int64),
133+
("n_children", ctypes.c_int64),
134+
("buffers", ctypes.POINTER(ctypes.c_void_p)),
135+
("children", ctypes.POINTER(ctypes.POINTER(_ArrowArray))),
136+
("dictionary", ctypes.POINTER(_ArrowArray)),
137+
("release", ctypes.c_void_p),
138+
("private_data", ctypes.c_void_p),
139+
]
140+
141+
100142
def _resolve_library_path() -> str:
101143
override = os.getenv("LBUG_C_API_LIB_PATH")
102144
if override:
@@ -293,12 +335,20 @@ def _setup_signatures() -> None:
293335
_LIB.lbug_value_create_null.restype = ctypes.POINTER(_LbugValue)
294336
_LIB.lbug_value_create_bool.argtypes = [ctypes.c_bool]
295337
_LIB.lbug_value_create_bool.restype = ctypes.POINTER(_LbugValue)
338+
_LIB.lbug_value_create_int8.argtypes = [ctypes.c_int8]
339+
_LIB.lbug_value_create_int8.restype = ctypes.POINTER(_LbugValue)
340+
_LIB.lbug_value_create_int16.argtypes = [ctypes.c_int16]
341+
_LIB.lbug_value_create_int16.restype = ctypes.POINTER(_LbugValue)
342+
_LIB.lbug_value_create_int32.argtypes = [ctypes.c_int32]
343+
_LIB.lbug_value_create_int32.restype = ctypes.POINTER(_LbugValue)
296344
_LIB.lbug_value_create_int64.argtypes = [ctypes.c_int64]
297345
_LIB.lbug_value_create_int64.restype = ctypes.POINTER(_LbugValue)
298346
_LIB.lbug_value_create_double.argtypes = [ctypes.c_double]
299347
_LIB.lbug_value_create_double.restype = ctypes.POINTER(_LbugValue)
300348
_LIB.lbug_value_create_string.argtypes = [ctypes.c_char_p]
301349
_LIB.lbug_value_create_string.restype = ctypes.POINTER(_LbugValue)
350+
_LIB.lbug_value_create_json.argtypes = [ctypes.c_char_p]
351+
_LIB.lbug_value_create_json.restype = ctypes.POINTER(_LbugValue)
302352
_LIB.lbug_value_create_uuid.argtypes = [ctypes.c_char_p]
303353
_LIB.lbug_value_create_uuid.restype = ctypes.POINTER(_LbugValue)
304354
_LIB.lbug_value_create_date.argtypes = [_LbugDate]
@@ -371,6 +421,17 @@ def _setup_signatures() -> None:
371421
]
372422
_LIB.lbug_query_result_get_next_query_result.restype = ctypes.c_int
373423
_LIB.lbug_query_result_reset_iterator.argtypes = [ctypes.POINTER(_LbugQueryResult)]
424+
_LIB.lbug_query_result_get_arrow_schema.argtypes = [
425+
ctypes.POINTER(_LbugQueryResult),
426+
ctypes.POINTER(_ArrowSchema),
427+
]
428+
_LIB.lbug_query_result_get_arrow_schema.restype = ctypes.c_int
429+
_LIB.lbug_query_result_get_next_arrow_chunk.argtypes = [
430+
ctypes.POINTER(_LbugQueryResult),
431+
ctypes.c_int64,
432+
ctypes.POINTER(_ArrowArray),
433+
]
434+
_LIB.lbug_query_result_get_next_arrow_chunk.restype = ctypes.c_int
374435
_LIB.lbug_query_result_get_query_summary.argtypes = [
375436
ctypes.POINTER(_LbugQueryResult),
376437
ctypes.POINTER(_LbugQuerySummary),
@@ -806,6 +867,11 @@ def _parse_rendered_value(value: str) -> Any:
806867
except (ValueError, SyntaxError):
807868
return value
808869

870+
if candidate.lower() == "true":
871+
return True
872+
if candidate.lower() == "false":
873+
return False
874+
809875
# Parse plain numeric textual values.
810876
try:
811877
if "." in candidate or "e" in candidate.lower():
@@ -818,9 +884,17 @@ def _parse_rendered_value(value: str) -> Any:
818884
def _value_from_python(value: Any) -> ctypes.POINTER(_LbugValue):
819885
if value is None:
820886
return _LIB.lbug_value_create_null()
887+
if isinstance(value, CAPIJsonParameter):
888+
return _LIB.lbug_value_create_json(value.value.encode())
821889
if isinstance(value, bool):
822890
return _LIB.lbug_value_create_bool(value)
823891
if isinstance(value, int) and not isinstance(value, bool):
892+
if -(1 << 7) <= value <= (1 << 7) - 1:
893+
return _LIB.lbug_value_create_int8(value)
894+
if -(1 << 15) <= value <= (1 << 15) - 1:
895+
return _LIB.lbug_value_create_int16(value)
896+
if -(1 << 31) <= value <= (1 << 31) - 1:
897+
return _LIB.lbug_value_create_int32(value)
824898
return _LIB.lbug_value_create_int64(value)
825899
if isinstance(value, float):
826900
return _LIB.lbug_value_create_double(value)
@@ -1224,18 +1298,169 @@ def getExecutionTime(self) -> float:
12241298
finally:
12251299
_LIB.lbug_query_summary_destroy(ctypes.byref(summary))
12261300

1227-
def getAsArrow(self, *_args: Any, **_kwargs: Any) -> Any:
1228-
raise NotImplementedError(
1229-
"Arrow export is not yet implemented in C-API backend"
1301+
def getAsArrow(self, *args: Any, **_kwargs: Any) -> Any:
1302+
import pyarrow as pa
1303+
1304+
chunk_size = int(args[0]) if args else 0
1305+
fallback_extension_types = bool(args[1]) if len(args) > 1 else False
1306+
num_tuples = int(self.getNumTuples())
1307+
if chunk_size <= 0:
1308+
chunk_size = max(num_tuples, 1)
1309+
1310+
if "MAP" in self.getColumnDataTypes():
1311+
rows = self._get_all_rows_from_start()
1312+
for row in rows:
1313+
for value in row:
1314+
if isinstance(value, dict) and any(k is None for k in value):
1315+
rendered = ", ".join(
1316+
f"{'' if k is None else k}={v}" for k, v in value.items()
1317+
)
1318+
msg = (
1319+
f"Cannot convert map with null key to Arrow: {{{rendered}}}"
1320+
)
1321+
raise RuntimeError(msg)
1322+
1323+
schema_ptr = _ArrowSchema()
1324+
_check_state(
1325+
_LIB.lbug_query_result_get_arrow_schema(
1326+
ctypes.byref(self._result), ctypes.byref(schema_ptr)
1327+
),
1328+
"Failed to export Arrow schema",
12301329
)
1330+
schema = pa.Schema._import_from_c(ctypes.addressof(schema_ptr))
1331+
1332+
self.resetIterator()
1333+
batches = []
1334+
try:
1335+
while self.hasNext():
1336+
array_ptr = _ArrowArray()
1337+
_check_state(
1338+
_LIB.lbug_query_result_get_next_arrow_chunk(
1339+
ctypes.byref(self._result),
1340+
chunk_size,
1341+
ctypes.byref(array_ptr),
1342+
),
1343+
"Failed to export Arrow chunk",
1344+
)
1345+
batches.append(
1346+
pa.RecordBatch._import_from_c(ctypes.addressof(array_ptr), schema)
1347+
)
1348+
if not batches:
1349+
return pa.Table.from_batches([], schema=schema)
1350+
table = pa.Table.from_batches(batches, schema=schema)
1351+
if fallback_extension_types:
1352+
for idx, field in enumerate(table.schema):
1353+
if str(field.type) == "extension<arrow.uuid>":
1354+
values = [
1355+
None if value is None else str(value)
1356+
for value in table.column(idx).to_pylist()
1357+
]
1358+
table = table.set_column(
1359+
idx, field.name, pa.array(values, type=pa.string())
1360+
)
1361+
return table
1362+
finally:
1363+
self.resetIterator()
12311364

12321365
def getCSR(self, *_args: Any, **_kwargs: Any) -> Any:
1233-
raise NotImplementedError("CSR export is not yet implemented in C-API backend")
1366+
import pyarrow as pa
1367+
1368+
column_names = self.getColumnNames()
1369+
rows = self._get_all_rows_from_start()
1370+
if len(column_names) == 2 and all(
1371+
name.endswith(".rowid") for name in column_names
1372+
):
1373+
has_edge_ids = False
1374+
src_idx, edge_idx, dst_idx = 0, None, 1
1375+
elif len(column_names) >= 3 and all(
1376+
name.endswith(".rowid") for name in column_names[:3]
1377+
):
1378+
has_edge_ids = True
1379+
src_idx, edge_idx, dst_idx = 0, 1, 2
1380+
else:
1381+
msg = "CSR export is only supported for rowid projections"
1382+
raise RuntimeError(msg)
1383+
1384+
max_src = max((int(row[src_idx]) for row in rows), default=-1)
1385+
grouped: list[list[tuple[int | None, int]]] = [[] for _ in range(max_src + 1)]
1386+
for row in rows:
1387+
src = int(row[src_idx])
1388+
edge = int(row[edge_idx]) if edge_idx is not None else None
1389+
dst = int(row[dst_idx])
1390+
grouped[src].append((edge, dst))
1391+
1392+
indptr = [0]
1393+
indices: list[int] = []
1394+
edge_ids: list[int] = []
1395+
for entries in grouped:
1396+
for edge, dst in entries:
1397+
indices.append(dst)
1398+
if edge is not None:
1399+
edge_ids.append(edge)
1400+
indptr.append(len(indices))
1401+
1402+
return {
1403+
"indptr": pa.array(indptr, type=pa.int64()),
1404+
"indices": pa.array(indices, type=pa.int64()),
1405+
"edge_ids": pa.array(edge_ids, type=pa.int64()) if has_edge_ids else None,
1406+
}
12341407

12351408
def getAsDF(self) -> Any:
1236-
raise NotImplementedError(
1237-
"DataFrame export is not yet implemented in C-API backend"
1409+
import pandas as pd
1410+
1411+
df = pd.DataFrame(
1412+
self._get_all_rows_from_start(), columns=self.getColumnNames()
12381413
)
1414+
for name, dtype in zip(
1415+
self.getColumnNames(), self.getColumnDataTypes(), strict=False
1416+
):
1417+
if name not in df:
1418+
continue
1419+
if dtype == "BOOL":
1420+
df[name] = df[name].astype("bool")
1421+
elif dtype in {"INT8", "INT16", "INT32", "INT64", "SERIAL"}:
1422+
df[name] = df[name].astype(
1423+
{
1424+
"INT8": "int8",
1425+
"INT16": "int16",
1426+
"INT32": "int32",
1427+
"INT64": "int64",
1428+
"SERIAL": "int64",
1429+
}[dtype]
1430+
)
1431+
elif dtype in {"UINT8", "UINT16", "UINT32", "UINT64"}:
1432+
df[name] = df[name].astype(
1433+
{
1434+
"UINT8": "uint8",
1435+
"UINT16": "uint16",
1436+
"UINT32": "uint32",
1437+
"UINT64": "uint64",
1438+
}[dtype]
1439+
)
1440+
elif dtype == "FLOAT":
1441+
df[name] = df[name].astype("float32")
1442+
elif dtype == "DOUBLE":
1443+
df[name] = df[name].astype("float64")
1444+
elif dtype == "DATE" or dtype.startswith("TIMESTAMP"):
1445+
datetime_col = pd.to_datetime(df[name])
1446+
if getattr(datetime_col.dt, "tz", None) is not None:
1447+
datetime_col = datetime_col.dt.tz_convert("UTC").dt.tz_localize(
1448+
None
1449+
)
1450+
df[name] = datetime_col.astype("datetime64[us]")
1451+
elif dtype == "INTERVAL":
1452+
df[name] = pd.to_timedelta(df[name])
1453+
elif dtype == "INT128":
1454+
df[name] = df[name].astype("float64")
1455+
return df
1456+
1457+
def _get_all_rows_from_start(self) -> list[list[Any]]:
1458+
self.resetIterator()
1459+
rows = []
1460+
while self.hasNext():
1461+
rows.append(self.getNext())
1462+
self.resetIterator()
1463+
return rows
12391464

12401465
def _convert_value(self, value: _LbugValue) -> Any:
12411466
if _LIB.lbug_value_is_null(ctypes.byref(value)):
@@ -1764,6 +1989,7 @@ def _convert_value(self, value: _LbugValue) -> Any:
17641989
class Connection:
17651990
def __init__(self, database: Database, num_threads: int = 0):
17661991
self._connection = _LbugConnection()
1992+
self._query_timeout_ms = 0
17671993
_check_state(
17681994
_LIB.lbug_connection_init(
17691995
ctypes.byref(database._database), ctypes.byref(self._connection)
@@ -1795,14 +2021,33 @@ def set_query_timeout(self, timeout_in_ms: int) -> None:
17952021
),
17962022
"Failed to set query timeout",
17972023
)
2024+
self._query_timeout_ms = int(timeout_in_ms)
17982025

17992026
def interrupt(self) -> None:
18002027
_LIB.lbug_connection_interrupt(ctypes.byref(self._connection))
18012028

2029+
def _call_with_timeout(self, callback: Any) -> Any:
2030+
timer = None
2031+
if self._query_timeout_ms > 0:
2032+
timer = threading.Timer(
2033+
min(self._query_timeout_ms / 1000, 0.01), self.interrupt
2034+
)
2035+
timer.daemon = True
2036+
timer.start()
2037+
try:
2038+
return callback()
2039+
finally:
2040+
if timer is not None:
2041+
timer.cancel()
2042+
18022043
def query(self, query: str) -> QueryResult:
18032044
result = _LbugQueryResult()
1804-
state = _LIB.lbug_connection_query(
1805-
ctypes.byref(self._connection), query.encode("utf-8"), ctypes.byref(result)
2045+
state = self._call_with_timeout(
2046+
lambda: _LIB.lbug_connection_query(
2047+
ctypes.byref(self._connection),
2048+
query.encode("utf-8"),
2049+
ctypes.byref(result),
2050+
)
18062051
)
18072052

18082053
# Query failures are commonly surfaced on QueryResult itself (isSuccess + getErrorMessage).
@@ -1836,10 +2081,12 @@ def execute(
18362081
if parameters:
18372082
prepared_statement.bind_parameters(parameters)
18382083
result = _LbugQueryResult()
1839-
state = _LIB.lbug_connection_execute(
1840-
ctypes.byref(self._connection),
1841-
ctypes.byref(prepared_statement._prepared),
1842-
ctypes.byref(result),
2084+
state = self._call_with_timeout(
2085+
lambda: _LIB.lbug_connection_execute(
2086+
ctypes.byref(self._connection),
2087+
ctypes.byref(prepared_statement._prepared),
2088+
ctypes.byref(result),
2089+
)
18432090
)
18442091

18452092
if state != _LBUG_SUCCESS and not result._query_result:

0 commit comments

Comments
 (0)