Repository: ploomber/jupysql
Pull request #777 (2023-08-02 04:38:49)

add first redshift integration tests
## Describe your changes

I added some redshift integration tests. more to come

## Issue number

Closes #X

## Checklist before requesting a review

- [ ] Performed a self-review of my code
- [ ] Formatted my code with [`pkgmt format`](https://ploomber-contributing.readthedocs.io/en/latest/contributing/pr.html#linting-formatting)
- [ ] Added [tests](https://ploomber-contributing.readthedocs.io/en/latest/contributing/pr.html#testing) (when necessary).
- [ ] Added [docstring](https://ploomber-contributing.readthedocs.io/en/latest/contributing/pr.html#documenting-changes-and-new-features) documentation and update the [changelog](https://ploomber-contributing.readthedocs.io/en/latest/contributing/pr.html#changelog) (when needed)



<!-- readthedocs-preview jupysql start -->
----
:books: Documentation preview :books:: https://jupysql--777.org.readthedocs.build/en/777/

<!-- readthedocs-preview jupysql end -->


Patch:
diff --git a/.github/workflows/ci-integration-db-live.yaml b/.github/workflows/ci-integration-db-live.yaml
index dfc3410..f40e649 100644
--- a/.github/workflows/ci-integration-db-live.yaml
+++ b/.github/workflows/ci-integration-db-live.yaml
@@ -47,7 +47,7 @@ jobs:
         run: |
           python -m pip install --upgrade pip
           python -m pip install --upgrade nox
-          nox --session test_integration_snowflake --install-only
+          nox --session test_integration_cloud --install-only
 
       - name: Integration Test
         if: ${{ steps.secret-check.outputs.available == 'true'}}
@@ -55,9 +55,13 @@ jobs:
           SF_USERNAME: ${{ secrets.SF_USERNAME }}
           SF_PASSWORD: ${{ secrets.SF_PASSWORD }}
           SF_DATABASE: ${{ secrets.SF_DATABASE }}
+          REDSHIFT_HOST: ${{ secrets.REDSHIFT_HOST }}
+          REDSHIFT_USERNAME: ${{ secrets.REDSHIFT_USERNAME }}
+          REDSHIFT_PASSWORD: ${{ secrets.REDSHIFT_PASSWORD }}
+
         run: |
 
-          nox --session test_integration_snowflake --no-install --reuse-existing-virtualenvs
+          nox --session test_integration_cloud --no-install --reuse-existing-virtualenvs
 
       - name: Upload failed images artifacts
         uses: actions/upload-artifact@v3
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd2dee3..872bbdd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## 0.9.1dev
 * [Fix] Fix boxplot for duckdb native ([#728](https://github.com/ploomber/jupysql/issues/728))
+* [Feature] Adds Redshift support for `%sqlplot boxplot`
 
 ## 0.9.0 (2023-08-01)
 
diff --git a/noxfile.py b/noxfile.py
index b82e041..0263606 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -120,17 +120,18 @@ def test_unit_sqlalchemy_one(session):
     venv_backend=VENV_BACKEND,
     python=environ.get("PYTHON_VERSION", "3.11"),
 )
-def test_integration_snowflake(session):
+def test_integration_cloud(session):
     """
-    Run snowflake tests (NOTE: the sqlalchemy-snowflake driver only works with
+    Run integration tests on cloud databases (currently snowflake and redshift)
+    (NOTE: the sqlalchemy-snowflake and sqlalchemy-redshift driver only work with
     SQLAlchemy 1.x)
     """
 
     # TODO: do not require integrationt test dependencies if only running snowflake
     # tests
     _install(session, integration=True)
-    session.install("snowflake-sqlalchemy")
-    session.run("pytest", "src/tests/integration", "-k", "snowflake", "-v")
+    session.install("snowflake-sqlalchemy", "redshift-connector", "sqlalchemy-redshift")
+    session.run("pytest", "src/tests/integration", "-k", "snowflake or redshift", "-v")
 
 
 @nox.session(
@@ -144,6 +145,6 @@ def test_integration(session):
         "pytest",
         "src/tests/integration",
         "-k",
-        "not snowflake",
+        "not (snowflake or redshift)",
         "-v",
     )
diff --git a/setup.py b/setup.py
index 6b9fa9d..3316a7c 100644
--- a/setup.py
+++ b/setup.py
@@ -67,6 +67,9 @@ INTEGRATION = [
     "oracledb",
     "sqlalchemy-pytds",
     "python-tds",
+    # redshift
+    "redshift-connector",
+    "sqlalchemy-redshift",
 ]
 
 setup(
diff --git a/src/sql/plot.py b/src/sql/plot.py
index 7d7a184..b600905 100644
--- a/src/sql/plot.py
+++ b/src/sql/plot.py
@@ -5,9 +5,9 @@ from ploomber_core.dependencies import requires
 from ploomber_core.exceptions import modify_exceptions
 from jinja2 import Template
 
-from sql.util import flatten
-from sqlalchemy.exc import ProgrammingError
+
 from sql import exceptions, display
+from sql.stats import _summary_stats
 
 try:
     import matplotlib.pyplot as plt
@@ -26,40 +26,6 @@ from sql.telemetry import telemetry
 import warnings
 
 
-def _summary_stats(conn, table, column, with_=None):
-    """Compute percentiles and mean for boxplot"""
-
-    if not conn:
-        conn = sql.connection.ConnectionManager.current
-    driver = conn._get_database_information()["driver"]
-
-    template = Template(
-        """
-    SELECT
-    percentile_disc([0.25, 0.50, 0.75]) WITHIN GROUP \
-    (ORDER BY "{{column}}") AS percentiles,
-    AVG("{{column}}") AS mean,
-    COUNT(*) AS N
-    FROM "{{table}}"
-"""
-    )
-
-    query = template.render(table=table, column=column)
-
-    try:
-        values = conn.execute(query, with_).fetchone()
-    except ProgrammingError as e:
-        print(e)
-        raise exceptions.RuntimeError(
-            f"\nEnsure that percentile_disc function is available on {driver}."
-        )
-    except Exception as e:
-        raise e
-
-    keys = ["q1", "med", "q3", "mean", "N"]
-    return {k: float(v) for k, v in zip(keys, flatten(values))}
-
-
 def _whishi(conn, table, column, hival, with_=None):
     if not conn:
         conn = sql.connection.ConnectionManager.current
diff --git a/src/sql/stats.py b/src/sql/stats.py
new file mode 100644
index 0000000..8f53627
--- /dev/null
+++ b/src/sql/stats.py
@@ -0,0 +1,118 @@
+from jinja2 import Template
+from sqlalchemy.exc import ProgrammingError
+
+import sql.connection
+from sql.util import flatten
+from sql import exceptions
+
+
+def _summary_stats(conn, table, column, with_=None):
+    if conn.dialect in {"duckdb", "postgresql"}:
+        return _summary_stats_parallel(conn, table, column, with_=with_)
+    elif conn.dialect in {"redshift"}:
+        return _summary_stats_redshift(conn, table, column, with_=with_)
+    else:
+        return _summary_stats_one_by_one(conn, table, column, with_=with_)
+
+
+def _summary_stats_one_by_one(conn, table, column, with_=None):
+    if not conn:
+        conn = sql.connection.ConnectionManager.current.connection
+
+    template_percentile = Template(
+        """
+SELECT
+percentile_disc(0.25) WITHIN GROUP (ORDER BY "{{column}}") OVER (),
+percentile_disc(0.50) WITHIN GROUP (ORDER BY "{{column}}") OVER (),
+percentile_disc(0.75) WITHIN GROUP (ORDER BY "{{column}}") OVER ()
+FROM "{{table}}"
+"""
+    )
+    query = template_percentile.render(table=table, column=column)
+
+    percentiles = list(conn.execute(query, with_).fetchone())
+
+    template = Template(
+        """
+SELECT
+AVG("{{column}}") AS mean,
+COUNT(*) AS N
+FROM "{{table}}"
+"""
+    )
+    query = template.render(table=table, column=column)
+
+    other = list(conn.execute(query, with_).fetchone())
+
+    keys = ["q1", "med", "q3", "mean", "N"]
+
+    return {k: float(v) for k, v in zip(keys, percentiles + other)}
+
+
+def _summary_stats_redshift(conn, table, column, with_=None):
+    if not conn:
+        conn = sql.connection.ConnectionManager.current.connection
+
+    template_percentile = Template(
+        """
+SELECT
+approximate percentile_disc(0.25) WITHIN GROUP (ORDER BY "{{column}}"),
+approximate percentile_disc(0.50) WITHIN GROUP (ORDER BY "{{column}}"),
+approximate percentile_disc(0.75) WITHIN GROUP (ORDER BY "{{column}}")
+FROM "{{table}}"
+"""
+    )
+    query = template_percentile.render(table=table, column=column)
+
+    percentiles = list(conn.execute(query, with_).fetchone())
+
+    template = Template(
+        """
+SELECT
+AVG("{{column}}") AS mean,
+COUNT(*) AS N
+FROM "{{table}}"
+"""
+    )
+    query = template.render(table=table, column=column)
+
+    other = list(conn.execute(query, with_).fetchone())
+
+    keys = ["q1", "med", "q3", "mean", "N"]
+
+    return {k: float(v) for k, v in zip(keys, percentiles + other)}
+
+
+def _summary_stats_parallel(conn, table, column, with_=None):
+    """Compute percentiles and mean for boxplot"""
+
+    if not conn:
+        conn = sql.connection.ConnectionManager.current
+
+    driver = conn._get_database_information()["driver"]
+
+    template = Template(
+        """
+    SELECT
+    percentile_disc([0.25, 0.50, 0.75]) WITHIN GROUP \
+    (ORDER BY "{{column}}") AS percentiles,
+    AVG("{{column}}") AS mean,
+    COUNT(*) AS N
+    FROM "{{table}}"
+"""
+    )
+
+    query = template.render(table=table, column=column)
+
+    try:
+        values = conn.execute(query, with_).fetchone()
+    except ProgrammingError as e:
+        print(e)
+        raise exceptions.RuntimeError(
+            f"\nEnsure that percentile_disc function is available on {driver}."
+        )
+    except Exception as e:
+        raise e
+
+    keys = ["q1", "med", "q3", "mean", "N"]
+    return {k: float(v) for k, v in zip(keys, flatten(values))}


Test patch:
diff --git a/src/sql/_testing.py b/src/sql/_testing.py
index 2d1e3b6..6bdd623 100644
--- a/src/sql/_testing.py
+++ b/src/sql/_testing.py
@@ -178,6 +178,18 @@ databaseConfig = {
             "service_name": "XEPDB1",
         },
     },
+    "redshift": {
+        "drivername": "redshift+redshift_connector",
+        "username": os.getenv("REDSHIFT_USERNAME"),
+        "password": os.getenv("REDSHIFT_PASSWORD"),
+        # database/schema
+        "database": "dev",
+        "host": os.getenv("REDSHIFT_HOST"),
+        "port": 5439,
+        "alias": "redshift",
+        "docker_ct": None,
+        "query": {},
+    },
 }
 
 
diff --git a/src/tests/integration/conftest.py b/src/tests/integration/conftest.py
index 32b06de..33982c7 100644
--- a/src/tests/integration/conftest.py
+++ b/src/tests/integration/conftest.py
@@ -11,6 +11,14 @@ from sql import _testing
 from sql import connection
 
 
+def _requires_env_variables(database, variables):
+    for variable in variables:
+        if os.getenv(variable) is None:
+            raise ValueError(
+                f"{variable} is required to run {database} integration tests"
+            )
+
+
 @pytest.fixture(scope="function", autouse=True)
 def isolate_connections(monkeypatch):
     """
@@ -367,14 +375,7 @@ def ip_with_MSSQL(ip_empty, setup_MSSQL):
 
 @pytest.fixture(scope="session")
 def setup_Snowflake(test_table_name_dict):
-    username = os.getenv("SF_USERNAME")
-    password = os.getenv("SF_PASSWORD")
-
-    if username is None:
-        raise ValueError("SF_USERNAME is required to run snowflake integration tests")
-
-    if password is None:
-        raise ValueError("SF_PASSWORD is required to run snowflake integration tests")
+    _requires_env_variables("snowflake", ["SF_USERNAME", "SF_PASSWORD"])
 
     engine = create_engine(_testing.DatabaseConfigHelper.get_database_url("Snowflake"))
     engine.connect()
@@ -386,7 +387,7 @@ def setup_Snowflake(test_table_name_dict):
 
 
 @pytest.fixture
-def ip_with_Snowflake(ip_empty, setup_Snowflake, pytestconfig):
+def ip_with_Snowflake(ip_empty, setup_Snowflake):
     configKey = "Snowflake"
     config = _testing.DatabaseConfigHelper.get_database_config(configKey)
     # Select database engine
@@ -401,6 +402,37 @@ def ip_with_Snowflake(ip_empty, setup_Snowflake, pytestconfig):
     ip_empty.run_cell("%sql -x " + config["alias"])
 
 
+@pytest.fixture(scope="session")
+def setup_redshift(test_table_name_dict):
+    _requires_env_variables(
+        "redshift", ["REDSHIFT_USERNAME", "REDSHIFT_PASSWORD", "REDSHIFT_HOST"]
+    )
+
+    engine = create_engine(_testing.DatabaseConfigHelper.get_database_url("redshift"))
+    engine.connect()
+    # Load pre-defined datasets
+    load_generic_testing_data(engine, test_table_name_dict, index=False)
+    yield engine
+    tear_down_generic_testing_data(engine, test_table_name_dict)
+    engine.dispose()
+
+
+@pytest.fixture
+def ip_with_redshift(ip_empty, setup_redshift):
+    configKey = "redshift"
+    config = _testing.DatabaseConfigHelper.get_database_config(configKey)
+    # Select database engine
+    ip_empty.run_cell(
+        "%sql "
+        + _testing.DatabaseConfigHelper.get_database_url(configKey)
+        + " --alias "
+        + config["alias"]
+    )
+    yield ip_empty
+    # Disconnect database
+    ip_empty.run_cell("%sql -x " + config["alias"])
+
+
 @pytest.fixture(scope="session")
 def setup_oracle(test_table_name_dict):
     with _testing.oracle():
@@ -414,7 +446,7 @@ def setup_oracle(test_table_name_dict):
 
 
 @pytest.fixture
-def ip_with_oracle(ip_empty, setup_oracle, pytestconfig):
+def ip_with_oracle(ip_empty, setup_oracle):
     configKey = "oracle"
     config = _testing.DatabaseConfigHelper.get_database_config(configKey)
     # Select database engine
diff --git a/src/tests/integration/test_generic_db_operations.py b/src/tests/integration/test_generic_db_operations.py
index 01b01e3..49d982e 100644
--- a/src/tests/integration/test_generic_db_operations.py
+++ b/src/tests/integration/test_generic_db_operations.py
@@ -40,35 +40,38 @@ def mock_log_api(monkeypatch):
 
 
 @pytest.mark.parametrize(
-    "ip_with_dynamic_db, expected",
+    "ip_with_dynamic_db",
     [
-        ("ip_with_postgreSQL", 3),
-        ("ip_with_mySQL", 3),
-        ("ip_with_mariaDB", 3),
-        ("ip_with_SQLite", 3),
-        ("ip_with_duckDB_native", 3),
-        ("ip_with_duckDB", 3),
-        ("ip_with_Snowflake", 3),
+        "ip_with_postgreSQL",
+        "ip_with_mySQL",
+        "ip_with_mariaDB",
+        "ip_with_SQLite",
+        "ip_with_duckDB_native",
+        "ip_with_duckDB",
+        "ip_with_Snowflake",
+        "ip_with_redshift",
     ],
 )
-def test_query_count(ip_with_dynamic_db, expected, request, test_table_name_dict):
+def test_run_query(ip_with_dynamic_db, request, test_table_name_dict):
     ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db)
 
+    # run a query
     out = ip_with_dynamic_db.run_cell(
         f"%sql SELECT * FROM {test_table_name_dict['taxi']} LIMIT 3"
     )
 
-    # Test query with --with & --save
+    # test --save
     ip_with_dynamic_db.run_cell(
         f"%sql --save taxi_subset --no-execute SELECT * FROM\
           {test_table_name_dict['taxi']} LIMIT 3"
     )
+
     out_query_with_save_arg = ip_with_dynamic_db.run_cell(
         "%sql --with taxi_subset SELECT * FROM taxi_subset"
     )
 
-    assert len(out.result) == expected
-    assert len(out_query_with_save_arg.result) == expected
+    assert len(out.result) == 3
+    assert len(out_query_with_save_arg.result) == 3
 
 
 @pytest.mark.parametrize(
@@ -81,6 +84,7 @@ def test_query_count(ip_with_dynamic_db, expected, request, test_table_name_dict
         "ip_with_duckDB",
         "ip_with_duckDB_native",
         "ip_with_Snowflake",
+        "ip_with_redshift",
     ],
 )
 def test_handle_multiple_open_result_sets(
@@ -113,27 +117,32 @@ def test_handle_multiple_open_result_sets(
 
 
 @pytest.mark.parametrize(
-    "ip_with_dynamic_db, expected, limit",
+    "ip_with_dynamic_db, args",
     [
-        ("ip_with_postgreSQL", 15, 15),
-        ("ip_with_mySQL", 15, 15),
-        ("ip_with_mariaDB", 15, 15),
-        ("ip_with_SQLite", 15, 15),
-        ("ip_with_duckDB", 15, 15),
+        ("ip_with_postgreSQL", ""),
+        ("ip_with_mySQL", ""),
+        ("ip_with_mariaDB", ""),
+        ("ip_with_SQLite", ""),
+        ("ip_with_duckDB", ""),
         pytest.param(
             "ip_with_duckDB_native",
-            15,
-            15,
+            "",
             marks=pytest.mark.xfail(
                 reason="'duckdb.DuckDBPyConnection' object has no attribute 'rowcount'"
             ),
         ),
-        # Snowflake doesn't support index, skip that
+        # snowflake and redshift do not support "CREATE INDEX", so we need to
+        # pass --no-index
+        ("ip_with_Snowflake", "--no-index"),
+        ("ip_with_redshift", "--no-index"),
     ],
 )
 def test_create_table_with_indexed_df(
-    ip_with_dynamic_db, expected, limit, request, test_table_name_dict
+    ip_with_dynamic_db, args, request, test_table_name_dict
 ):
+    limit = 15
+    expected = 15
+
     ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db)
     # Clean up
 
@@ -158,7 +167,7 @@ def test_create_table_with_indexed_df(
     )
     # Create table from DF
     persist_out = ip_with_dynamic_db.run_cell(
-        f"%sql --persist {test_table_name_dict['new_table_from_df']}"
+        f"%sql --persist {test_table_name_dict['new_table_from_df']} {args}"
     )
     out_df = ip_with_dynamic_db.run_cell(
         f"%sql SELECT * FROM {test_table_name_dict['new_table_from_df']}"
@@ -295,6 +304,7 @@ def test_telemetry_execute_command_has_connection_info(
         ("ip_with_SQLite"),
         ("ip_with_duckDB"),
         ("ip_with_duckDB_native"),
+        ("ip_with_redshift"),
         pytest.param(
             "ip_with_MSSQL",
             marks=pytest.mark.xfail(reason="sqlglot does not support SQL server"),
@@ -348,8 +358,9 @@ BOX_PLOT_FAIL_REASON = (
 @pytest.mark.parametrize(
     "ip_with_dynamic_db",
     [
-        pytest.param("ip_with_postgreSQL"),
-        pytest.param("ip_with_duckDB"),
+        "ip_with_postgreSQL",
+        "ip_with_duckDB",
+        "ip_with_redshift",
         pytest.param(
             "ip_with_duckDB_native",
             marks=pytest.mark.xfail(reason="Custom driver not supported"),
@@ -393,6 +404,7 @@ def test_sqlplot_boxplot(ip_with_dynamic_db, cell, request, test_table_name_dict
         ("ip_with_mariaDB"),
         ("ip_with_SQLite"),
         ("ip_with_duckDB"),
+        ("ip_with_redshift"),
         pytest.param(
             "ip_with_duckDB_native",
             marks=pytest.mark.xfail(reason="not supported yet for native connections"),
@@ -405,7 +417,7 @@ def test_sqlplot_boxplot(ip_with_dynamic_db, cell, request, test_table_name_dict
         ("ip_with_oracle"),
     ],
 )
-def test_sql_cmd_magic_uno(ip_with_dynamic_db, request, test_table_name_dict):
+def test_sqlcmd_test(ip_with_dynamic_db, request, test_table_name_dict):
     ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db)
     table = test_table_name_dict["numbers"]
 
@@ -420,40 +432,6 @@ def test_sql_cmd_magic_uno(ip_with_dynamic_db, request, test_table_name_dict):
     assert "The above values do not match your test requirements." in str(excinfo.value)
 
 
-@pytest.mark.parametrize(
-    "ip_with_dynamic_db",
-    [
-        ("ip_with_postgreSQL"),
-        ("ip_with_mySQL"),
-        ("ip_with_mariaDB"),
-        ("ip_with_SQLite"),
-        ("ip_with_duckDB"),
-        ("ip_with_duckDB_native"),
-        ("ip_with_MSSQL"),
-        pytest.param(
-            "ip_with_Snowflake",
-            marks=pytest.mark.xfail(
-                reason="Something wrong with test_sql_cmd_magic_dos in snowflake"
-            ),
-        ),
-        ("ip_with_oracle"),
-    ],
-)
-def test_sql_cmd_magic_dos(ip_with_dynamic_db, request, capsys):
-    ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db)
-
-    ip_with_dynamic_db.run_cell(
-        """
-    %%sql sqlite://
-    CREATE TABLE test_numbers (value);
-    INSERT INTO test_numbers VALUES (0);
-    INSERT INTO test_numbers VALUES (4);
-    INSERT INTO test_numbers VALUES (5);
-    INSERT INTO test_numbers VALUES (6);
-    """
-    )
-
-
 @pytest.mark.parametrize(
     "ip_with_dynamic_db",
     [
@@ -640,7 +618,7 @@ def test_profile_data_mismatch(ip_with_dynamic_db, request, capsys):
         ),
     ],
 )
-def test_profile_query(
+def test_sqlcmd_profile(
     request,
     ip_with_dynamic_db,
     table,
@@ -691,6 +669,7 @@ def test_profile_query(
         ("ip_with_mariaDB"),
         ("ip_with_SQLite"),
         ("ip_with_duckDB"),
+        ("ip_with_redshift"),
         pytest.param(
             "ip_with_duckDB_native",
             marks=pytest.mark.xfail(reason="Bug #428"),
@@ -699,9 +678,7 @@ def test_profile_query(
         ("ip_with_Snowflake"),
     ],
 )
-def test_sqlcmd_tables_columns(
-    ip_with_dynamic_db, table, request, test_table_name_dict
-):
+def test_sqlcmd_columns(ip_with_dynamic_db, table, request, test_table_name_dict):
     ip_with_dynamic_db = request.getfixturevalue(ip_with_dynamic_db)
     out = ip_with_dynamic_db.run_cell(
         f"%sqlcmd columns --table {test_table_name_dict[table]}"
@@ -717,6 +694,7 @@ def test_sqlcmd_tables_columns(
         ("ip_with_mariaDB"),
         ("ip_with_SQLite"),
         ("ip_with_duckDB"),
+        ("ip_with_redshift"),
         pytest.param(
             "ip_with_duckDB_native",
             marks=pytest.mark.xfail(reason="Bug #428"),
diff --git a/src/tests/integration/test_mssql.py b/src/tests/integration/test_mssql.py
index 168560b..ee5a673 100644
--- a/src/tests/integration/test_mssql.py
+++ b/src/tests/integration/test_mssql.py
@@ -113,19 +113,3 @@ def test_sqlplot_boxplot(ip_with_MSSQL, cell):
     out = ip_with_MSSQL.run_cell(cell)
 
     assert type(out.result).__name__ in {"Axes", "AxesSubplot"}
-
-
-def test_unsupported_function(ip_with_MSSQL, test_table_name_dict):
-    # clean current Axes
-    plt.cla()
-
-    with pytest.raises(UsageError) as excinfo:
-        ip_with_MSSQL.run_cell(
-            f"%sqlplot boxplot --table " f"{test_table_name_dict['taxi']} --column x"
-        )
-
-    assert "Ensure that percentile_disc function is available" in str(excinfo.value)
-    assert (
-        "If you need help solving this issue, "
-        "send us a message: https://ploomber.io/community" in str(excinfo.value)
-    )
diff --git a/src/tests/integration/test_stats.py b/src/tests/integration/test_stats.py
new file mode 100644
index 0000000..d8f9343
--- /dev/null
+++ b/src/tests/integration/test_stats.py
@@ -0,0 +1,28 @@
+import pytest
+
+from sql.stats import _summary_stats
+from sql.connection import SQLAlchemyConnection
+
+
+@pytest.mark.parametrize(
+    "fixture_name",
+    [
+        "setup_duckDB",
+        "setup_MSSQL",
+        "setup_postgreSQL",
+        "setup_redshift",
+    ],
+)
+def test_summary_stats(fixture_name, request, test_table_name_dict):
+    engine = request.getfixturevalue(fixture_name)
+    conn = SQLAlchemyConnection(engine)
+    table = test_table_name_dict["plot_something"]
+    column = "x"
+
+    assert _summary_stats(conn, table, column) == {
+        "q1": 1.0,
+        "med": 2.0,
+        "q3": 3.0,
+        "mean": 2.0,
+        "N": 5.0,
+    }