diff --git a/.github/workflows/testcontainers.yml b/.github/workflows/testcontainers.yml index f8b9b4dd96e..4c42f9a7528 100644 --- a/.github/workflows/testcontainers.yml +++ b/.github/workflows/testcontainers.yml @@ -58,6 +58,16 @@ jobs: timeout: 15 - dialect: db2 timeout: 25 + - dialect: mariadb + timeout: 10 + - dialect: timescaledb + timeout: 10 + - dialect: yugabytedb + timeout: 10 + - dialect: monetdb + timeout: 10 + - dialect: mongodb + timeout: 10 timeout-minutes: ${{ matrix.timeout }} env: PYTHONPATH: ${{ github.workspace }} diff --git a/pyproject.toml b/pyproject.toml index 988f2d42c41..000de46845f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -222,6 +222,11 @@ impala = ["impyla>=0.24.0, <0.25"] # superset/db_engine_specs/kusto.py's known_incompatibilities metadata. kusto = ["sqlalchemy-kusto>=3.1.2, <4"] kylin = ["kylinpy>=2.8.4, <2.9"] +# MariaDB is a MySQL fork implementing the same wire protocol - connects via +# the plain mysql dialect, same driver as mysql. +mariadb = ["apache-superset[mysql]"] +monetdb = ["sqlalchemy-monetdb>=2.1.0, <3", "pymonetdb>=1.9.1, <2"] +mongodb = ["pymongosql>=0.7.3, <1"] mssql = ["pymssql>=2.3.13, <3"] # motherduck is an alias for duckdb - MotherDuck works via the duckdb driver motherduck = ["apache-superset[duckdb]"] @@ -270,6 +275,9 @@ tdengine = [ "taos-ws-py>=0.7.0" ] teradata = ["teradatasql>=20.0.0.66"] +# TimescaleDB is a genuine Postgres extension, not a fork - connects via the +# plain postgresql dialect, same driver as postgres. +timescaledb = ["apache-superset[postgres]"] thumbnails = [] # deprecated, will be removed in 7.0 vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"] netezza = ["nzalchemy>= 11.1.2, < 11.2"] @@ -277,6 +285,9 @@ starrocks = ["starrocks>=1.3.4, <2"] doris = ["pydoris>=1.2.0, <2.0.0"] oceanbase = ["oceanbase_py>=0.0.1.2"] ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"] +# YugabyteDB's YSQL layer is fully Postgres-wire compatible - connects via +# the plain postgresql dialect, same driver as postgres. +yugabytedb = ["apache-superset[postgres]"] development = [ # no bounds for apache-superset-extensions-cli until a stable version "apache-superset-extensions-cli", diff --git a/requirements/development.in b/requirements/development.in index e788b30c28d..b55dbd1636d 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,mssql,mysql,oracle,postgres,presto,prophet,trino,thumbnails] +-e .[development,bigquery,cockroachdb,crate,druid,duckdb,elasticsearch,fastmcp,gevent,gsheets,monetdb,mongodb,mssql,mysql,oracle,postgres,presto,prophet,trino,thumbnails] -e ./superset-extensions-cli[test] # testcontainers-backed db_engine_specs tests (tests/testcontainers/) -- # see .github/workflows/testcontainers.yml @@ -26,4 +26,9 @@ # including it here breaks the multi-platform (amd64+arm64) dev Docker # image build. The testcontainers CI job installs it on demand, only for # the db2 matrix leg -- see .github/workflows/testcontainers.yml. -testcontainers[cockroachdb,cratedb,db2,mssql,oracle,trino]>=4.15.0,<5 +# +# 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. +testcontainers[cockroachdb,cratedb,db2,mongodb,mssql,mysql,oracle,postgres,trino]>=4.15.0,<5 diff --git a/requirements/development.txt b/requirements/development.txt index cda0b755dae..31b3435444a 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -24,6 +24,8 @@ annotated-types==0.7.0 # via # -c requirements/base-constraint.txt # pydantic +antlr4-python3-runtime==4.13.2 + # via pymongosql anyio==4.11.0 # via # httpx @@ -193,6 +195,7 @@ cryptography==50.0.0 # oracledb # paramiko # pyjwt + # pymysql # pyopenssl # secretstorage cycler==0.12.1 @@ -221,6 +224,7 @@ dnspython==2.7.0 # via # -c requirements/base-constraint.txt # email-validator + # pymongo docker==7.2.0 # via # apache-superset @@ -488,6 +492,7 @@ jmespath==1.1.0 # via # boto3 # botocore + # pymongosql joserfc==1.7.2 # via fastmcp-slim jsonpath-ng==1.8.0 @@ -803,10 +808,22 @@ pyjwt==2.13.0 # mcp pylint==3.3.7 # via apache-superset +pymonetdb==1.9.1 + # via + # apache-superset + # sqlalchemy-monetdb +pymongo==4.17.0 + # via + # pymongosql + # testcontainers +pymongosql==0.7.3 + # via apache-superset pymssql==2.3.13 # via # apache-superset # testcontainers +pymysql==1.2.0 + # via testcontainers pynacl==1.6.2 # via # -c requirements/base-constraint.txt @@ -1001,6 +1018,7 @@ sqlalchemy==2.0.52 # sqlalchemy-cockroachdb # sqlalchemy-continuum # sqlalchemy-cratedb + # sqlalchemy-monetdb # sqlalchemy-utils # testcontainers sqlalchemy-bigquery==1.17.2 @@ -1015,6 +1033,8 @@ sqlalchemy-cratedb==0.43.1 # via # apache-superset # testcontainers +sqlalchemy-monetdb==2.1.0 + # via apache-superset sqlalchemy-utils==0.42.1 # via # -c requirements/base-constraint.txt diff --git a/tests/testcontainers/db_engine_specs/test_mariadb.py b/tests/testcontainers/db_engine_specs/test_mariadb.py new file mode 100644 index 00000000000..2689f897d50 --- /dev/null +++ b/tests/testcontainers/db_engine_specs/test_mariadb.py @@ -0,0 +1,101 @@ +# 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.mariadb against a real MariaDB instance, spun up on +demand via testcontainers. Run via .github/workflows/testcontainers.yml. + +MariaDB is a MySQL fork implementing the same wire protocol: connects via +the plain "mysql" dialect with mysqlclient, same as vanilla MySQL, just +pointed at the mariadb image instead of mysql:latest. + +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.mariadb import MariaDBEngineSpec +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("mariadb:11") 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: + """ + MariaDBEngineSpec.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 = MariaDBEngineSpec.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 = MariaDBEngineSpec.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_monetdb.py b/tests/testcontainers/db_engine_specs/test_monetdb.py new file mode 100644 index 00000000000..79ddb296883 --- /dev/null +++ b/tests/testcontainers/db_engine_specs/test_monetdb.py @@ -0,0 +1,111 @@ +# 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.monetdb against a real MonetDB instance, spun up on +demand via testcontainers. Run via .github/workflows/testcontainers.yml. + +monetdb/monetdb publishes an amd64-only image, so this needs Rosetta/QEMU +emulation on Apple Silicon -- unlike CrateDB's x86-64-v3 CPU requirement, +this one actually runs fine under emulation (verified locally). No native +testcontainers module exists for MonetDB, so this uses a generic +DockerContainer with the documented MDB_* environment variables and waits +for the daemon's own startup log line. +""" + +import re +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.monetdb import MonetDbEngineSpec +from superset.sql.parse import Table + +pytestmark = pytest.mark.testcontainers + +from ._driver import require_driver # noqa: E402 + +require_driver("testcontainers.core.container") +require_driver("sqlalchemy_monetdb") + +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, +) + +PORT = 50000 +PASSWORD = "monetdb" # noqa: S105 -- fixed test-fixture password, not a secret +DBNAME = "test" + + +@pytest.fixture(scope="module") +def engine() -> Iterator[Engine]: + container = DockerContainer("monetdb/monetdb:latest") + container.with_exposed_ports(PORT) + container.with_env("MDB_DB_ADMIN_PASS", PASSWORD) + container.with_env("MDB_CREATE_DBS", DBNAME) + container.waiting_for(LogMessageWaitStrategy(re.compile("Starting MonetDB daemon"))) + + with container: + host = container.get_container_host_ip() + port = container.get_exposed_port(PORT) + yield create_engine(f"monetdb://monetdb:{PASSWORD}@{host}:{port}/{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: + """ + MonetDbEngineSpec.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 = MonetDbEngineSpec.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 = MonetDbEngineSpec.get_column_spec(str(col["type"])) + assert spec is not None diff --git a/tests/testcontainers/db_engine_specs/test_mongodb.py b/tests/testcontainers/db_engine_specs/test_mongodb.py new file mode 100644 index 00000000000..ee5c8776fab --- /dev/null +++ b/tests/testcontainers/db_engine_specs/test_mongodb.py @@ -0,0 +1,104 @@ +# 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.mongodb against a real MongoDB instance, spun up on +demand via testcontainers. Run via .github/workflows/testcontainers.yml. + +MongoDB is schemaless, and Superset talks to it via `pymongosql`, a +SQL-to-MongoDB translation layer (dialect requires a `?mode=superset` query +param -- not part of testcontainers' own MongoDbContainer.get_connection_url()). +Documents get inserted via the native pymongo driver, not SQL INSERT, +matching how Superset actually encounters MongoDB in practice and avoiding +any assumption about pymongosql's own INSERT/DDL support. +""" + +from collections.abc import Iterator + +import pytest +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.engine import Engine + +from superset.db_engine_specs.mongodb import MongoDBEngineSpec +from superset.sql.parse import Table + +pytestmark = pytest.mark.testcontainers + +from ._driver import require_driver # noqa: E402 + +require_driver("testcontainers.community.mongodb") +require_driver("pymongosql") + +from testcontainers.community.mongodb import MongoDbContainer # noqa: E402 + +COLLECTION = "pilot_pagination" + + +@pytest.fixture(scope="module") +def engine() -> Iterator[Engine]: + with MongoDbContainer("mongo:7.0.7") as container: + client = container.get_connection_client() + client[container.dbname][COLLECTION].insert_many([{"id": i} for i in range(10)]) + # MongoDbContainer.get_connection_url() has no database path segment + # or query string at all (it only builds user:pass@host:port), so + # naively appending "&mode=superset" glues it straight onto the port + # number instead of starting a query string. Build the full URL + # ourselves instead of relying on string concatenation. + host = container.get_container_host_ip() + port = container.get_exposed_port(container.port) + # authSource=admin is required: MongoDbContainer creates its root + # user via MONGO_INITDB_ROOT_USERNAME, which lives in the `admin` + # database, not in `dbname` -- without it, auth fails against + # whatever database is in the URL path. + yield create_engine( + f"mongodb://{container.username}:{container.password}@{host}:{port}" + f"/{container.dbname}?mode=superset&authSource=admin" + ) + + +def test_paginated_query_returns_correct_rows_in_order(engine: Engine) -> None: + """ + A plain 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. Unlike Elasticsearch's SQL + layer (which has no OFFSET support at all), pymongosql maps OFFSET to + MongoDB's native `skip`, so this dialect supports it. + """ + with engine.connect() as conn: + rows = conn.execute( + text( + f"SELECT id FROM {COLLECTION} ORDER BY id LIMIT 3 OFFSET 4" # noqa: S608 + ) + ).fetchall() + + assert [row.id for row in rows] == [4, 5, 6] + + +def test_get_columns_maps_native_types(engine: Engine) -> None: + """ + MongoDBEngineSpec.get_columns wraps a real SQLAlchemy Inspector, which + pymongosql implements by sampling real documents to infer column types + -- this exercises that against an actual running instance rather than + a mocked Inspector. + """ + inspector = inspect(engine) + columns = MongoDBEngineSpec.get_columns(inspector, Table(COLLECTION)) + + by_name = {col["column_name"]: col for col in columns} + assert "id" in by_name + spec = MongoDBEngineSpec.get_column_spec(str(by_name["id"]["type"])) + assert spec is not None diff --git a/tests/testcontainers/db_engine_specs/test_timescaledb.py b/tests/testcontainers/db_engine_specs/test_timescaledb.py new file mode 100644 index 00000000000..38e3f994ee6 --- /dev/null +++ b/tests/testcontainers/db_engine_specs/test_timescaledb.py @@ -0,0 +1,98 @@ +# 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.timescaledb against a real TimescaleDB 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. + +TimescaleDB is a genuine Postgres extension, not a fork: connects via the +plain "postgresql" dialect with psycopg2, same as vanilla Postgres, just +pointed at the timescale/timescaledb image instead of postgres:latest. +""" + +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.timescaledb import TimescaleDBEngineSpec +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("timescale/timescaledb:latest-pg16") 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: + """ + TimescaleDBEngineSpec.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 = TimescaleDBEngineSpec.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 = TimescaleDBEngineSpec.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_yugabytedb.py b/tests/testcontainers/db_engine_specs/test_yugabytedb.py new file mode 100644 index 00000000000..0995a388640 --- /dev/null +++ b/tests/testcontainers/db_engine_specs/test_yugabytedb.py @@ -0,0 +1,116 @@ +# 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.yugabytedb against a real YugabyteDB instance, spun +up on demand via testcontainers. Run via .github/workflows/testcontainers.yml. + +YugabyteDB's YSQL layer is fully Postgres-wire compatible (postgresql+ +psycopg2, port 5433), but the image doesn't ship a plain `psql` binary +(only its own `ysqlsh`), so testcontainers' PostgresContainer can't be +reused directly -- its built-in readiness check execs `psql`, which would +fail here. This uses a generic DockerContainer, starting the node via +`yugabyted start --background=false` and waiting for yugabyted's own final +startup message instead. +""" + +import re +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.yugabytedb import YugabyteDBEngineSpec +from superset.sql.parse import Table + +pytestmark = pytest.mark.testcontainers + +from ._driver import require_driver # noqa: E402 + +require_driver("testcontainers.core.container") + +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, +) + +YSQL_PORT = 5433 + + +@pytest.fixture(scope="module") +def engine() -> Iterator[Engine]: + container = DockerContainer("yugabytedb/yugabyte:latest") + container.with_exposed_ports(YSQL_PORT) + container.with_command("bin/yugabyted start --background=false") + container.waiting_for( + LogMessageWaitStrategy( + re.compile("Data placement constraint successfully verified") + ) + ) + + with container: + host = container.get_container_host_ip() + port = container.get_exposed_port(YSQL_PORT) + # Default single-node credentials/database, per yugabyted's own + # documented quickstart defaults -- no env vars needed to set them. + yield create_engine( + f"postgresql+psycopg2://yugabyte:yugabyte@{host}:{port}/yugabyte" + ) + + +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: + """ + YugabyteDBEngineSpec.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 = YugabyteDBEngineSpec.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 = YugabyteDBEngineSpec.get_column_spec(str(col["type"])) + assert spec is not None