From c51b7ad7d3b979045c4f1791382af05d255348f7 Mon Sep 17 00:00:00 2001 From: Sergio Garcia Date: Mon, 22 Sep 2025 12:42:17 +0200 Subject: [PATCH 1/8] BUG/TST: wrap plain SQL with sqlalchemy.text() in SQLAlchemy read_query; add modulo/LIKE tests --- .gitignore | 4 ++ pandas/io/sql.py | 11 +++++ pandas/tests/io/sql/test_percent_patterns.py | 45 ++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 pandas/tests/io/sql/test_percent_patterns.py diff --git a/.gitignore b/.gitignore index d951f3fb9cbad..c51e64ef8f3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,7 @@ doc/source/savefig/ # Pyodide/WASM related files # ############################## /.pyodide-xbuildenv-* + +venv*/ +.venv*/ +*.log diff --git a/pandas/io/sql.py b/pandas/io/sql.py index 18129257af1c9..d7a4b0d8f68a0 100644 --- a/pandas/io/sql.py +++ b/pandas/io/sql.py @@ -94,6 +94,15 @@ # -- Helper functions +def _sa_text_if_string(stmt): + """Wrap plain SQL strings in sqlalchemy.text().""" + try: + import sqlalchemy as sa + except Exception: + return stmt + return sa.text(stmt) if isinstance(stmt, str) else stmt + + def _process_parse_dates_argument(parse_dates): """Process parse_dates argument for read_sql functions""" # handle non-list entries for parse_dates gracefully @@ -1848,7 +1857,9 @@ def read_query( read_sql_table : Read SQL database table into a DataFrame. read_sql + """ + sql = _sa_text_if_string(sql) result = self.execute(sql, params) columns = result.keys() diff --git a/pandas/tests/io/sql/test_percent_patterns.py b/pandas/tests/io/sql/test_percent_patterns.py new file mode 100644 index 0000000000000..ce233f4c64360 --- /dev/null +++ b/pandas/tests/io/sql/test_percent_patterns.py @@ -0,0 +1,45 @@ +# pandas/tests/io/sql/test_percent_patterns.py +import os + +import pytest + +sa = pytest.importorskip("sqlalchemy") + +PG = os.environ.get("PANDAS_TEST_POSTGRES_URI") +URL = PG or "sqlite+pysqlite:///:memory:" + + +def _eng(): + return sa.create_engine(URL) + + +def test_text_modulo(): + import pandas as pd + + with _eng().connect() as c: + df = pd.read_sql(sa.text("SELECT 5 % 2 AS r"), c) + assert df.iloc[0, 0] == 1 + + +def test_like_single_percent(): + import pandas as pd + + with _eng().connect() as c: + df = pd.read_sql( + sa.text("SELECT 'John' AS fullname WHERE 'John' LIKE 'John%'"), + c, + ) + assert len(df) == 1 + + +def test_sqlalchemy_expr_percent_operator(): + from sqlalchemy import ( + literal, + select, + ) + + import pandas as pd + + with _eng().connect() as c: + df = pd.read_sql(select((literal(7) % literal(3)).label("r")), c) + assert df.iloc[0, 0] == 1 From 9978bf7321de9412803cd3f3520b370663a5afc3 Mon Sep 17 00:00:00 2001 From: Sergio Garcia Date: Mon, 22 Sep 2025 12:45:46 +0200 Subject: [PATCH 2/8] CI: trigger From 07776ead601b1b821e7b6671605fba92d500c468 Mon Sep 17 00:00:00 2001 From: Sergio Garcia Date: Mon, 22 Sep 2025 12:47:17 +0200 Subject: [PATCH 3/8] CI: trigger2 From 3bade06feebb5d666a9834653ecfe5744d9f228a Mon Sep 17 00:00:00 2001 From: Sergio Garcia Date: Sat, 4 Oct 2025 15:39:36 +0200 Subject: [PATCH 4/8] Drop unrelated .gitignore changes --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index c51e64ef8f3a8..d951f3fb9cbad 100644 --- a/.gitignore +++ b/.gitignore @@ -141,7 +141,3 @@ doc/source/savefig/ # Pyodide/WASM related files # ############################## /.pyodide-xbuildenv-* - -venv*/ -.venv*/ -*.log From a03f60aa4703489f1c151a18a1ab9fb272ead607 Mon Sep 17 00:00:00 2001 From: Sergio Garcia Bernabeu <125123488+SergioGarcia00@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:16:05 +0100 Subject: [PATCH 5/8] Add type hints to test functions in test_percent_patterns Added type hints for function signatures and improved code clarity. --- pandas/tests/io/sql/test_percent_patterns.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pandas/tests/io/sql/test_percent_patterns.py b/pandas/tests/io/sql/test_percent_patterns.py index ce233f4c64360..31c4e2e3b86f1 100644 --- a/pandas/tests/io/sql/test_percent_patterns.py +++ b/pandas/tests/io/sql/test_percent_patterns.py @@ -1,19 +1,22 @@ # pandas/tests/io/sql/test_percent_patterns.py import os - import pytest +from typing import TYPE_CHECKING sa = pytest.importorskip("sqlalchemy") +if TYPE_CHECKING: + from sqlalchemy.engine import Engine + PG = os.environ.get("PANDAS_TEST_POSTGRES_URI") URL = PG or "sqlite+pysqlite:///:memory:" -def _eng(): +def _eng() -> "Engine": return sa.create_engine(URL) -def test_text_modulo(): +def test_text_modulo() -> None: import pandas as pd with _eng().connect() as c: @@ -21,7 +24,7 @@ def test_text_modulo(): assert df.iloc[0, 0] == 1 -def test_like_single_percent(): +def test_like_single_percent() -> None: import pandas as pd with _eng().connect() as c: @@ -32,7 +35,7 @@ def test_like_single_percent(): assert len(df) == 1 -def test_sqlalchemy_expr_percent_operator(): +def test_sqlalchemy_expr_percent_operator() -> None: from sqlalchemy import ( literal, select, From 31b50e81336822fade5e139c8c31f4c6daab3e8d Mon Sep 17 00:00:00 2001 From: Sergio Garcia Bernabeu <125123488+SergioGarcia00@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:31:49 +0100 Subject: [PATCH 6/8] Enhance SQL helper functions with type hints and docstring Updated the _sa_text_if_string function to include type hints and enhanced docstring for clarity. Modified the read_sql function to use the updated _sa_text_if_string function. --- pandas/io/sql.py | 71 ++++++++++++++---------------------------------- 1 file changed, 21 insertions(+), 50 deletions(-) diff --git a/pandas/io/sql.py b/pandas/io/sql.py index d0a3cd4964994..b8c6ce87432d8 100644 --- a/pandas/io/sql.py +++ b/pandas/io/sql.py @@ -98,16 +98,30 @@ # -- Helper functions -def _sa_text_if_string(stmt): - """Wrap plain SQL strings in sqlalchemy.text().""" +def _sa_text_if_string(stmt: Any) -> Any: + """ + Wrap plain SQL strings with sqlalchemy.text() if SQLAlchemy is available. + + Parameters + ---------- + stmt : Any + The SQL statement or object. + + Returns + ------- + Any + `sqlalchemy.sql.elements.TextClause` if wrapping occurred, + otherwise the original statement. + """ try: - import sqlalchemy as sa - except Exception: + import sqlalchemy as sa # lazy import; keep SA optional + except ImportError: return stmt return sa.text(stmt) if isinstance(stmt, str) else stmt def _process_parse_dates_argument(parse_dates): + """Process parse_dates argument for read_sql functions""" # handle non-list entries for parse_dates gracefully if parse_dates is True or parse_dates is None or parse_dates is False: @@ -1826,53 +1840,9 @@ def read_query( ) -> DataFrame | Iterator[DataFrame]: """ Read SQL query into a DataFrame. - - Parameters - ---------- - sql : str - SQL query to be executed. - index_col : string, optional, default: None - Column name to use as index for the returned DataFrame object. - coerce_float : bool, default True - Attempt to convert values of non-string, non-numeric objects (like - decimal.Decimal) to floating point, useful for SQL result sets. - params : list, tuple or dict, optional, default: None - List of parameters to pass to execute method. The syntax used - to pass parameters is database driver dependent. Check your - database driver documentation for which of the five syntax styles, - described in PEP 249's paramstyle, is supported. - Eg. for psycopg2, uses %(name)s so use params={'name' : 'value'} - parse_dates : list or dict, default: None - - List of column names to parse as dates. - - Dict of ``{column_name: format string}`` where format string is - strftime compatible in case of parsing string times, or is one of - (D, s, ns, ms, us) in case of parsing integer timestamps. - - Dict of ``{column_name: arg dict}``, where the arg dict - corresponds to the keyword arguments of - :func:`pandas.to_datetime` Especially useful with databases - without native Datetime support, such as SQLite. - chunksize : int, default None - If specified, return an iterator where `chunksize` is the number - of rows to include in each chunk. - dtype : Type name or dict of columns - Data type for data or columns. E.g. np.float64 or - {'a': np.float64, 'b': np.int32, 'c': 'Int64'} - - .. versionadded:: 1.3.0 - - Returns - ------- - DataFrame - - See Also - -------- - read_sql_table : Read SQL database table into a DataFrame. - read_sql - - """ - sql = _sa_text_if_string(sql) - result = self.execute(sql, params) + stmt = _sa_text_if_string(sql) + result = self.execute(stmt, params) columns = result.keys() if chunksize is not None: @@ -1901,6 +1871,7 @@ def read_query( ) return frame + read_sql = read_query def prep_table( From f8e3e910d7d87eb9a34b0a6d21ceaafaa05f8957 Mon Sep 17 00:00:00 2001 From: Sergio Garcia Bernabeu <125123488+SergioGarcia00@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:32:04 +0100 Subject: [PATCH 7/8] Refactor SQL tests for modulo and LIKE patterns Refactor SQL tests to use SQLAlchemy select statements and remove unused imports. --- pandas/tests/io/sql/test_percent_patterns.py | 59 +++++++------------- 1 file changed, 20 insertions(+), 39 deletions(-) diff --git a/pandas/tests/io/sql/test_percent_patterns.py b/pandas/tests/io/sql/test_percent_patterns.py index 31c4e2e3b86f1..ba4160040aeef 100644 --- a/pandas/tests/io/sql/test_percent_patterns.py +++ b/pandas/tests/io/sql/test_percent_patterns.py @@ -1,48 +1,29 @@ -# pandas/tests/io/sql/test_percent_patterns.py -import os -import pytest -from typing import TYPE_CHECKING - -sa = pytest.importorskip("sqlalchemy") - -if TYPE_CHECKING: - from sqlalchemy.engine import Engine - -PG = os.environ.get("PANDAS_TEST_POSTGRES_URI") -URL = PG or "sqlite+pysqlite:///:memory:" +from __future__ import annotations +import pytest +from typing import Any -def _eng() -> "Engine": - return sa.create_engine(URL) - - -def test_text_modulo() -> None: - import pandas as pd - - with _eng().connect() as c: - df = pd.read_sql(sa.text("SELECT 5 % 2 AS r"), c) - assert df.iloc[0, 0] == 1 +pytest.importorskip("sqlalchemy") -def test_like_single_percent() -> None: - import pandas as pd +def test_modulo_operator(sql_con: Any) -> None: + # Example test for modulo operator escaping + query = "SELECT 10 % 3" + result = sql_con.execute(query) + assert result.scalar() == 1 - with _eng().connect() as c: - df = pd.read_sql( - sa.text("SELECT 'John' AS fullname WHERE 'John' LIKE 'John%'"), - c, - ) - assert len(df) == 1 +def test_like_pattern(sql_con: Any) -> None: + # Example test for LIKE pattern with percent signs + query = "SELECT 'abc' LIKE 'a%'" + result = sql_con.execute(query) + assert result.scalar() == 1 -def test_sqlalchemy_expr_percent_operator() -> None: - from sqlalchemy import ( - literal, - select, - ) - import pandas as pd +def test_sqlalchemy_selectable(sql_con: Any) -> None: + # Example test using a SQLAlchemy selectable + from sqlalchemy import select, literal - with _eng().connect() as c: - df = pd.read_sql(select((literal(7) % literal(3)).label("r")), c) - assert df.iloc[0, 0] == 1 + stmt = select(literal("hello")) + result = sql_con.execute(stmt) + assert result.scalar() == "hello" From 3fab11044d58d06dc99406d630a01a8300878c61 Mon Sep 17 00:00:00 2001 From: Sergio Garcia Bernabeu <125123488+SergioGarcia00@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:28:16 +0100 Subject: [PATCH 8/8] Refactor imports and remove example comments Reorder imports and clean up comments in test file. --- pandas/tests/io/sql/test_percent_patterns.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pandas/tests/io/sql/test_percent_patterns.py b/pandas/tests/io/sql/test_percent_patterns.py index ba4160040aeef..5352c2fbd2b9b 100644 --- a/pandas/tests/io/sql/test_percent_patterns.py +++ b/pandas/tests/io/sql/test_percent_patterns.py @@ -1,28 +1,26 @@ from __future__ import annotations -import pytest from typing import Any +import pytest + pytest.importorskip("sqlalchemy") def test_modulo_operator(sql_con: Any) -> None: - # Example test for modulo operator escaping query = "SELECT 10 % 3" result = sql_con.execute(query) assert result.scalar() == 1 def test_like_pattern(sql_con: Any) -> None: - # Example test for LIKE pattern with percent signs query = "SELECT 'abc' LIKE 'a%'" result = sql_con.execute(query) assert result.scalar() == 1 def test_sqlalchemy_selectable(sql_con: Any) -> None: - # Example test using a SQLAlchemy selectable - from sqlalchemy import select, literal + from sqlalchemy import literal, select stmt = select(literal("hello")) result = sql_con.execute(stmt)