Skip to content

Commit 7d7bbf6

Browse files
committed
Add cast_map to scan_db for server-side column narrowing
scan_db gains an optional cast_map={column: dtype} that casts the named columns server-side with a SQL CAST, wrapping the query in an auto-enumerated projecting subquery so `select *` still flows every column. The narrowed dtype is reported in the schema, so a filter on a cast column (for example a datetime narrowed to date) is pushed down to the database instead of stalling above a client-side cast. Clauses that are illegal inside a derived table (a top-level ORDER BY without TOP/OFFSET, or OPTION hints in the T-SQL dialect) are hoisted onto the cast SELECT so the nested subquery stays valid. Unsupported target dtypes are rejected rather than silently emitting VARCHAR, and decimal precision and scale are preserved. Extract a shared polars_dtype_to_sqlglot_type helper used by both the predicate translator and the cast wrapping. Signed-off-by: Pascal Tomecek <40371786+ptomecek@users.noreply.github.com>
1 parent 85adfbb commit 7d7bbf6

5 files changed

Lines changed: 288 additions & 32 deletions

File tree

docs/wiki/API-Reference.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,15 @@ without materializing the whole frame.
156156
### `scan_db`
157157

158158
```python
159-
scan_db(query, connection, fetch_size=10000, **kwargs) -> pl.LazyFrame
159+
scan_db(query, connection, fetch_size=10000, cast_map=None, **kwargs) -> pl.LazyFrame
160160
```
161161

162162
Run a SQL query over an ODBC `connection` string with predicate and projection pushdown.
163163
The SQL dialect is detected from the connection; filters become `WHERE` clauses and
164-
selections narrow the `SELECT`.
164+
selections narrow the `SELECT`. Pass `cast_map={column: dtype}` to cast columns
165+
server-side (a SQL `CAST`, keeping `select *`) and report the narrowed dtype in the
166+
schema, so a filter on a cast column still pushes down — for example narrowing a
167+
`datetime` column that is logically a `date`.
165168

166169
### `scan_clickhouse`
167170

docs/wiki/Reading-and-Writing-Data.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ The dialect is detected from the ODBC connection, so the generated SQL matches y
4141
database. Pass `fetch_size=` to control the batch size used when Polars does not
4242
request one.
4343

44+
If a column is stored as a wider type than it is used as — for example a `datetime`
45+
column that is logically a `date` — cast it server-side with `cast_map` so filters on
46+
it still push down:
47+
48+
```python
49+
lf = scan_db(
50+
"SELECT * FROM trades",
51+
connection="Driver={PostgreSQL};Server=db.example.com;Database=mkt;Uid=reader;******",
52+
cast_map={"ts": pl.Date},
53+
)
54+
55+
# `ts` is narrowed to a date in the query, so this filter becomes a SQL WHERE clause
56+
# instead of being applied after a full scan.
57+
result = lf.filter(pl.col("ts") == date(2024, 1, 1)).collect()
58+
```
59+
60+
`cast_map` wraps your query in a projecting subquery that casts the named columns and
61+
passes the rest through, so `select *` keeps flowing every column.
62+
4463
## Read from ClickHouse
4564

4665
`scan_clickhouse` streams query results over ClickHouse's HTTP interface as Arrow IPC.

polars_io_tools/io_sources/lazy_sql_reader.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .sql_utils import (
1111
apply_polars_io_source_exprs,
1212
fix_three_part_identifiers,
13+
wrap_query_with_casts,
1314
)
1415
from .util import register_io_source_with_is_pure
1516

@@ -90,7 +91,7 @@ def get_schema_from_query_odbc(
9091
raise ValueError(f"Could not determine schema for query: {query}, with error: {e}") from e
9192

9293

93-
def scan_db(query: str, connection: str, fetch_size: int = 10000, **kwargs) -> pl.LazyFrame:
94+
def scan_db(query: str, connection: str, fetch_size: int = 10000, cast_map: dict[str, Any] | None = None, **kwargs) -> pl.LazyFrame:
9495
"""
9596
Create a LazyFrame from a SQL query with predicate pushdown support.
9697
@@ -107,6 +108,12 @@ def scan_db(query: str, connection: str, fetch_size: int = 10000, **kwargs) -> p
107108
source generator function that scan_db wraps (because it is required \
108109
by the Polars IO plugins API). This value will only be used if Polars \
109110
does not pass a value for batch size; if it does, that will be used instead.
111+
cast_map (dict[str, pl.DataType] | None, default None): Optional mapping of output column \
112+
name to a Polars dtype to cast that column to *server-side*. The narrowing is emitted \
113+
as a SQL ``CAST`` inside the query, and the reported schema reflects the target dtype, \
114+
so filters on the cast column push down to the database. Use this to correct a \
115+
mis-declared source type (e.g. a column stored as ``datetime`` that should be ``date``) \
116+
without abandoning ``select *`` — remaining columns pass through untouched.
110117
**kwargs: Additional arguments for the database connector
111118
112119
Returns:
@@ -132,6 +139,15 @@ def _fetch_info_needing_connection() -> tuple[
132139

133140
schema, parsed_query, dialect = _fetch_info_needing_connection()
134141

142+
if cast_map:
143+
unknown = [name for name in cast_map if name not in schema]
144+
if unknown:
145+
raise ValueError(f"cast_map references column(s) not in the query schema {list(schema)}: {unknown}")
146+
# Narrow the columns server-side and report the target dtypes, so predicates on
147+
# a cast column push down to the database instead of stalling above a client cast.
148+
parsed_query = wrap_query_with_casts(parsed_query, dialect, list(schema), cast_map)
149+
schema = {name: cast_map.get(name, dtype) for name, dtype in schema.items()}
150+
135151
# Create the generator function for our custom IO source
136152
def source_generator(
137153
with_columns: list[str] | None,

polars_io_tools/io_sources/sql_utils.py

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,66 @@
1616
"convert_predicate_to_sql",
1717
"create_sqlglot_literal",
1818
"fix_three_part_identifiers",
19+
"polars_dtype_to_sqlglot_type",
1920
)
2021

2122

2223
# Configure logging
2324
log = logging.getLogger(__name__)
2425

2526

27+
def polars_dtype_to_sqlglot_type(dtype: pl.DataType | type[pl.DataType], *, strict: bool = False) -> sqlglot.exp.DataType:
28+
"""Map a Polars dtype (instance or class) to a sqlglot ``DataType``.
29+
30+
Shared by the predicate translator (``SQLExpressionVisitor.visit_cast``) and the
31+
server-side cast wrapping in ``scan_db`` so both emit identical SQL types.
32+
33+
Decimal precision and scale are preserved so a decimal cast does not silently
34+
truncate the fractional part.
35+
36+
Args:
37+
dtype: The Polars dtype to translate, as a class or an instance.
38+
strict: When True, raise ``ValueError`` for a dtype without a dedicated SQL
39+
mapping instead of falling back to ``VARCHAR``. Callers that report the
40+
requested dtype as the resulting schema (so the emitted SQL must actually
41+
produce it) should set this; the predicate translator leaves it False and
42+
lets the database coerce an unmapped cast.
43+
"""
44+
type_map = {
45+
pl.Int8: sqlglot.exp.DataType.Type.TINYINT,
46+
pl.Int16: sqlglot.exp.DataType.Type.SMALLINT,
47+
pl.Int32: sqlglot.exp.DataType.Type.INT,
48+
pl.Int64: sqlglot.exp.DataType.Type.BIGINT,
49+
pl.UInt8: getattr(sqlglot.exp.DataType.Type, "UTINYINT", sqlglot.exp.DataType.Type.TINYINT),
50+
pl.UInt16: getattr(sqlglot.exp.DataType.Type, "USMALLINT", sqlglot.exp.DataType.Type.SMALLINT),
51+
pl.UInt32: getattr(sqlglot.exp.DataType.Type, "UINT", sqlglot.exp.DataType.Type.INT),
52+
pl.UInt64: getattr(sqlglot.exp.DataType.Type, "UBIGINT", sqlglot.exp.DataType.Type.BIGINT),
53+
pl.Float32: sqlglot.exp.DataType.Type.FLOAT,
54+
pl.Float64: sqlglot.exp.DataType.Type.DOUBLE,
55+
pl.Utf8: sqlglot.exp.DataType.Type.VARCHAR,
56+
pl.Date: sqlglot.exp.DataType.Type.DATE,
57+
pl.Datetime: sqlglot.exp.DataType.Type.TIMESTAMP,
58+
pl.Time: sqlglot.exp.DataType.Type.TIME,
59+
pl.Decimal: sqlglot.exp.DataType.Type.DECIMAL,
60+
}
61+
key = dtype if isinstance(dtype, type) else type(dtype)
62+
base = type_map.get(key)
63+
if base is None:
64+
if strict:
65+
supported = ", ".join(sorted(t.__name__ for t in type_map))
66+
raise ValueError(f"No SQL type mapping for Polars dtype {dtype!r}. Supported dtypes: {supported}.")
67+
return sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.VARCHAR)
68+
69+
# Preserve decimal precision/scale so the cast keeps the fractional part.
70+
if key is pl.Decimal and not isinstance(dtype, type):
71+
precision = getattr(dtype, "precision", None)
72+
if precision is not None:
73+
scale = getattr(dtype, "scale", None) or 0
74+
return sqlglot.exp.DataType.build(f"DECIMAL({precision}, {scale})")
75+
76+
return sqlglot.exp.DataType(this=base)
77+
78+
2679
def create_sqlglot_literal(value: Any) -> sqlglot.exp.Expression:
2780
"""Create a sqlglot literal from a raw value.
2881
@@ -405,35 +458,8 @@ def visit_cast(self, node: CastNode) -> None:
405458
self.result = input_expr
406459
return
407460

408-
# Map Polars types to SQL types
409-
type_map = {
410-
pl.Int8: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.TINYINT),
411-
pl.Int16: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.SMALLINT),
412-
pl.Int32: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.INT),
413-
pl.Int64: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.BIGINT),
414-
pl.UInt8: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.UTINYINT)
415-
if hasattr(sqlglot.exp.DataType.Type, "UTINYINT")
416-
else sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.TINYINT),
417-
pl.UInt16: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.USMALLINT)
418-
if hasattr(sqlglot.exp.DataType.Type, "USMALLINT")
419-
else sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.SMALLINT),
420-
pl.UInt32: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.UINT)
421-
if hasattr(sqlglot.exp.DataType.Type, "UINT")
422-
else sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.INT),
423-
pl.UInt64: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.UBIGINT)
424-
if hasattr(sqlglot.exp.DataType.Type, "UBIGINT")
425-
else sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.BIGINT),
426-
pl.Float32: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.FLOAT),
427-
pl.Float64: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.DOUBLE),
428-
pl.Utf8: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.VARCHAR),
429-
pl.Date: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.DATE),
430-
pl.Datetime: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.TIMESTAMP),
431-
pl.Time: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.TIME),
432-
pl.Decimal: sqlglot.exp.DataType(this=sqlglot.exp.DataType.Type.DECIMAL),
433-
}
434-
435-
# Get the SQL type
436-
sql_type = type_map.get(type(node.dtype), "VARCHAR")
461+
# Map the Polars type to a SQL type (shared with scan_db's cast_map)
462+
sql_type = polars_dtype_to_sqlglot_type(node.dtype)
437463

438464
# Create the CAST expression
439465
self.result = sqlglot.exp.Cast(this=input_expr, to=sql_type)
@@ -678,6 +704,60 @@ def _prepare_inner_for_subquery(
678704
return inner, order_to_hoist, options_to_hoist
679705

680706

707+
def wrap_query_with_casts(
708+
query: sqlglot.exp.Expression,
709+
dialect: str | type[Dialect] | None,
710+
ordered_columns: list[str],
711+
cast_map: dict[str, pl.DataType | type[pl.DataType]],
712+
) -> sqlglot.exp.Expression:
713+
"""Wrap ``query`` in a projecting subquery that casts selected columns server-side.
714+
715+
Every column in ``ordered_columns`` is re-projected (preserving order); columns
716+
present in ``cast_map`` are wrapped in a SQL ``CAST`` to the mapped type, the rest
717+
pass through untouched. The original query is left intact as the inner relation,
718+
so ``select *`` keeps flowing new upstream columns.
719+
720+
The narrowing lands one subquery level *below* the predicate/projection pushdown
721+
added later by :func:`apply_polars_io_source_exprs`, so a filter on a cast column
722+
(e.g. ``datetime`` narrowed to ``date``) resolves against the already-cast output
723+
column and is pushed to the database rather than evaluated client-side.
724+
725+
Clauses that are illegal inside a derived table (an MSSQL top-level ``ORDER BY``
726+
without ``TOP``/``OFFSET``, or ``OPTION`` hints) are hoisted onto the cast
727+
``SELECT`` so the nested subquery stays valid; the later wrapping in
728+
:func:`apply_polars_io_source_exprs` hoists them again to statement level.
729+
"""
730+
quote = _is_known_dialect(dialect)
731+
732+
def _ident(name: str) -> sqlglot.exp.Identifier:
733+
return sqlglot.exp.Identifier(this=name, quoted=quote)
734+
735+
inner_query, order_to_hoist, options_to_hoist = _prepare_inner_for_subquery(query, dialect=dialect)
736+
737+
projections: list[sqlglot.exp.Expression] = []
738+
for name in ordered_columns:
739+
col = sqlglot.exp.Column(this=_ident(name))
740+
if name in cast_map:
741+
sql_type = polars_dtype_to_sqlglot_type(cast_map[name], strict=True)
742+
projections.append(sqlglot.exp.Cast(this=col, to=sql_type).as_(name, quoted=quote))
743+
else:
744+
projections.append(col)
745+
746+
inner = sqlglot.exp.Subquery(
747+
this=inner_query,
748+
alias=sqlglot.exp.TableAlias(this=sqlglot.exp.Identifier(this="__cpl_cast")),
749+
)
750+
cast_select = sqlglot.exp.Select().from_(inner, dialect=dialect).select(*projections, append=False, dialect=dialect)
751+
752+
# Re-apply the clauses hoisted out of the (now nested) original query.
753+
if order_to_hoist is not None:
754+
cast_select.set("order", order_to_hoist)
755+
for opt in options_to_hoist:
756+
cast_select.args.setdefault("options", []).append(opt)
757+
758+
return cast_select
759+
760+
681761
def apply_polars_io_source_exprs(
682762
query: sqlglot.exp.Expression,
683763
dialect: str | type[Dialect] | None,

polars_io_tools/tests/io_sources/test_lazy_sql_reader.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2821,3 +2821,141 @@ def test_mssql_parenthesized_set_operations_only_stabilize_leftmost_output_branc
28212821
"[g]",
28222822
"COUNT(*) AS except_count",
28232823
]
2824+
2825+
2826+
# Tests for scan_db's server-side cast_map (datetime -> date narrowing pushdown)
2827+
2828+
2829+
def _make_record_table(conn):
2830+
"""Create a table whose RecordDate is a TIMESTAMP (the mis-declared-type case)."""
2831+
conn.execute(
2832+
"""
2833+
CREATE TABLE RecordTbl AS SELECT * FROM (
2834+
SELECT CAST('2025-05-30 09:15:00' AS TIMESTAMP) AS RecordDate, 1 AS PointID, 1.5 AS Rate
2835+
UNION ALL
2836+
SELECT CAST('2025-05-30 17:45:00' AS TIMESTAMP) AS RecordDate, 2 AS PointID, 2.5 AS Rate
2837+
UNION ALL
2838+
SELECT CAST('2025-05-31 00:00:00' AS TIMESTAMP) AS RecordDate, 3 AS PointID, 3.5 AS Rate
2839+
)
2840+
"""
2841+
)
2842+
2843+
2844+
def test_cast_map_reports_target_dtype_in_schema(duckdb_connection):
2845+
_make_record_table(duckdb_connection)
2846+
lf = cpl.scan_db("SELECT * FROM RecordTbl", "fake_connection_string", cast_map={"RecordDate": pl.Date})
2847+
schema = lf.collect_schema()
2848+
assert schema["RecordDate"] == pl.Date
2849+
# Untouched columns keep their native dtypes and are still present (select * preserved).
2850+
assert set(schema.names()) == {"RecordDate", "PointID", "Rate"}
2851+
2852+
2853+
def test_cast_map_narrows_column_server_side(duckdb_connection):
2854+
_make_record_table(duckdb_connection)
2855+
out = cpl.scan_db("SELECT * FROM RecordTbl", "fake_connection_string", cast_map={"RecordDate": pl.Date}).sort("PointID").collect()
2856+
assert out["RecordDate"].dtype == pl.Date
2857+
assert out["RecordDate"].to_list() == [date(2025, 5, 30), date(2025, 5, 30), date(2025, 5, 31)]
2858+
2859+
2860+
def test_cast_map_pushes_date_predicate_to_sql(duckdb_connection):
2861+
_make_record_table(duckdb_connection)
2862+
lf = cpl.scan_db("SELECT * FROM RecordTbl", "fake_connection_string", cast_map={"RecordDate": pl.Date})
2863+
2864+
import arrow_odbc
2865+
2866+
captured: list[str] = []
2867+
original = arrow_odbc.read_arrow_batches_from_odbc
2868+
2869+
def capturing(*args, **kwargs):
2870+
captured.append(args[0] if args else kwargs["query"])
2871+
return original(*args, **kwargs)
2872+
2873+
arrow_odbc.read_arrow_batches_from_odbc = capturing
2874+
try:
2875+
out = lf.filter(pl.col("RecordDate") == date(2025, 5, 30)).sort("PointID").collect()
2876+
finally:
2877+
arrow_odbc.read_arrow_batches_from_odbc = original
2878+
2879+
# Two rows on 2025-05-30 survive the narrowing; the 05-31 row is filtered out.
2880+
assert out["PointID"].to_list() == [1, 2]
2881+
assert out["RecordDate"].to_list() == [date(2025, 5, 30), date(2025, 5, 30)]
2882+
2883+
# The predicate reached SQL: the executed query casts RecordDate to DATE and filters on it.
2884+
assert captured, "No SQL captured"
2885+
sql = captured[-1]
2886+
assert "CAST" in sql.upper() and "DATE" in sql.upper()
2887+
assert "RecordDate" in sql
2888+
assert "WHERE" in sql.upper(), f"date predicate was not pushed to SQL: {sql}"
2889+
2890+
2891+
def test_cast_map_projection_pushdown_still_prunes(duckdb_connection):
2892+
_make_record_table(duckdb_connection)
2893+
lf = cpl.scan_db("SELECT * FROM RecordTbl", "fake_connection_string", cast_map={"RecordDate": pl.Date})
2894+
2895+
import arrow_odbc
2896+
2897+
captured: list[str] = []
2898+
original = arrow_odbc.read_arrow_batches_from_odbc
2899+
2900+
def capturing(*args, **kwargs):
2901+
captured.append(args[0] if args else kwargs["query"])
2902+
return original(*args, **kwargs)
2903+
2904+
arrow_odbc.read_arrow_batches_from_odbc = capturing
2905+
try:
2906+
out = lf.select(["RecordDate", "PointID"]).sort("PointID").collect()
2907+
finally:
2908+
arrow_odbc.read_arrow_batches_from_odbc = original
2909+
2910+
assert out.columns == ["RecordDate", "PointID"]
2911+
assert out["RecordDate"].dtype == pl.Date
2912+
# The outer projection is pruned to the selected columns (the inner cast layer
2913+
# still enumerates every column so the DB prunes Rate via the outer select).
2914+
sql = captured[-1]
2915+
assert sql.strip().upper().startswith('SELECT "RECORDDATE", "POINTID" FROM')
2916+
2917+
2918+
def test_cast_map_unknown_column_raises(duckdb_connection):
2919+
_make_record_table(duckdb_connection)
2920+
with pytest.raises(ValueError, match="cast_map references column"):
2921+
cpl.scan_db("SELECT * FROM RecordTbl", "fake_connection_string", cast_map={"NotAColumn": pl.Date})
2922+
2923+
2924+
def test_cast_map_none_is_unchanged(duckdb_connection):
2925+
_make_record_table(duckdb_connection)
2926+
lf = cpl.scan_db("SELECT * FROM RecordTbl", "fake_connection_string")
2927+
assert lf.collect_schema()["RecordDate"] == pl.Datetime("us")
2928+
2929+
2930+
def test_cast_map_hoists_mssql_order_by_out_of_derived_table():
2931+
"""A top-level MSSQL ORDER BY must not end up inside the cast derived table.
2932+
2933+
MSSQL rejects ORDER BY in a derived table without TOP/OFFSET (error 1033), so the
2934+
cast wrapper must hoist it to statement level.
2935+
"""
2936+
from polars_io_tools.io_sources.sql_utils import apply_polars_io_source_exprs, wrap_query_with_casts
2937+
2938+
query = parse_one("SELECT * FROM dbo.t ORDER BY x", dialect=MSSQL)
2939+
wrapped = wrap_query_with_casts(query, MSSQL, ["x", "y"], {"x": pl.Date})
2940+
final = apply_polars_io_source_exprs(wrapped.copy(), MSSQL, None, pl.col("x") == date(2025, 5, 30), None, None)
2941+
sql = final.sql(dialect=MSSQL)
2942+
2943+
# ORDER BY sits at the end, after the outermost subquery closes, not inside __cpl_cast.
2944+
assert "AS __cpl_cast) AS __cpl_subq" in sql
2945+
assert "ORDER BY [x]" in sql
2946+
assert sql.rstrip().endswith("ORDER BY [x]")
2947+
assert "ORDER BY [x]) AS __cpl_cast" not in sql
2948+
2949+
2950+
def test_cast_map_preserves_decimal_scale():
2951+
from polars_io_tools.io_sources.sql_utils import polars_dtype_to_sqlglot_type
2952+
2953+
assert polars_dtype_to_sqlglot_type(pl.Decimal(10, 2)).sql(dialect=MSSQL) == "NUMERIC(10, 2)"
2954+
2955+
2956+
def test_cast_map_rejects_unsupported_dtype(duckdb_connection):
2957+
_make_record_table(duckdb_connection)
2958+
# Boolean has no server-side narrowing mapping; reporting it as the schema while
2959+
# emitting VARCHAR would misrepresent the data, so it must be rejected.
2960+
with pytest.raises(ValueError, match="No SQL type mapping"):
2961+
cpl.scan_db("SELECT * FROM RecordTbl", "fake_connection_string", cast_map={"RecordDate": pl.Boolean})

0 commit comments

Comments
 (0)