You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/28 16:56:00 UTC

(superset) branch feat/testcontainers-trivial-dialects created (now 6d151499746)

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

rusackas pushed a change to branch feat/testcontainers-trivial-dialects
in repository https://gitbox.apache.org/repos/asf/superset.git


      at 6d151499746 feat(ci): expand testcontainers coverage to postgres, mysql, clickhouse, starrocks

This branch includes the following new commits:

     new 6d151499746 feat(ci): expand testcontainers coverage to postgres, mysql, clickhouse, starrocks

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 01/01: feat(ci): expand testcontainers coverage to postgres, mysql, clickhouse, starrocks

Posted by ru...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch feat/testcontainers-trivial-dialects
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 6d151499746aeb34911f373a00d6cd43bd26ec18
Author: Superset Dev <de...@superset.apache.org>
AuthorDate: Fri Aug 28 09:54:36 2026 -0700

    feat(ci): expand testcontainers coverage to postgres, mysql, clickhouse, starrocks
    
    Stacked on feat/testcontainers-more-dialects. All four extras already
    existed in pyproject.toml, so this only wires up tests -- no new
    optional-dependency groups needed.
    
    - postgres/mysql: straight copies of the timescaledb/mariadb pattern
      respectively, pointed at vanilla images instead of a fork/extension.
    - clickhouse: connects over the container's HTTP port (8123), matching
      clickhouse-connect (Superset's driver), not the native TCP port (9000)
      the container's own docstring example uses. ClickHouse has no real
      primary-key concept and clickhouse-connect's DDL compiler rejects
      CREATE TABLE without an explicit engine, so _pagination.py gained an
      optional extra_table_args hook to pass MergeTree(order_by=...). Also
      works around a pre-existing quirk in db_engine_specs/clickhouse.py:
      its module-level type-formatting setup dereferences current_app.config,
      so importing it outside a Flask app context raises RuntimeError --
      tests/unit_tests/db_engine_specs/test_clickhouse.py already works
      around this with per-test local imports, but that suite also benefits
      from an autouse app_context fixture this suite doesn't have, so this
      test pushes one explicitly around the one-time import.
    - starrocks: no dedicated testcontainers module, so a generic
      DockerContainer against the official allin1-ubuntu image (FE+BE in one
      container). Not verified locally (multiple-GB image, skipped to keep
      local Docker load low per session guidance); the fixture retries its
      first connection since the query port can accept TCP before StarRocks'
      query engine is fully initialized.
---
 .github/workflows/testcontainers.yml               |  11 ++
 requirements/development.in                        |  10 +-
 requirements/development.txt                       |  21 +++-
 .../testcontainers/db_engine_specs/_pagination.py  |   7 +-
 .../db_engine_specs/test_clickhouse.py             | 130 +++++++++++++++++++
 tests/testcontainers/db_engine_specs/test_mysql.py | 117 +++++++++++++++++
 .../db_engine_specs/test_postgres.py               |  99 +++++++++++++++
 .../db_engine_specs/test_starrocks.py              | 139 +++++++++++++++++++++
 8 files changed, 529 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/testcontainers.yml b/.github/workflows/testcontainers.yml
index 07e0f31ae90..9cb6a92426f 100644
--- a/.github/workflows/testcontainers.yml
+++ b/.github/workflows/testcontainers.yml
@@ -72,6 +72,17 @@ jobs:
             timeout: 10
           - dialect: mongodb
             timeout: 10
+          - dialect: postgres
+            timeout: 10
+          - dialect: mysql
+            timeout: 10
+          - dialect: clickhouse
+            timeout: 10
+          # StarRocks' allin1-ubuntu image brings up both FE and BE in one
+          # container, which is a heavier bring-up than a single-process
+          # database -- wider margin until real CI data says otherwise.
+          - dialect: starrocks
+            timeout: 15
     timeout-minutes: ${{ matrix.timeout }}
     env:
       PYTHONPATH: ${{ github.workspace }}
diff --git a/requirements/development.in b/requirements/development.in
index 5ada86fabfd..cc686c40cc4 100644
--- a/requirements/development.in
+++ b/requirements/development.in
@@ -16,7 +16,7 @@
 # specific language governing permissions and limitations
 # under the License.
 #
--e .[development,bigquery,cockroachdb,crate,druid,duckdb,elasticsearch,fastmcp,gevent,gsheets,monetdb,mongodb,mssql,mysql,oracle,postgres,presto,prophet,trino,thumbnails]
+-e .[development,bigquery,clickhouse,cockroachdb,crate,druid,duckdb,elasticsearch,fastmcp,gevent,gsheets,monetdb,mongodb,mssql,mysql,oracle,postgres,presto,prophet,starrocks,trino,thumbnails]
 -e ./superset-extensions-cli[test]
 # testcontainers-backed db_engine_specs tests (tests/testcontainers/) --
 # see .github/workflows/testcontainers.yml
@@ -30,5 +30,11 @@
 # mariadb/timescaledb/yugabytedb need no testcontainers extra of their own:
 # they reuse the postgres/mysql container classes pointed at a different
 # image, and psycopg2-binary/mysqlclient are already pulled in above via
-# the postgres/mysql extras.
+# the postgres/mysql extras. Plain postgres/mysql obviously need nothing
+# extra either. clickhouse and starrocks also need no testcontainers extra:
+# ClickHouseContainer has no driver import of its own (clickhouse-connect,
+# pulled in above via the clickhouse extra, is all the test needs), and
+# StarRocks has no dedicated testcontainers module at all -- its test uses
+# a generic DockerContainer plus the same mysqlclient the mysql extra
+# already provides.
 testcontainers[cockroachdb,cratedb,mongodb,mssql,mysql,oracle,postgres,trino]>=4.15.0,<5
diff --git a/requirements/development.txt b/requirements/development.txt
index 8a7b642739c..5dfb197045d 100644
--- a/requirements/development.txt
+++ b/requirements/development.txt
@@ -16,6 +16,7 @@ alembic==1.15.2
     # via
     #   -c requirements/base-constraint.txt
     #   flask-migrate
+    #   starrocks
 amqp==5.3.1
     # via
     #   -c requirements/base-constraint.txt
@@ -44,6 +45,8 @@ apsw==3.50.1.0
     #   shillelagh
 astroid==3.3.10
     # via pylint
+asyncmy2==0.2.21
+    # via starrocks
 attrs==25.3.0
     # via
     #   -c requirements/base-constraint.txt
@@ -67,6 +70,7 @@ backports-tarfile==1.2.0
 backports-zstd==1.6.0
     # via
     #   -c requirements/base-constraint.txt
+    #   clickhouse-connect
     #   flask-compress
 bcrypt==4.3.0
     # via
@@ -119,6 +123,7 @@ celery==5.6.3
 certifi==2026.5.20
     # via
     #   -c requirements/base-constraint.txt
+    #   clickhouse-connect
     #   elasticsearch
     #   httpcore
     #   httpx
@@ -164,6 +169,8 @@ click-repl==0.3.0
     # via
     #   -c requirements/base-constraint.txt
     #   celery
+clickhouse-connect==1.7.2
+    # via apache-superset
 cmdstanpy==1.1.0
     # via prophet
 colorama==0.4.6
@@ -521,6 +528,8 @@ kombu==5.6.2
     # via
     #   -c requirements/base-constraint.txt
     #   celery
+lark==1.3.1
+    # via starrocks
 lazy-object-proxy==1.10.0
     # via openapi-spec-validator
 limits==5.1.0
@@ -528,7 +537,9 @@ limits==5.1.0
     #   -c requirements/base-constraint.txt
     #   flask-limiter
 lz4==4.4.5
-    # via trino
+    # via
+    #   clickhouse-connect
+    #   trino
 mako==1.4.1
     # via
     #   -c requirements/base-constraint.txt
@@ -819,7 +830,9 @@ pymssql==2.3.13
     #   apache-superset
     #   testcontainers
 pymysql==1.2.0
-    # via testcontainers
+    # via
+    #   starrocks
+    #   testcontainers
 pynacl==1.6.2
     # via
     #   -c requirements/base-constraint.txt
@@ -1015,6 +1028,7 @@ sqlalchemy==2.0.52
     #   sqlalchemy-cratedb
     #   sqlalchemy-monetdb
     #   sqlalchemy-utils
+    #   starrocks
     #   testcontainers
 sqlalchemy-bigquery==1.17.2
     # via apache-superset
@@ -1053,6 +1067,8 @@ starlette==1.3.1
     # via
     #   fastmcp-slim
     #   mcp
+starrocks==1.3.4
+    # via apache-superset
 statsd==4.0.1
     # via apache-superset
 syntaqlite==0.9.0
@@ -1128,6 +1144,7 @@ urllib3==2.7.0
     # via
     #   -c requirements/base-constraint.txt
     #   botocore
+    #   clickhouse-connect
     #   crate
     #   docker
     #   elasticsearch
diff --git a/tests/testcontainers/db_engine_specs/_pagination.py b/tests/testcontainers/db_engine_specs/_pagination.py
index 3ebf7eb2703..582bce888f5 100644
--- a/tests/testcontainers/db_engine_specs/_pagination.py
+++ b/tests/testcontainers/db_engine_specs/_pagination.py
@@ -26,10 +26,13 @@ real execution can.
 Each call site keeps its own test function (and dialect-specific docstring)
 so failures still report against the right module; this only factors out
 the identical table setup/assert body, via an optional post-insert hook for
-dialects (CrateDB) that need one.
+dialects (CrateDB) that need one, and an optional extra-table-args hook for
+dialects (ClickHouse) whose CREATE TABLE requires a schema item a plain
+Column/primary key can't express.
 """
 
 from collections.abc import Callable
+from typing import Any
 
 from sqlalchemy import Column, insert, Integer, MetaData, select, Table as SATable
 from sqlalchemy.engine import Connection, Engine
@@ -38,6 +41,7 @@ from sqlalchemy.engine import Connection, Engine
 def assert_paginated_query_returns_correct_rows_in_order(
     engine: Engine,
     after_insert: Callable[[Connection], None] | None = None,
+    extra_table_args: tuple[Any, ...] = (),
 ) -> None:
     metadata = MetaData()
     t = SATable(
@@ -49,6 +53,7 @@ def assert_paginated_query_returns_correct_rows_in_order(
         # is off), so the id=0 row below would silently get auto-assigned 1,
         # colliding with the explicit id=1 row in the same batch insert.
         Column("id", Integer, primary_key=True, autoincrement=False),
+        *extra_table_args,
     )
     metadata.create_all(engine)
     with engine.begin() as conn:
diff --git a/tests/testcontainers/db_engine_specs/test_clickhouse.py b/tests/testcontainers/db_engine_specs/test_clickhouse.py
new file mode 100644
index 00000000000..de574d1c08d
--- /dev/null
+++ b/tests/testcontainers/db_engine_specs/test_clickhouse.py
@@ -0,0 +1,130 @@
+# 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.clickhouse against a real ClickHouse instance, spun
+up on demand via testcontainers. Run via .github/workflows/testcontainers.yml.
+
+Superset's recommended ClickHouse connector is `clickhouse-connect`
+(`ClickHouseConnectEngineSpec`, engine "clickhousedb"), which talks HTTP,
+not `ClickHouseContainer`'s own documented `clickhouse_driver` (a different,
+native-TCP-protocol package Superset doesn't use at all). The container
+exposes both the native TCP port (9000) and the HTTP port (8123); this test
+connects over the HTTP port to match Superset's actual driver.
+
+Unlike every other dialect in this suite, ClickHouse tables have no real
+primary key/constraint concept -- CREATE TABLE requires an explicit engine
+(e.g. MergeTree), or clickhouse-connect's DDL compiler raises a CompileError
+rather than defaulting to one.
+
+`superset.db_engine_specs.clickhouse` runs module-level setup code (default
+type-formatting overrides) that dereferences `current_app.config` whenever
+clickhouse-connect is installed, so importing it outside a Flask app context
+raises RuntimeError the first time it's imported in a process.
+`tests/unit_tests/db_engine_specs/test_clickhouse.py` gets an app context
+for free from that suite's autouse fixture; this suite has no such fixture,
+so this test pushes one explicitly around just that one-time import, reusing
+the real app instance `tests/conftest.py` already builds for the rest of the
+test run rather than constructing a second one.
+"""
+
+from collections.abc import Iterator
+
+import pytest
+from sqlalchemy import (
+    Column,
+    create_engine,
+    inspect,
+    Integer,
+    MetaData,
+    Table as SATable,
+)
+from sqlalchemy.engine import Engine
+
+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.community.clickhouse")
+require_driver("clickhouse_connect")
+
+from clickhouse_connect.cc_sqlalchemy.ddl.tableengine import MergeTree  # noqa: E402
+from testcontainers.community.clickhouse import ClickHouseContainer  # noqa: E402
+
+from ._pagination import (  # noqa: E402
+    assert_paginated_query_returns_correct_rows_in_order,
+)
+
+HTTP_PORT = 8123
+
+
+@pytest.fixture(scope="module")
+def engine() -> Iterator[Engine]:
+    with ClickHouseContainer("clickhouse/clickhouse-server:latest") as container:
+        host = container.get_container_host_ip()
+        port = container.get_exposed_port(HTTP_PORT)
+        yield create_engine(
+            f"clickhousedb://{container.username}:{container.password}"
+            f"@{host}:{port}/{container.dbname}"
+        )
+
+
+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, extra_table_args=(MergeTree(order_by="id"),)
+    )
+
+
+def test_get_columns_maps_native_types(engine: Engine) -> None:
+    """
+    ClickHouseConnectEngineSpec.get_columns wraps a real SQLAlchemy
+    Inspector; this exercises that against actual server-reported column
+    metadata rather than a mocked Inspector.
+    """
+    from tests.integration_tests.test_app import app
+
+    with app.app_context():
+        from superset.db_engine_specs.clickhouse import ClickHouseConnectEngineSpec
+
+    metadata = MetaData()
+    SATable(
+        "pilot_types",
+        metadata,
+        Column("id", Integer, primary_key=True),
+        Column("amount", Integer),
+        MergeTree(order_by="id"),
+    )
+    metadata.create_all(engine)
+
+    inspector = inspect(engine)
+    columns = ClickHouseConnectEngineSpec.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 = ClickHouseConnectEngineSpec.get_column_spec(str(col["type"]))
+        assert spec is not None
+        assert spec.generic_type == GenericDataType.NUMERIC
+        assert isinstance(spec.sqla_type, Integer)
diff --git a/tests/testcontainers/db_engine_specs/test_mysql.py b/tests/testcontainers/db_engine_specs/test_mysql.py
new file mode 100644
index 00000000000..cc80c0e493e
--- /dev/null
+++ b/tests/testcontainers/db_engine_specs/test_mysql.py
@@ -0,0 +1,117 @@
+# 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.mysql against a real MySQL instance, spun up on
+demand via testcontainers. Run via .github/workflows/testcontainers.yml.
+
+Plain MySQL itself was never covered by this suite: MariaDB and StarRocks
+both reuse `MySQLEngineSpec`'s plain "mysql" dialect via mysqlclient, but
+neither stands in for vanilla MySQL server's own dialect quirks.
+
+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. CI installs it via apt on
+Linux, where this does not occur.
+"""
+
+from collections.abc import Iterator
+
+import pytest
+from sqlalchemy import (
+    Column,
+    create_engine,
+    inspect,
+    Integer,
+    MetaData,
+    Table as SATable,
+)
+from sqlalchemy.engine import Engine
+
+from superset.db_engine_specs.mysql import MySQLEngineSpec
+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.community.mysql")
+
+from testcontainers.community.mysql import MySqlContainer  # noqa: E402
+
+from ._pagination import (  # noqa: E402
+    assert_paginated_query_returns_correct_rows_in_order,
+)
+
+
+@pytest.fixture(scope="module")
+def engine() -> Iterator[Engine]:
+    with MySqlContainer("mysql:8.0") as container:
+        # get_connection_url() has no host override and defaults to
+        # get_container_host_ip(), which is the literal string "localhost"
+        # on native Linux Docker (e.g. GitHub Actions runners). MySQLdb
+        # (mysqlclient) treats a "localhost" host specially and attempts a
+        # Unix socket connection instead of TCP, which fails since there's
+        # no local MySQL socket -- the container is reached over the
+        # network. Only rewrite that specific local case to 127.0.0.1; a
+        # remote Docker daemon reports its own real host/IP here, which
+        # must be preserved so the suite can still reach it.
+        host = container.get_container_host_ip()
+        if host == "localhost":
+            host = "127.0.0.1"
+        port = container.get_exposed_port(container.port)
+        yield create_engine(
+            f"mysql://{container.username}:{container.password}"
+            f"@{host}:{port}/{container.dbname}"
+        )
+
+
+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:
+    """
+    MySQLEngineSpec.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 = MySQLEngineSpec.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 = MySQLEngineSpec.get_column_spec(str(col["type"]))
+        assert spec is not None
+        assert spec.generic_type == GenericDataType.NUMERIC
+        assert isinstance(spec.sqla_type, Integer)
diff --git a/tests/testcontainers/db_engine_specs/test_postgres.py b/tests/testcontainers/db_engine_specs/test_postgres.py
new file mode 100644
index 00000000000..40741815775
--- /dev/null
+++ b/tests/testcontainers/db_engine_specs/test_postgres.py
@@ -0,0 +1,99 @@
+# 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.postgres against a real PostgreSQL instance, spun up
+on demand via testcontainers. Run via .github/workflows/testcontainers.yml
+-- these exercise real SQL execution and dialect introspection, which
+mocked unit tests structurally cannot.
+
+Plain Postgres itself was never covered by this suite: CockroachDB,
+TimescaleDB and YugabyteDB all speak the Postgres wire protocol and already
+exercise `PostgresContainer`/the "postgresql" dialect, but none of them
+stand in for vanilla PostgreSQL's own dialect quirks.
+"""
+
+from collections.abc import Iterator
+
+import pytest
+from sqlalchemy import (
+    Column,
+    create_engine,
+    inspect,
+    Integer,
+    MetaData,
+    Table as SATable,
+)
+from sqlalchemy.engine import Engine
+
+from superset.db_engine_specs.postgres import PostgresEngineSpec
+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.community.postgres")
+
+from testcontainers.community.postgres import PostgresContainer  # noqa: E402
+
+from ._pagination import (  # noqa: E402
+    assert_paginated_query_returns_correct_rows_in_order,
+)
+
+
+@pytest.fixture(scope="module")
+def engine() -> Iterator[Engine]:
+    with PostgresContainer("postgres:17-alpine") as container:
+        yield create_engine(container.get_connection_url())
+
+
+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:
+    """
+    PostgresEngineSpec.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 = PostgresEngineSpec.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 = PostgresEngineSpec.get_column_spec(str(col["type"]))
+        assert spec is not None
+        assert spec.generic_type == GenericDataType.NUMERIC
+        assert isinstance(spec.sqla_type, Integer)
diff --git a/tests/testcontainers/db_engine_specs/test_starrocks.py b/tests/testcontainers/db_engine_specs/test_starrocks.py
new file mode 100644
index 00000000000..a82a0ede398
--- /dev/null
+++ b/tests/testcontainers/db_engine_specs/test_starrocks.py
@@ -0,0 +1,139 @@
+# 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.starrocks against a real StarRocks instance, spun up
+on demand via testcontainers. Run via .github/workflows/testcontainers.yml.
+
+StarRocks has no dedicated testcontainers module, so this uses a generic
+DockerContainer against the official `starrocks/allin1-ubuntu` image, which
+brings up both the FE (query frontend, MySQL wire protocol on port 9030)
+and BE (execution backend) in a single container -- a heavier bring-up than
+a single-process database. `root` has no password by default and no
+database exists yet, so the fixture creates one itself before yielding an
+engine pointed at it, retrying that first connection since the FE's query
+port can accept TCP connections slightly before the query engine is fully
+initialized.
+
+Not verified locally in this environment: the `allin1-ubuntu` image is
+multiple GB and was skipped here to keep local Docker resource usage low,
+per session guidance to lean on CI (which has no such constraint) for
+dialects with unusually heavy images.
+"""
+
+import time
+from collections.abc import Iterator
+
+import pytest
+from sqlalchemy import (
+    Column,
+    create_engine,
+    inspect,
+    Integer,
+    MetaData,
+    Table as SATable,
+    text,
+)
+from sqlalchemy.engine import Engine
+from sqlalchemy.exc import OperationalError
+
+from superset.db_engine_specs.starrocks import StarRocksEngineSpec
+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("starrocks")
+
+from testcontainers.core.container import DockerContainer  # noqa: E402
+from testcontainers.core.wait_strategies import PortWaitStrategy  # noqa: E402
+
+from ._pagination import (  # noqa: E402
+    assert_paginated_query_returns_correct_rows_in_order,
+)
+
+QUERY_PORT = 9030
+DBNAME = "pilot"
+
+
+@pytest.fixture(scope="module")
+def engine() -> Iterator[Engine]:
+    container = DockerContainer("starrocks/allin1-ubuntu")
+    container.with_exposed_ports(QUERY_PORT)
+    container.waiting_for(PortWaitStrategy(QUERY_PORT))
+
+    with container:
+        host = container.get_container_host_ip()
+        port = container.get_exposed_port(QUERY_PORT)
+        bootstrap_engine = create_engine(
+            f"starrocks://root:@{host}:{port}/default_catalog.information_schema"
+        )
+
+        last_error: OperationalError | None = None
+        for _ in range(30):
+            try:
+                with bootstrap_engine.begin() as conn:
+                    conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {DBNAME}"))
+                break
+            except OperationalError as ex:
+                last_error = ex
+                time.sleep(2)
+        else:
+            raise RuntimeError(
+                "StarRocks FE never became ready to create a database"
+            ) from last_error
+
+        yield create_engine(f"starrocks://root:@{host}:{port}/default_catalog.{DBNAME}")
+
+
+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:
+    """
+    StarRocksEngineSpec.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 = StarRocksEngineSpec.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 = StarRocksEngineSpec.get_column_spec(str(col["type"]))
+        assert spec is not None
+        assert spec.generic_type == GenericDataType.NUMERIC
+        assert isinstance(spec.sqla_type, Integer)