sqlalchemy-monetdb-adbc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_monetdb_adbc/__init__.py +37 -0
- sqlalchemy_monetdb_adbc/_alembic.py +74 -0
- sqlalchemy_monetdb_adbc/_convert.py +107 -0
- sqlalchemy_monetdb_adbc/arrow.py +99 -0
- sqlalchemy_monetdb_adbc/base.py +623 -0
- sqlalchemy_monetdb_adbc/compiler.py +410 -0
- sqlalchemy_monetdb_adbc/connection.py +62 -0
- sqlalchemy_monetdb_adbc/constants.py +39 -0
- sqlalchemy_monetdb_adbc/ddl.py +33 -0
- sqlalchemy_monetdb_adbc/dialect.py +252 -0
- sqlalchemy_monetdb_adbc/multi_reflection.py +539 -0
- sqlalchemy_monetdb_adbc/provision.py +119 -0
- sqlalchemy_monetdb_adbc/py.typed +1 -0
- sqlalchemy_monetdb_adbc/reflection.py +195 -0
- sqlalchemy_monetdb_adbc/requirements.py +248 -0
- sqlalchemy_monetdb_adbc/types.py +275 -0
- sqlalchemy_monetdb_adbc-0.1.0.dist-info/METADATA +349 -0
- sqlalchemy_monetdb_adbc-0.1.0.dist-info/RECORD +21 -0
- sqlalchemy_monetdb_adbc-0.1.0.dist-info/WHEEL +4 -0
- sqlalchemy_monetdb_adbc-0.1.0.dist-info/entry_points.txt +5 -0
- sqlalchemy_monetdb_adbc-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from sqlalchemy_monetdb_adbc.arrow import fetch_arrow_table, fetch_record_batches, ingest_arrow
|
|
4
|
+
from sqlalchemy_monetdb_adbc.connection import raw_adbc_connection
|
|
5
|
+
from sqlalchemy_monetdb_adbc.dialect import MonetDBADBCDialect
|
|
6
|
+
from sqlalchemy_monetdb_adbc.types import (
|
|
7
|
+
DOUBLE_PRECISION,
|
|
8
|
+
HUGEINT,
|
|
9
|
+
INET,
|
|
10
|
+
MONTH_INTERVAL,
|
|
11
|
+
SECOND_INTERVAL,
|
|
12
|
+
TINYINT,
|
|
13
|
+
URL,
|
|
14
|
+
PydanticJSON,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = version("sqlalchemy-monetdb-adbc")
|
|
19
|
+
except PackageNotFoundError:
|
|
20
|
+
__version__ = "0.0.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DOUBLE_PRECISION",
|
|
24
|
+
"HUGEINT",
|
|
25
|
+
"INET",
|
|
26
|
+
"MONTH_INTERVAL",
|
|
27
|
+
"SECOND_INTERVAL",
|
|
28
|
+
"TINYINT",
|
|
29
|
+
"URL",
|
|
30
|
+
"MonetDBADBCDialect",
|
|
31
|
+
"PydanticJSON",
|
|
32
|
+
"__version__",
|
|
33
|
+
"fetch_arrow_table",
|
|
34
|
+
"fetch_record_batches",
|
|
35
|
+
"ingest_arrow",
|
|
36
|
+
"raw_adbc_connection",
|
|
37
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Alembic support.
|
|
2
|
+
|
|
3
|
+
Alembic resolves a migration implementation by dialect name from the subclasses
|
|
4
|
+
of ``DefaultImpl`` that have been imported, so defining this class registers
|
|
5
|
+
MonetDB. Alembic is not a runtime dependency, so ``dialect`` imports this module
|
|
6
|
+
inside a ``try``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Literal, cast
|
|
10
|
+
|
|
11
|
+
from alembic.ddl.base import (
|
|
12
|
+
ColumnType,
|
|
13
|
+
alter_column,
|
|
14
|
+
alter_table,
|
|
15
|
+
format_type, # pyright: ignore[reportUnknownVariableType]
|
|
16
|
+
)
|
|
17
|
+
from alembic.ddl.impl import DefaultImpl
|
|
18
|
+
from sqlalchemy.ext.compiler import compiles
|
|
19
|
+
from sqlalchemy.sql.compiler import DDLCompiler
|
|
20
|
+
from sqlalchemy.sql.type_api import TypeEngine
|
|
21
|
+
|
|
22
|
+
from sqlalchemy_monetdb_adbc.types import PydanticJSON
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _unquote_string_default(value: str) -> str:
|
|
26
|
+
if len(value) >= 2 and value[0] == value[-1] == "'":
|
|
27
|
+
return value[1:-1].replace("''", "'")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MonetDBImpl(DefaultImpl):
|
|
32
|
+
__dialect__ = "monetdb"
|
|
33
|
+
|
|
34
|
+
transactional_ddl = True
|
|
35
|
+
|
|
36
|
+
def render_type(self, type_obj: Any, autogen_context: Any) -> str | Literal[False]:
|
|
37
|
+
# A migration describes the database schema, so a PydanticJSON column
|
|
38
|
+
# renders as the JSON column it actually is. Rendering the model would
|
|
39
|
+
# make migrations import application code and break as soon as that
|
|
40
|
+
# model is renamed or removed.
|
|
41
|
+
if isinstance(type_obj, PydanticJSON):
|
|
42
|
+
autogen_context.imports.add("import sqlalchemy as sa")
|
|
43
|
+
return "sa.JSON()"
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def compare_server_default(
|
|
47
|
+
self,
|
|
48
|
+
inspector_column: Any,
|
|
49
|
+
metadata_column: Any,
|
|
50
|
+
rendered_metadata_default: str | None,
|
|
51
|
+
rendered_inspector_default: str | None,
|
|
52
|
+
) -> bool:
|
|
53
|
+
# MonetDB echoes a string default back with its quotes, so compare the
|
|
54
|
+
# unquoted forms rather than reporting every such column as changed.
|
|
55
|
+
if rendered_inspector_default is not None:
|
|
56
|
+
rendered_inspector_default = _unquote_string_default(rendered_inspector_default)
|
|
57
|
+
if rendered_metadata_default is not None:
|
|
58
|
+
rendered_metadata_default = _unquote_string_default(rendered_metadata_default)
|
|
59
|
+
return rendered_inspector_default != rendered_metadata_default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@compiles(ColumnType, "monetdb")
|
|
63
|
+
def _monetdb_column_type( # pyright: ignore[reportUnusedFunction]
|
|
64
|
+
element: ColumnType,
|
|
65
|
+
compiler: DDLCompiler,
|
|
66
|
+
**kw: Any, # noqa: ARG001
|
|
67
|
+
) -> str:
|
|
68
|
+
# MonetDB spells this "ALTER COLUMN <name> <type>", without the TYPE keyword
|
|
69
|
+
# the default rendering emits.
|
|
70
|
+
table = alter_table(compiler, element.table_name, element.schema)
|
|
71
|
+
column = alter_column(compiler, element.column_name)
|
|
72
|
+
column_type = cast(TypeEngine[Any], element.type_) # pyright: ignore[reportUnknownMemberType]
|
|
73
|
+
rendered = format_type(compiler, column_type)
|
|
74
|
+
return f"{table} {column} {rendered}"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Fast Arrow column to Python list conversion.
|
|
2
|
+
|
|
3
|
+
``pyarrow.Array.to_pylist`` boxes every element through a generic scalar
|
|
4
|
+
conversion. For bulk reads that cost can dominate pulling the result off the
|
|
5
|
+
wire. ``to_numpy(zero_copy_only=False).tolist()`` does the same work one column
|
|
6
|
+
at a time in compiled code.
|
|
7
|
+
|
|
8
|
+
This is why numpy is a dependency rather than an optional accelerator: without
|
|
9
|
+
it the fastest available path is reading the Arrow values buffer through
|
|
10
|
+
``memoryview.cast``, which matches numpy on fixed-width numerics but does
|
|
11
|
+
nothing for the string and temporal columns where most of the cost actually is.
|
|
12
|
+
|
|
13
|
+
The fast path is guarded, and anything not provably identical to ``to_pylist``
|
|
14
|
+
falls back to it. Each exclusion is a case where numpy returns a different
|
|
15
|
+
Python type or value, and every one is asserted in ``tests/test_convert.py``:
|
|
16
|
+
|
|
17
|
+
* Integer and floating-point columns containing nulls. numpy has no integer
|
|
18
|
+
null and represents both cases with ``nan``.
|
|
19
|
+
* Nanosecond timestamps and times, which come back as a raw ``int`` count
|
|
20
|
+
because numpy cannot represent a nanosecond instant as a ``datetime``.
|
|
21
|
+
* ``date64``, which widens ``datetime.date`` to ``datetime.datetime``.
|
|
22
|
+
* Nested and extension types, which are simply not whitelisted.
|
|
23
|
+
|
|
24
|
+
For a single non-temporal value, the generic scalar conversion is faster than
|
|
25
|
+
setting up numpy, so point-query batches take that path directly.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# pyarrow ships no type information, so its data types, arrays, and predicates
|
|
30
|
+
# all read as unknown under strict mode. As in ``arrow.py``, Arrow objects are
|
|
31
|
+
# annotated ``Any`` and their concrete types given per function.
|
|
32
|
+
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false
|
|
33
|
+
|
|
34
|
+
import datetime
|
|
35
|
+
from typing import Any, Final, cast
|
|
36
|
+
|
|
37
|
+
import pyarrow as pa
|
|
38
|
+
|
|
39
|
+
# Temporal units numpy converts to the same Python object ``to_pylist`` returns.
|
|
40
|
+
_SAFE_TEMPORAL_UNITS: Final = frozenset({"s", "ms", "us"})
|
|
41
|
+
_ARROW_EXTENSION_NAME: Final = b"ARROW:extension:name"
|
|
42
|
+
_MONETDB_HUGEINT_EXTENSION: Final = b"monetdb.hugeint"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _numpy_safe(data_type: Any) -> bool:
|
|
46
|
+
"""Whether ``to_numpy().tolist()`` round-trips this ``pyarrow.DataType`` exactly.
|
|
47
|
+
|
|
48
|
+
A whitelist rather than a blacklist: an unrecognized type falls back to
|
|
49
|
+
``to_pylist`` instead of silently converting through an unverified path.
|
|
50
|
+
"""
|
|
51
|
+
if pa.types.is_timestamp(data_type):
|
|
52
|
+
return data_type.unit in _SAFE_TEMPORAL_UNITS
|
|
53
|
+
|
|
54
|
+
if pa.types.is_time(data_type):
|
|
55
|
+
return data_type.unit in _SAFE_TEMPORAL_UNITS
|
|
56
|
+
|
|
57
|
+
if pa.types.is_date(data_type):
|
|
58
|
+
return bool(pa.types.is_date32(data_type))
|
|
59
|
+
|
|
60
|
+
return bool(
|
|
61
|
+
pa.types.is_integer(data_type)
|
|
62
|
+
or pa.types.is_floating(data_type)
|
|
63
|
+
or pa.types.is_boolean(data_type)
|
|
64
|
+
or pa.types.is_string(data_type)
|
|
65
|
+
or pa.types.is_large_string(data_type)
|
|
66
|
+
or pa.types.is_binary(data_type)
|
|
67
|
+
or pa.types.is_large_binary(data_type)
|
|
68
|
+
or pa.types.is_decimal(data_type)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _timestamp_to_pylist(column: Any) -> list[datetime.datetime]:
|
|
73
|
+
values = cast(list[datetime.datetime], column.to_numpy(zero_copy_only=False).tolist())
|
|
74
|
+
timezone = cast(datetime.tzinfo, cast(Any, pa).lib.string_to_tzinfo(column.type.tz))
|
|
75
|
+
if timezone.utcoffset(None) == datetime.timedelta(0):
|
|
76
|
+
return [value.replace(tzinfo=timezone) for value in values]
|
|
77
|
+
|
|
78
|
+
return [value.replace(tzinfo=datetime.UTC).astimezone(timezone) for value in values]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def column_to_pylist(column: Any) -> list[Any]:
|
|
82
|
+
"""Convert one :class:`pyarrow.Array` to Python objects.
|
|
83
|
+
|
|
84
|
+
Always equal to ``column.to_pylist()``, down to the exact Python type of
|
|
85
|
+
every element.
|
|
86
|
+
"""
|
|
87
|
+
nulls_change_values = column.null_count and (pa.types.is_integer(column.type) or pa.types.is_floating(column.type))
|
|
88
|
+
tiny_scalar_batch = len(column) == 1 and not pa.types.is_temporal(column.type)
|
|
89
|
+
timestamp_with_timezone = bool(pa.types.is_timestamp(column.type) and column.type.tz is not None)
|
|
90
|
+
if timestamp_with_timezone and not column.null_count and _numpy_safe(column.type):
|
|
91
|
+
return cast(list[Any], _timestamp_to_pylist(column))
|
|
92
|
+
|
|
93
|
+
if tiny_scalar_batch or nulls_change_values or timestamp_with_timezone or not _numpy_safe(column.type):
|
|
94
|
+
return cast(list[Any], column.to_pylist())
|
|
95
|
+
|
|
96
|
+
return cast(list[Any], column.to_numpy(zero_copy_only=False).tolist())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def batch_to_rows(batch: Any) -> list[tuple[Any, ...]]:
|
|
100
|
+
"""Convert a :class:`pyarrow.RecordBatch` to row tuples, one column at a time."""
|
|
101
|
+
columns: list[list[Any]] = []
|
|
102
|
+
for field, column in zip(batch.schema, batch.columns, strict=True):
|
|
103
|
+
values = column_to_pylist(column)
|
|
104
|
+
if field.metadata and field.metadata.get(_ARROW_EXTENSION_NAME) == _MONETDB_HUGEINT_EXTENSION:
|
|
105
|
+
values = [None if value is None else int(value) for value in values]
|
|
106
|
+
columns.append(values)
|
|
107
|
+
return list(zip(*columns, strict=True))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import Any, Literal, cast
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.engine import Connection
|
|
6
|
+
from sqlalchemy.sql.compiler import SQLCompiler
|
|
7
|
+
from sqlalchemy.sql.elements import ClauseElement
|
|
8
|
+
from sqlalchemy.sql.expression import Executable
|
|
9
|
+
|
|
10
|
+
from sqlalchemy_monetdb_adbc.connection import raw_adbc_connection
|
|
11
|
+
|
|
12
|
+
# pyarrow ships no type information, so the Arrow objects these helpers return
|
|
13
|
+
# and accept are annotated as Any. Their concrete types are given per function.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compile_arrow_statement(connection: Connection, statement: Executable | str) -> tuple[str, list[Any]]:
|
|
17
|
+
if isinstance(statement, str):
|
|
18
|
+
return statement, []
|
|
19
|
+
|
|
20
|
+
schema_translate_map = connection.get_execution_options().get("schema_translate_map")
|
|
21
|
+
compiled = cast(
|
|
22
|
+
SQLCompiler,
|
|
23
|
+
cast(ClauseElement, statement).compile(
|
|
24
|
+
dialect=connection.dialect,
|
|
25
|
+
schema_translate_map=schema_translate_map,
|
|
26
|
+
render_schema_translate=bool(schema_translate_map),
|
|
27
|
+
compile_kwargs={"render_postcompile": True},
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
parameters = compiled.params
|
|
31
|
+
# The dialect uses qmark, so bind values go positionally in the order the
|
|
32
|
+
# compiler emitted them.
|
|
33
|
+
ordered = [parameters[name] for name in compiled.positiontup or ()]
|
|
34
|
+
return str(compiled), ordered
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch_arrow_table(
|
|
38
|
+
connection: Connection,
|
|
39
|
+
statement: Executable | str,
|
|
40
|
+
parameters: list[Any] | None = None,
|
|
41
|
+
) -> Any:
|
|
42
|
+
"""Run a statement on ``connection`` and return a ``pyarrow.Table``.
|
|
43
|
+
|
|
44
|
+
The query runs on the ADBC session backing ``connection``, so it sees that
|
|
45
|
+
connection's uncommitted work and takes part in its transaction. Rows are
|
|
46
|
+
never converted to Python objects.
|
|
47
|
+
"""
|
|
48
|
+
sql, bound = compile_arrow_statement(connection, statement)
|
|
49
|
+
adbc_connection = raw_adbc_connection(connection)
|
|
50
|
+
|
|
51
|
+
with adbc_connection.cursor() as cursor:
|
|
52
|
+
adbc_cursor = cast(Any, cursor)
|
|
53
|
+
adbc_cursor.execute(sql, parameters if parameters is not None else bound)
|
|
54
|
+
return adbc_cursor.fetch_arrow_table()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager # pyright: ignore[reportDeprecated]
|
|
58
|
+
def fetch_record_batches(
|
|
59
|
+
connection: Connection,
|
|
60
|
+
statement: Executable | str,
|
|
61
|
+
parameters: list[Any] | None = None,
|
|
62
|
+
) -> Iterator[Any]:
|
|
63
|
+
"""Stream a statement's result as a ``pyarrow.RecordBatchReader``.
|
|
64
|
+
|
|
65
|
+
Like :func:`fetch_arrow_table`, but the result is not materialized at once.
|
|
66
|
+
A context manager, because the reader stays valid only while the cursor
|
|
67
|
+
behind it is open. MonetDB carries one result channel per session, so
|
|
68
|
+
finish the stream before using ``connection`` again.
|
|
69
|
+
"""
|
|
70
|
+
sql, bound = compile_arrow_statement(connection, statement)
|
|
71
|
+
adbc_connection = raw_adbc_connection(connection)
|
|
72
|
+
|
|
73
|
+
with adbc_connection.cursor() as cursor:
|
|
74
|
+
adbc_cursor = cast(Any, cursor)
|
|
75
|
+
adbc_cursor.execute(sql, parameters if parameters is not None else bound)
|
|
76
|
+
yield adbc_cursor.fetch_record_batch()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ingest_arrow(
|
|
80
|
+
connection: Connection,
|
|
81
|
+
table_name: str,
|
|
82
|
+
data: Any,
|
|
83
|
+
*,
|
|
84
|
+
mode: Literal["append", "create", "replace", "create_append"] = "append",
|
|
85
|
+
schema_name: str | None = None,
|
|
86
|
+
) -> int:
|
|
87
|
+
"""Bulk-load Arrow data into ``table_name`` on ``connection``'s transaction.
|
|
88
|
+
|
|
89
|
+
``data`` is anything ADBC accepts: a ``pyarrow`` table, record batch, or
|
|
90
|
+
reader, or any object exposing the Arrow PyCapsule interface. Returns the
|
|
91
|
+
number of rows written.
|
|
92
|
+
"""
|
|
93
|
+
adbc_connection = raw_adbc_connection(connection)
|
|
94
|
+
|
|
95
|
+
with adbc_connection.cursor() as cursor:
|
|
96
|
+
return cast(int, cast(Any, cursor).adbc_ingest(table_name, data, mode=mode, db_schema_name=schema_name))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["fetch_arrow_table", "fetch_record_batches", "ingest_arrow"]
|