This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new d2101c8dba2 fix(exasol): classify common server errors (#44721)
d2101c8dba2 is described below

commit d2101c8dba2d43a275d8d7ccf43bb716f650e74d
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Oct 1 12:49:26 2026 +1000

    fix(exasol): classify common server errors (#44721)
---
 superset/db_engine_specs/exasol.py              |  76 ++++++++++
 tests/unit_tests/db_engine_specs/test_exasol.py | 191 ++++++++++++++++++++++++
 2 files changed, 267 insertions(+)

diff --git a/superset/db_engine_specs/exasol.py 
b/superset/db_engine_specs/exasol.py
index bd83a5d34f0..5614810713e 100644
--- a/superset/db_engine_specs/exasol.py
+++ b/superset/db_engine_specs/exasol.py
@@ -14,10 +14,13 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import re
+from re import Pattern
 from typing import Any, Optional
 
 from superset.constants import TimeGrain
 from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
+from superset.errors import SupersetErrorType
 
 
 class ExasolEngineSpec(BaseEngineSpec):  # pylint: disable=abstract-method
@@ -27,6 +30,40 @@ class ExasolEngineSpec(BaseEngineSpec):  # pylint: 
disable=abstract-method
     engine_name = "Exasol"
     max_column_name_length = 128
 
+    # Keep the server's message from its keyword onwards, including the
+    # position/identifier. The passthrough placeholder has no translatable 
text.
+    custom_errors: dict[Pattern[str], tuple[str, SupersetErrorType, dict[str, 
Any]]] = {
+        re.compile(r"(?P<message>syntax error[^\n]*)", re.IGNORECASE): (
+            "%(message)s",
+            SupersetErrorType.SYNTAX_ERROR,
+            {},
+        ),
+        re.compile(r"(?P<message>table [^\n]* does not exist[^\n]*)", 
re.IGNORECASE): (
+            "%(message)s",
+            SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR,
+            {},
+        ),
+        # Exasol reports a missing column as ``column <NAME> not found``. The
+        # keyword must start the diagnostic (not follow another word, as in
+        # ``object COLUMN not found``) and be followed by at most one qualified
+        # identifier.
+        re.compile(
+            r'(?P<message>(?<![\w"] )(?<![\w.$"])column'
+            r'(?: (?:"[^"\n]*"|[\w$]+)(?:\.(?:"[^"\n]*"|[\w$]+))*)?'
+            r" not found[^\n]*)",
+            re.IGNORECASE,
+        ): (
+            "%(message)s",
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+            {},
+        ),
+        re.compile(r"(?P<message>insufficient privileges[^\n]*)", 
re.IGNORECASE): (
+            "%(message)s",
+            SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
+            {},
+        ),
+    }
+
     metadata = {
         "description": (
             "Exasol is a high-performance, in-memory, MPP analytical database."
@@ -40,6 +77,17 @@ class ExasolEngineSpec(BaseEngineSpec):  # pylint: 
disable=abstract-method
         "pypi_packages": ["sqlalchemy-exasol"],
         "connection_string": "exa+pyodbc://{username}:{password}@{dsn}",
         "default_port": 8563,
+        "notes": (
+            "SQL Lab recognizes Exasol syntax errors, missing-table and 
missing-column "
+            "errors, and insufficient-privilege errors while retaining the 
server's "
+            "diagnostic text. Echoed SQL is excluded from error 
classification. "
+            "An ambiguous `object ... not found` message remains a "
+            "generic database error; it does not distinguish a missing table 
from a "
+            "missing column.\n\n"
+            "For WebSocket connections, use PyExasol 2.4.1 or later to 
preserve server "
+            "messages in DB-API exceptions. Earlier versions can return an 
empty "
+            "error message."
+        ),
         "parameters": {
             "username": "Database username",
             "password": "Database password",
@@ -85,6 +133,34 @@ class ExasolEngineSpec(BaseEngineSpec):  # pylint: 
disable=abstract-method
         TimeGrain.YEAR: "DATE_TRUNC('year', {col})",
     }
 
+    @classmethod
+    def _extract_error_message(cls, ex: Exception) -> str:
+        """Extract the server diagnostic without driver-echoed SQL."""
+        message = super()._extract_error_message(ex)
+        # Strip SQL before looking for a verbose message field: SQL literals
+        # can themselves contain text resembling a driver envelope.
+        # The (?m)^[ \t]* anchor strips only own-line SQL echoes: no Exasol
+        # driver emits same-line [SQL: ...]; SQLAlchemy joins with \n and
+        # PyExasol uses an own-line query => envelope.
+        message = re.split(
+            r"(?m)^[ \t]*(?:query[ \t]*=>|\[SQL:|\[parameters:)",
+            message,
+            maxsplit=1,
+        )[0]
+        if match := re.search(r"(?m)^[ \t]*message[ \t]*=>[ \t]*", message):
+            # PyExasol prints the message first, then its fixed 
connection/query
+            # fields. Only those field names end the message, so a diagnostic
+            # line that itself contains ``=>`` is kept.
+            diagnostic = re.split(
+                r"(?m)^[ \t]*(?:(?:dsn|user|schema|session_id|code|query)[ 
\t]*=>"
+                r"|\)[ \t]*$)",
+                message[match.end() :],
+                maxsplit=1,
+            )[0].strip()
+            if diagnostic:
+                return diagnostic
+        return message.strip()
+
     @classmethod
     def fetch_data(
         cls, cursor: Any, limit: Optional[int] = None
diff --git a/tests/unit_tests/db_engine_specs/test_exasol.py 
b/tests/unit_tests/db_engine_specs/test_exasol.py
new file mode 100644
index 00000000000..9612b11e157
--- /dev/null
+++ b/tests/unit_tests/db_engine_specs/test_exasol.py
@@ -0,0 +1,191 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import pytest
+from sqlalchemy.exc import DBAPIError, StatementError
+
+from superset.db_engine_specs.exasol import ExasolEngineSpec
+from superset.errors import SupersetErrorType
+
+
[email protected](
+    "message, expected",
+    [
+        (
+            "syntax error, unexpected FROM_ [line 1, column 8]",
+            SupersetErrorType.SYNTAX_ERROR,
+        ),
+        (
+            "table DEMO.MISSING does not exist [line 1, column 12]",
+            SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR,
+        ),
+        (
+            "insufficient privileges: SELECT on table PRIVATE_TABLE",
+            SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
+        ),
+        (
+            "column not found [line 1, column 58]",
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+        ),
+        (
+            "column MISSING_COL not found [line 1, column 8]",
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+        ),
+        (
+            'column "Mixed.Case" not found [line 1, column 8]',
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+        ),
+        (
+            'column "T"."C" not found [line 1, column 8]',
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+        ),
+        (
+            'column MY."C" not found [line 1, column 8]',
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+        ),
+        (
+            "column MY.COL not found [line 1, column 8]",
+            SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+        ),
+        ("object AMBIGUOUS not found", 
SupersetErrorType.GENERIC_DB_ENGINE_ERROR),
+        ("object COLUMN not found", SupersetErrorType.GENERIC_DB_ENGINE_ERROR),
+        ("object MY_COLUMN not found", 
SupersetErrorType.GENERIC_DB_ENGINE_ERROR),
+        ('object "column" not found', 
SupersetErrorType.GENERIC_DB_ENGINE_ERROR),
+        ("function TO_COLUMN not found", 
SupersetErrorType.GENERIC_DB_ENGINE_ERROR),
+        (
+            "object COLUMN_X not found [line 1, column 8]",
+            SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+        ),
+        ("another server failure", SupersetErrorType.GENERIC_DB_ENGINE_ERROR),
+    ],
+)
+def test_extract_errors(message: str, expected: SupersetErrorType) -> None:
+    """Classify known messages without discarding the server diagnostic."""
+    errors = ExasolEngineSpec.extract_errors(Exception(message))
+    assert len(errors) == 1
+    assert errors[0].error_type == expected
+    assert errors[0].message == message
+
+
+def test_server_message_templates_are_not_translatable() -> None:
+    """A passthrough placeholder contains no text for translators."""
+    for template, _, _ in ExasolEngineSpec.custom_errors.values():
+        assert type(template) is str
+        assert template == "%(message)s"
+
+
[email protected](
+    "diagnostic, expected",
+    [
+        (
+            "insufficient privileges: INSERT on table P",
+            SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
+        ),
+        ("object COLUMN not found", SupersetErrorType.GENERIC_DB_ENGINE_ERROR),
+        ("another server failure", SupersetErrorType.GENERIC_DB_ENGINE_ERROR),
+    ],
+)
[email protected](
+    "sql",
+    [
+        "SELECT 'syntax error' FROM P",
+        "SELECT 'table MISSING does not exist' FROM P",
+        "SELECT 'column MISSING not found' FROM P",
+        "SELECT 'insufficient privileges' FROM P",
+        "SELECT '\nmessage => syntax error\n' FROM P",
+    ],
+)
[email protected](
+    "envelope", ["verbose", "sqlalchemy", "parameters", "sqlalchemy_exception"]
+)
+def test_extract_errors_ignores_echoed_sql(
+    diagnostic: str, expected: SupersetErrorType, sql: str, envelope: str
+) -> None:
+    """Only server diagnostics, not SQL or parameters, determine error 
types."""
+    if envelope == "verbose":
+        raw = (
+            "exa error: \n(\n"
+            f"    message      =>  {diagnostic}\n"
+            "    dsn          =>  example:8563\n"
+            "    user         =>  syntax error\n"
+            "    code         =>  42000\n"
+            f"    query        =>  {sql}\n"
+            ")\n"
+            f"[SQL: {sql}]\n"
+        )
+    elif envelope == "sqlalchemy":
+        raw = f"{diagnostic}\n[SQL: {sql}]\n[parameters: ()]"
+    else:
+        raw = f"{diagnostic}\n[parameters: ({sql},)]"
+
+    exception = (
+        StatementError(diagnostic, sql, {}, Exception(diagnostic))
+        if envelope == "sqlalchemy_exception"
+        else Exception(raw)
+    )
+    errors = ExasolEngineSpec.extract_errors(exception)
+    assert len(errors) == 1
+    assert errors[0].error_type == expected
+    assert errors[0].message == diagnostic
+
+
+def test_extract_verbose_multiline_diagnostic() -> None:
+    """Preserve multiline generic diagnostics without envelope fields."""
+    diagnostic = "another server failure\nDetails: request could not be 
completed"
+    raw = f"\n(\n    message => {diagnostic}\n)\n"
+    errors = ExasolEngineSpec.extract_errors(Exception(raw))
+    assert errors[0].error_type == SupersetErrorType.GENERIC_DB_ENGINE_ERROR
+    assert errors[0].message == diagnostic
+
+
+def test_extract_verbose_diagnostic_keeps_arrow_lines() -> None:
+    """Only PyExasol's envelope fields end the message field."""
+    diagnostic = "another server failure\nhint => check the view definition"
+    raw = (
+        f"\n(\n    message     =>  {diagnostic}\n"
+        "    dsn         =>  localhost:8563\n"
+        "    user        =>  SYS\n"
+        "    schema      =>  PUBLIC\n"
+        "    session_id  =>  1\n"
+        "    code        =>  42000\n"
+        "    query       =>  SELECT 1\n)\n"
+    )
+    errors = ExasolEngineSpec.extract_errors(Exception(raw))
+    assert errors[0].error_type == SupersetErrorType.GENERIC_DB_ENGINE_ERROR
+    assert errors[0].message == diagnostic
+
+
[email protected]("diagnostic", ["", " \t", "\n \t\n"])
[email protected]("wrapped", [False, True])
+def test_extract_verbose_empty_diagnostic(diagnostic: str, wrapped: bool) -> 
None:
+    """Keep the error code when the message is empty, without echoed SQL."""
+    envelope = (
+        "exa error: \n(\n"
+        f"    message      =>  {diagnostic}\n"
+        "    dsn          =>  example:8563\n"
+        "    user         =>  SYS\n"
+        "    code         =>  42000\n"
+    )
+    sql = "SELECT 'syntax error' FROM P"
+    raw = f"{envelope}    query        =>  {sql}\n)\n[SQL: {sql}]\n"
+    exception = DBAPIError(sql, {}, Exception(raw)) if wrapped else 
Exception(raw)
+    errors = ExasolEngineSpec.extract_errors(exception)
+    assert len(errors) == 1
+    assert errors[0].error_type == SupersetErrorType.GENERIC_DB_ENGINE_ERROR
+    prefix = "(builtins.Exception) " if wrapped else ""
+    assert errors[0].message == prefix + envelope.strip()
+    assert "code         =>  42000" in errors[0].message
+    assert sql not in errors[0].message

Reply via email to