feat(db): custom database error messages (#34674)

This commit is contained in:
Damian Pendrak
2025-10-01 13:29:57 +02:00
committed by GitHub
parent 88e5581d04
commit 19db0353a9
15 changed files with 695 additions and 17 deletions

View File

@@ -20,6 +20,7 @@
from __future__ import annotations
import json # noqa: TID251
import re
from textwrap import dedent
from typing import Any
@@ -30,12 +31,39 @@ from sqlalchemy.dialects import sqlite
from sqlalchemy.engine.url import make_url, URL
from sqlalchemy.sql import sqltypes
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.sql.parse import Table
from superset.superset_typing import ResultSetColumnType, SQLAColumnType
from superset.utils.core import GenericDataType
from tests.unit_tests.db_engine_specs.utils import assert_column_spec
def create_expected_superset_error(
message: str,
error_type: SupersetErrorType = SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
engine_name: str | None = None,
) -> SupersetError:
"""
Helper function to create expected SupersetError objects for testing.
"""
extra = {
"engine_name": engine_name,
"issue_codes": [
{
"code": 1002,
"message": "Issue 1002 - The database returned an unexpected error.",
}
],
}
return SupersetError(
message=message,
error_type=error_type,
level=ErrorLevel.ERROR,
extra=extra,
)
def test_get_text_clause_with_colon() -> None:
"""
Make sure text clauses are correctly escaped
@@ -539,3 +567,330 @@ def test_use_equality_for_boolean_filters_property() -> None:
# Default should be False (use IS operators)
assert BaseEngineSpec.use_equality_for_boolean_filters is False
def test_extract_errors(mocker: MockerFixture) -> None:
"""
Test that error is extracted correctly when no custom error message is provided.
"""
from superset.db_engine_specs.base import BaseEngineSpec
mocker.patch(
"flask.current_app.config",
{},
)
msg = "This connector does not support roles"
result = BaseEngineSpec.extract_errors(Exception(msg))
expected = create_expected_superset_error(
message="This connector does not support roles",
engine_name=None,
)
assert result == [expected]
def test_extract_errors_from_config(mocker: MockerFixture) -> None:
"""
Test that custom error messages are extracted correctly from app config
using database_name.
"""
from superset.db_engine_specs.base import BaseEngineSpec
class TestEngineSpec(BaseEngineSpec):
engine_name = "ExampleEngine"
mocker.patch(
"flask.current_app.config",
{
"CUSTOM_DATABASE_ERRORS": {
"examples": {
re.compile("This connector does not support roles"): (
"Custom error message",
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{},
)
}
}
},
)
msg = "This connector does not support roles"
result = TestEngineSpec.extract_errors(Exception(msg), database_name="examples")
expected = create_expected_superset_error(
message="Custom error message",
engine_name="ExampleEngine",
)
assert result == [expected]
def test_extract_errors_only_to_specified_database(mocker: MockerFixture) -> None:
"""
Test that custom error messages are only applied to the specified database_name.
"""
from superset.db_engine_specs.base import BaseEngineSpec
class TestEngineSpec(BaseEngineSpec):
engine_name = "ExampleEngine"
mocker.patch(
"flask.current_app.config",
{
"CUSTOM_DATABASE_ERRORS": {
"examples": {
re.compile("This connector does not support roles"): (
"Custom error message",
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{},
)
}
}
},
)
msg = "This connector does not support roles"
# database_name doesn't match configured one, so default message is used
result = TestEngineSpec.extract_errors(Exception(msg), database_name="examples_2")
expected = create_expected_superset_error(
message="This connector does not support roles",
engine_name="ExampleEngine",
)
assert result == [expected]
def test_extract_errors_from_config_with_regex(mocker: MockerFixture) -> None:
"""
Test that custom error messages with regex, custom_doc_links,
and show_issue_info are extracted correctly from config.
"""
from superset.db_engine_specs.base import BaseEngineSpec
class TestEngineSpec(BaseEngineSpec):
engine_name = "ExampleEngine"
mocker.patch(
"flask.current_app.config",
{
"CUSTOM_DATABASE_ERRORS": {
"examples": {
re.compile(r'message="(?P<message>[^"]*)"'): (
'Unexpected error: "%(message)s"',
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{
"custom_doc_links": [
{
"url": "https://example.com/docs",
"label": "Check documentation",
},
],
"show_issue_info": False,
},
)
}
}
},
)
msg = (
"db error: SomeUserError(type=USER_ERROR, name=TABLE_NOT_FOUND, "
'message="line 3:6: Table '
"'example_catalog.example_schema.example_table' does not exist"
'", '
"query_id=20250812_074513_00084_kju62)"
)
result = TestEngineSpec.extract_errors(Exception(msg), database_name="examples")
assert result == [
SupersetError(
message=(
'Unexpected error: "line 3:6: Table '
"'example_catalog.example_schema.example_table' does not exist"
'"'
),
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
level=ErrorLevel.ERROR,
extra={
"engine_name": "ExampleEngine",
"issue_codes": [
{
"code": 1002,
"message": "Issue 1002 - The database returned an unexpected error.", # noqa: E501
}
],
"custom_doc_links": [
{
"url": "https://example.com/docs",
"label": "Check documentation",
},
],
"show_issue_info": False,
},
)
]
def test_extract_errors_with_non_dict_custom_errors(mocker: MockerFixture):
"""
Test that extract_errors doesn't fail when custom database errors
are in wrong format.
"""
from superset.db_engine_specs.base import BaseEngineSpec
class TestEngineSpec(BaseEngineSpec):
engine_name = "ExampleEngine"
mocker.patch(
"flask.current_app.config",
{"CUSTOM_DATABASE_ERRORS": "not a dict"},
)
msg = "This connector does not support roles"
result = TestEngineSpec.extract_errors(Exception(msg))
expected = create_expected_superset_error(
message="This connector does not support roles",
engine_name="ExampleEngine",
)
assert result == [expected]
def test_extract_errors_with_non_dict_engine_custom_errors(mocker: MockerFixture):
"""
Test that extract_errors doesn't fail when database-specific custom errors
are in wrong format.
"""
from superset.db_engine_specs.base import BaseEngineSpec
class TestEngineSpec(BaseEngineSpec):
engine_name = "ExampleEngine"
mocker.patch(
"flask.current_app.config",
{"CUSTOM_DATABASE_ERRORS": {"examples": "not a dict"}},
)
msg = "This connector does not support roles"
result = TestEngineSpec.extract_errors(Exception(msg), database_name="examples")
expected = create_expected_superset_error(
message="This connector does not support roles",
engine_name="ExampleEngine",
)
assert result == [expected]
def test_extract_errors_with_empty_custom_error_message(mocker: MockerFixture):
"""
Test that when the custom error message is empty,
the original error message is preserved.
"""
from superset.db_engine_specs.base import BaseEngineSpec
class TestEngineSpec(BaseEngineSpec):
engine_name = "ExampleEngine"
mocker.patch(
"flask.current_app.config",
{
"CUSTOM_DATABASE_ERRORS": {
"examples": {
re.compile("This connector does not support roles"): (
"",
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{},
)
}
}
},
)
msg = "This connector does not support roles"
result = TestEngineSpec.extract_errors(Exception(msg), database_name="examples")
expected = create_expected_superset_error(
message="This connector does not support roles",
engine_name="ExampleEngine",
)
assert result == [expected]
def test_extract_errors_matches_database_name_selection(mocker: MockerFixture) -> None:
"""
Test that custom error messages are matched by database_name.
"""
from superset.db_engine_specs.base import BaseEngineSpec
class TestEngineSpec(BaseEngineSpec):
engine_name = "ExampleEngine"
mocker.patch(
"flask.current_app.config",
{
"CUSTOM_DATABASE_ERRORS": {
"examples": {
re.compile("connection error"): (
"Examples DB error message",
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{},
)
},
"examples_2": {
re.compile("connection error"): (
"Examples_2 DB error message",
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{},
)
},
}
},
)
msg = "connection error occurred"
# When database_name is examples_2 we should get that specific message
result = TestEngineSpec.extract_errors(Exception(msg), database_name="examples_2")
expected = create_expected_superset_error(
message="Examples_2 DB error message",
engine_name="ExampleEngine",
)
assert result == [expected]
def test_extract_errors_no_match_falls_back(mocker: MockerFixture) -> None:
"""
Test that when database_name has no match, the original error message is preserved.
"""
from superset.db_engine_specs.base import BaseEngineSpec
class TestEngineSpec(BaseEngineSpec):
engine_name = "ExampleEngine"
mocker.patch(
"flask.current_app.config",
{
"CUSTOM_DATABASE_ERRORS": {
"examples": {
re.compile("connection error"): (
"Examples DB error message",
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{},
)
},
}
},
)
msg = "some other error"
result = TestEngineSpec.extract_errors(Exception(msg), database_name="examples_2")
expected = create_expected_superset_error(
message="some other error",
engine_name="ExampleEngine",
)
assert result == [expected]