From 440034758e3aaf32efd2acea8678c4510a52f6c6 Mon Sep 17 00:00:00 2001 From: Pieterjan Spoelders Date: Thu, 23 Oct 2025 09:25:12 -0400 Subject: [PATCH 1/3] Enable dlt to build metadata schema and tables --- sqlalchemy_exasol/base.py | 48 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/sqlalchemy_exasol/base.py b/sqlalchemy_exasol/base.py index 1c32507b..121bf7e6 100644 --- a/sqlalchemy_exasol/base.py +++ b/sqlalchemy_exasol/base.py @@ -692,8 +692,52 @@ def visit_big_integer(self, type_, **kw): def visit_large_binary(self, type_): return self.visit_BLOB(type_) - def visit_datetime(self, type_): - return self.visit_TIMESTAMP(type_) + # --- Date/time --- + + # Some SQLAlchemy versions dispatch DateTime() to 'DATETIME' (upper-case) + def visit_DATETIME(self, type_, **kw): + return "TIMESTAMP" + + # Others dispatch to 'datetime' (lower-case) — keep both for safety + def visit_datetime(self, type_, **kw): + return "TIMESTAMP" + + # If anything ever uses an explicit TIMESTAMP type, make it consistent + def visit_TIMESTAMP(self, type_, **kw): + return "TIMESTAMP" + + # --- Strings / Text --- + + # SA String(length) -> VARCHAR(n); String() -> CLOB (Exasol requires length) + def visit_string(self, type_, **kw): + if type_.length: + return f"VARCHAR({int(type_.length)})" + return "CLOB" + + # SA Text() -> Exasol CLOB (Exasol has no TEXT; VARCHAR requires a length) + def visit_text(self, type_, **kw): + return "CLOB" + + # (optional) SA UnicodeText() -> CLOB as well + def visit_unicode_text(self, type_, **kw): + return "CLOB" + + # --- Numeric / Decimal --- + + # Ensure Numeric/DECIMAL always renders with an explicit scale + def visit_numeric(self, type_, **kw): + # SA may pass scale as None or -1 when only precision was given + p = type_.precision + s = 0 if (type_.scale in (None, -1)) else type_.scale + if p is not None: + return f"DECIMAL({p},{s})" + # sensible fallback if nothing provided + return "DECIMAL(18,0)" + + # Some code paths use DECIMAL directly rather than numeric + def visit_DECIMAL(self, type_, **kw): + return self.visit_numeric(type_, **kw) + class EXAIdentifierPreparer(compiler.IdentifierPreparer): From f86d448662fbc2ef301eba0400ea786a2dcc7fc7 Mon Sep 17 00:00:00 2001 From: Pieterjan Spoelders Date: Thu, 23 Oct 2025 09:25:32 -0400 Subject: [PATCH 2/3] Fix for inserting timestamps --- sqlalchemy_exasol/base.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/sqlalchemy_exasol/base.py b/sqlalchemy_exasol/base.py index 121bf7e6..5310f541 100644 --- a/sqlalchemy_exasol/base.py +++ b/sqlalchemy_exasol/base.py @@ -48,6 +48,7 @@ import logging import re from contextlib import closing +from datetime import datetime import sqlalchemy.exc from sqlalchemy import ( @@ -801,7 +802,28 @@ def _get_schema(sql_compiler, dialect): def should_autocommit_text(self, statement): return AUTOCOMMIT_REGEXP.match(statement) +class EXATimestamp(sqltypes.TypeDecorator): + """Coerce Python datetime to a JSON-serializable wire value for pyexasol. + Exasol TIMESTAMP has no timezone; we format naive/UTC datetimes accordingly. + """ + impl = sqltypes.TIMESTAMP + cache_ok = True + + def bind_processor(self, dialect): + def process(value): + if value is None: + return None + # Normal case: a Python datetime instance + if isinstance(value, datetime): + # Keep microseconds; Exasol accepts 'YYYY-MM-DD HH:MM:SS.ffffff' + return value.strftime("%Y-%m-%d %H:%M:%S.%f") + # Defensive: if a SA DateTime *type* accidentally lands here as a value + if isinstance(value, sqltypes.DateTime): + return None + return value + return process + class EXADialect(default.DefaultDialect): name = "exasol" max_identifier_length = 128 @@ -1243,3 +1265,12 @@ def fkey_rec(): def get_indexes(self, connection, table_name, schema=None, **kw): """EXASolution has no explicit indexes""" return [] + + def type_descriptor(self, typeobj): + """Return a DB-specific TypeEngine for a generic SA type. + + We wrap DateTime columns so their Python values serialize cleanly for pyexasol. + """ + if isinstance(typeobj, sqltypes.DateTime): + return EXATimestamp() + return super().type_descriptor(typeobj) \ No newline at end of file From 0f698561399b271e821c6172f62d60408a2deec7 Mon Sep 17 00:00:00 2001 From: Pieterjan Spoelders Date: Fri, 24 Oct 2025 04:48:14 -0400 Subject: [PATCH 3/3] Add an exception handler for PyExasol errors bubbling up and convert them to proper PEP-249 exceptions --- sqlalchemy_exasol/base.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/sqlalchemy_exasol/base.py b/sqlalchemy_exasol/base.py index 5310f541..21bbfc93 100644 --- a/sqlalchemy_exasol/base.py +++ b/sqlalchemy_exasol/base.py @@ -824,6 +824,20 @@ def process(value): return value return process + +from sqlalchemy import exc as sa_exc + +_PYEXA_TO_SA = { + "ExaQueryError": sa_exc.ProgrammingError, + "ExaAuthError": sa_exc.OperationalError, + "ExaRequestError": sa_exc.OperationalError, + "ExaCommunicationError": sa_exc.OperationalError, + "ExaRuntimeError": sa_exc.DatabaseError, + "ExaConstraintViolationError": sa_exc.IntegrityError, + "ExaIntegrityError": sa_exc.IntegrityError, + "ExaError": sa_exc.DatabaseError, +} + class EXADialect(default.DefaultDialect): name = "exasol" max_identifier_length = 128 @@ -1273,4 +1287,25 @@ def type_descriptor(self, typeobj): """ if isinstance(typeobj, sqltypes.DateTime): return EXATimestamp() - return super().type_descriptor(typeobj) \ No newline at end of file + return super().type_descriptor(typeobj) + + # leave this for true DB-API remapping (ODBC etc.) + dbapi_exception_translation_map = {} + + def do_execute(self, cursor, statement, parameters, context=None): + # print("TRIGGERED DO EXECUTE") + try: + return super().do_execute(cursor, statement, parameters, context) + except Exception as e: + mapped = _PYEXA_TO_SA.get(e.__class__.__name__) + if mapped: + # simplest: construct the SA exception directly + try: + raise mapped(statement, parameters, e) from e + except TypeError: + # handle minor signature differences across SA versions + try: + raise mapped(statement, parameters, e, False) from e + except TypeError: + raise mapped(str(e)) from e + raise \ No newline at end of file