mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
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.
222 lines
8.5 KiB
Python
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)
|