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