Files
superset2/tests/testcontainers/db_engine_specs/test_oceanbase.py
T
Superset Dev f0f55fbed3 fix(testcontainers): also monkeypatch oceanbase_py's get_columns
Fixing has_table() alone wasn't enough: get_columns() -- the actual
column-introspection call OceanBaseEngineSpec.get_columns() needs, and
what this test's second half exercises -- has the identical
connection.execute(raw string) bug, confirmed on real CI as the same
ObjectNotExecutableError. Patched the same way, reusing has_table()
correctly since self.has_table already resolves to the earlier patch.
2026-08-28 19:46:21 -07:00

222 lines
8.5 KiB
Python

# 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.
"""
Tests db_engine_specs.oceanbase against a real OceanBase instance, spun up
on demand via testcontainers. Run via .github/workflows/testcontainers.yml,
on the nightly cron / manual dispatch only (see `nightly_only: true` on
this dialect's matrix entry) -- OceanBase bootstraps a distributed-style
cluster even in single-node MODE=MINI, a substantially heavier first-boot
than a single-process database, not a good fit for every PR's CI budget.
OceanBaseEngineSpec extends MySQLEngineSpec and its dialect
(oceanbase_py.sqlalchemy.dialect.OceanBaseDialect) extends
MySQLDialect_mysqldb directly with no custom DDL or LIMIT/OFFSET compiler,
so this follows the same mysqlclient-based pattern as MariaDB/MySQL/
StarRocks in this suite -- including the same "localhost" -> "127.0.0.1"
fix MySQLdb needs on native Linux Docker.
Could not be verified locally in this environment: mysqlclient (MySQLdb)
has a pre-existing, unrelated native-library linking issue against this
machine's Homebrew-installed libmysqlclient, and this dialect wasn't
pulled/run locally at all given its heavier resource footprint -- CI-only
verification, matching the nightly_only gating.
oceanbase_py.sqlalchemy.dialect.OceanBaseDialect has real bugs, all
confirmed on real CI, in both has_table() (called by create_all()'s
default checkfirst=True) and get_columns() (called by
OceanBaseEngineSpec.get_columns(), which this suite's second test needs
to actually exercise):
1. Both pass a raw string straight to Connection.execute() (e.g.
`connection.execute(f"DESCRIBE {full_name}")`), which SQLAlchemy 2.0
rejects outright (ObjectNotExecutableError). Every *other* raw-SQL
method in the same dialect module correctly uses
`connection.exec_driver_sql(...)` instead.
2. has_table() never catches the error DESCRIBE raises for a table that
doesn't exist (1146) -- so even with (1) fixed, it can only ever
return True, raising instead of returning False for exactly the case
checkfirst exists to handle.
This test monkeypatches both methods to do what the rest of the dialect's
raw-SQL methods already do, plus has_table()'s missing not-found
handling, rather than working around any of this from the test side.
"""
from collections.abc import Iterator
from typing import Any
import pytest
from sqlalchemy import (
Column,
create_engine,
inspect,
Integer,
MetaData,
Table as SATable,
)
from sqlalchemy.engine import Connection, Engine, URL
from sqlalchemy.exc import NoSuchTableError, ProgrammingError
from superset.db_engine_specs.oceanbase import OceanBaseEngineSpec
from superset.sql.parse import Table
from superset.utils.core import GenericDataType
pytestmark = pytest.mark.testcontainers
from ._driver import require_driver # noqa: E402
require_driver("testcontainers.core.container")
require_driver("oceanbase_py")
from oceanbase_py.sqlalchemy import datatype # noqa: E402
from oceanbase_py.sqlalchemy.dialect import OceanBaseDialect # noqa: E402
from testcontainers.core.container import DockerContainer # noqa: E402
from testcontainers.core.wait_strategies import LogMessageWaitStrategy # noqa: E402
from ._pagination import ( # noqa: E402
assert_paginated_query_returns_correct_rows_in_order,
)
def _has_table(
self: OceanBaseDialect,
connection: Connection,
table_name: str,
schema: str | None = None,
**kw: object,
) -> bool:
if schema is None:
schema = self.default_schema_name
quote = self.identifier_preparer.quote_identifier
full_name = quote(table_name)
if schema:
full_name = f"{quote(schema)}.{full_name}"
try:
res = connection.exec_driver_sql(f"DESCRIBE {full_name}")
except ProgrammingError:
# The original never catches this at all -- DESCRIBE on a
# nonexistent table raises 1146 ("table doesn't exist") rather
# than returning an empty result set, so the unpatched method can
# only ever return True, and raises instead of returning False for
# exactly the case create_all()'s checkfirst exists to handle
# (confirmed on real CI).
return False
return res.first() is not None
def _get_columns(
self: OceanBaseDialect,
connection: Connection,
table_name: str,
schema: str | None = None,
**kw: object,
) -> list[dict[str, Any]]:
# Same connection.execute(raw string) anti-pattern as has_table(),
# confirmed on real CI as the same ObjectNotExecutableError -- this is
# the actual column-introspection call OceanBaseEngineSpec.get_columns
# (and this test) needs, so it gets the same exec_driver_sql fix.
if not self.has_table(connection, table_name, schema):
raise NoSuchTableError(f"schema={schema}, table={table_name}")
schema = schema or self._get_default_schema_name(connection)
quote = self.identifier_preparer.quote_identifier
full_name = quote(table_name)
if schema:
full_name = f"{quote(schema)}.{full_name}"
res = connection.exec_driver_sql(f"SHOW COLUMNS FROM {full_name}")
return [
{
"name": record.Field,
"type": datatype.parse_sql_type(record.Type),
"nullable": record.Null == "YES",
"default": record.Default,
}
for record in res
]
OceanBaseDialect.has_table = _has_table
OceanBaseDialect.get_columns = _get_columns
PORT = 2881
PASSWORD = "pilot" # noqa: S105 -- fixed test-fixture password, not a secret
@pytest.fixture(scope="module")
def engine() -> Iterator[Engine]:
container = DockerContainer("oceanbase/oceanbase-ce")
container.with_exposed_ports(PORT)
container.with_env("MODE", "MINI")
container.with_env("OB_TENANT_PASSWORD", PASSWORD)
container.waiting_for(LogMessageWaitStrategy("boot success!"))
with container:
host = container.get_container_host_ip()
if host == "localhost":
host = "127.0.0.1"
port = container.get_exposed_port(PORT)
# OceanBase usernames for a MySQL-mode tenant use "user@tenant"
# (e.g. "root@test"), a literal "@" that URL.create() percent-encodes
# correctly -- an f-string would produce a second "@" that breaks
# the URL's own host/user boundary parsing.
yield create_engine(
URL.create(
"oceanbase",
username="root@test",
password=PASSWORD,
host=host,
port=int(port),
database="test",
)
)
def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None:
"""
A plain SQLAlchemy Core LIMIT/OFFSET query, compiled and executed against
a real instance. Mocked tests cannot catch a dialect compiling this
incorrectly (see apache/superset#42899, where Trino emitted OFFSET
before LIMIT) -- only real execution can.
"""
assert_paginated_query_returns_correct_rows_in_order(engine)
def test_get_columns_maps_native_types(engine: Engine) -> None:
"""
OceanBaseEngineSpec.get_columns wraps a real SQLAlchemy Inspector; this
exercises that against actual server-reported column metadata rather
than a mocked Inspector.
"""
metadata = MetaData()
SATable(
"pilot_types",
metadata,
Column("id", Integer, primary_key=True),
Column("amount", Integer),
)
metadata.create_all(engine)
inspector = inspect(engine)
columns = OceanBaseEngineSpec.get_columns(inspector, Table("pilot_types"))
by_name = {col["column_name"]: col for col in columns}
assert set(by_name) == {"id", "amount"}
for col in by_name.values():
spec = OceanBaseEngineSpec.get_column_spec(str(col["type"]))
assert spec is not None
assert spec.generic_type == GenericDataType.NUMERIC
assert isinstance(spec.sqla_type, Integer)