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.
@@ -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"]