Skip to content

Commit 931968b

Browse files
authored
feat(sqlalchemy-bigquery): support BigQuery SAFE_CAST and SQLAlchemy 2.0 try_cast (#18953)
1 parent 708f98b commit 931968b

5 files changed

Lines changed: 154 additions & 9 deletions

File tree

‎packages/sqlalchemy-bigquery/README.rst‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Installations when processing large datasets
8484
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
8585

8686
When handling large datasets, you may see speed increases by also installing the
87-
`bqstorage` dependencies. See the instructions above about creating a virtual
87+
`bqstorage` dependencies. See the instructions above about creating a virtual
8888
environment and then install `sqlalchemy-bigquery` using the `bqstorage` extras:
8989

9090
.. code-block:: console
@@ -117,7 +117,7 @@ Project
117117
Authentication
118118
^^^^^^^^^^^^^^
119119

120-
Follow the `Google Cloud library guide <https://google-cloud-python.readthedocs.io/en/latest/core/auth.html>`_ for authentication.
120+
Follow the `Google Cloud library guide <https://google-cloud-python.readthedocs.io/en/latest/core/auth.html>`_ for authentication.
121121

122122
Alternatively, you can choose either of the following approaches:
123123

@@ -131,10 +131,10 @@ Alternatively, you can choose either of the following approaches:
131131
* pass the credentials in ``create_engine()`` as a Python dictionary using the ``credentials_info`` parameter:
132132

133133
.. code-block:: python
134-
134+
135135
# provide credentials as a Python dictionary
136136
credentials_info = {
137-
"type": "service_account",
137+
"type": "service_account",
138138
"project_id": "your-service-account-project-id"
139139
}
140140
engine = create_engine('bigquery://', credentials_info=credentials_info)
@@ -351,6 +351,22 @@ To create an integer-range partitioned table
351351
)
352352
353353
354+
Casting with ``SAFE_CAST``
355+
^^^^^^^^^^^^^^^^^^^^^^^^^^
356+
357+
To generate a BigQuery ``SAFE_CAST`` expression (which returns ``NULL`` instead
358+
of raising an error when a cast fails), use ``safe_cast`` from
359+
``sqlalchemy_bigquery`` (or ``sqlalchemy.try_cast`` on SQLAlchemy 2.0+):
360+
361+
.. code-block:: python
362+
363+
from sqlalchemy import Integer, select
364+
from sqlalchemy_bigquery import safe_cast
365+
366+
stmt = select(safe_cast(table.c.string_col, Integer))
367+
368+
369+
354370
Threading and Multiprocessing
355371
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
356372

‎packages/sqlalchemy-bigquery/docs/README.rst‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Installations when processing large datasets
8484
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
8585

8686
When handling large datasets, you may see speed increases by also installing the
87-
`bqstorage` dependencies. See the instructions above about creating a virtual
87+
`bqstorage` dependencies. See the instructions above about creating a virtual
8888
environment and then install `sqlalchemy-bigquery` using the `bqstorage` extras:
8989

9090
.. code-block:: console
@@ -117,7 +117,7 @@ Project
117117
Authentication
118118
^^^^^^^^^^^^^^
119119

120-
Follow the `Google Cloud library guide <https://google-cloud-python.readthedocs.io/en/latest/core/auth.html>`_ for authentication.
120+
Follow the `Google Cloud library guide <https://google-cloud-python.readthedocs.io/en/latest/core/auth.html>`_ for authentication.
121121

122122
Alternatively, you can choose either of the following approaches:
123123

@@ -131,10 +131,10 @@ Alternatively, you can choose either of the following approaches:
131131
* pass the credentials in ``create_engine()`` as a Python dictionary using the ``credentials_info`` parameter:
132132

133133
.. code-block:: python
134-
134+
135135
# provide credentials as a Python dictionary
136136
credentials_info = {
137-
"type": "service_account",
137+
"type": "service_account",
138138
"project_id": "your-service-account-project-id"
139139
}
140140
engine = create_engine('bigquery://', credentials_info=credentials_info)
@@ -351,6 +351,22 @@ To create an integer-range partitioned table
351351
)
352352
353353
354+
Casting with ``SAFE_CAST``
355+
^^^^^^^^^^^^^^^^^^^^^^^^^^
356+
357+
To generate a BigQuery ``SAFE_CAST`` expression (which returns ``NULL`` instead
358+
of raising an error when a cast fails), use ``safe_cast`` from
359+
``sqlalchemy_bigquery`` (or ``sqlalchemy.try_cast`` on SQLAlchemy 2.0+):
360+
361+
.. code-block:: python
362+
363+
from sqlalchemy import Integer, select
364+
from sqlalchemy_bigquery import safe_cast
365+
366+
stmt = select(safe_cast(table.c.string_col, Integer))
367+
368+
369+
354370
Threading and Multiprocessing
355371
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
356372

‎packages/sqlalchemy-bigquery/sqlalchemy_bigquery/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
TIME,
4343
TIMESTAMP,
4444
)
45-
from .base import BigQueryDialect, dialect
45+
from .base import BigQueryDialect, SafeCast, dialect, safe_cast
4646
from .version import __version__
4747

4848
# Now that support for Python 3.7, 3.8 and 3.9 has been removed, we don't expect the
@@ -77,8 +77,10 @@
7777
"RECORD",
7878
"STRING",
7979
"STRUCT",
80+
"SafeCast",
8081
"TIME",
8182
"TIMESTAMP",
83+
"safe_cast",
8284
]
8385

8486
try:

‎packages/sqlalchemy-bigquery/sqlalchemy_bigquery/base.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import re
2626
import uuid
2727
from decimal import Decimal
28+
from typing import Any
2829

2930
import google.api_core.exceptions
3031
import packaging.version
@@ -587,6 +588,13 @@ def visit_not_regexp_match_op_binary(self, binary, operator, **kw):
587588
def visit_mod_binary(self, binary, operator, **kw):
588589
return f"MOD({self.process(binary.left, **kw)}, {self.process(binary.right, **kw)})"
589590

591+
def visit_try_cast(self, cast: elements.Cast, **kwargs: Any) -> str:
592+
"""Compile a ``SAFE_CAST`` / ``try_cast`` expression for BigQuery."""
593+
return "SAFE_CAST(%s AS %s)" % (
594+
self.process(cast.clause, **kwargs),
595+
self.process(cast.typeclause, **kwargs),
596+
)
597+
590598

591599
class BigQueryTypeCompiler(GenericTypeCompiler):
592600
def visit_INTEGER(self, type_, **kw):
@@ -1382,6 +1390,32 @@ def get_view_definition(self, connection, view_name, schema=None, **kw):
13821390
return view.view_query
13831391

13841392

1393+
_BaseTryCast = getattr(elements, "TryCast", elements.Cast)
1394+
1395+
1396+
class SafeCast(_BaseTryCast):
1397+
"""Represent a BigQuery ``SAFE_CAST`` expression."""
1398+
1399+
__visit_name__ = "try_cast"
1400+
inherit_cache = True
1401+
1402+
1403+
def safe_cast(expression: Any, type_: Any) -> SafeCast:
1404+
"""Produce a BigQuery ``SAFE_CAST`` expression.
1405+
1406+
``SAFE_CAST`` is identical to ``CAST``, except that it returns ``NULL``
1407+
instead of raising an error when a cast fails.
1408+
1409+
Args:
1410+
expression: A SQL expression, column, or literal to be cast.
1411+
type_: The target SQLAlchemy ``TypeEngine`` class or instance.
1412+
1413+
Returns:
1414+
A ``SafeCast`` clause element that compiles to ``SAFE_CAST(...)``.
1415+
"""
1416+
return SafeCast(expression, type_)
1417+
1418+
13851419
class unnest(sqlalchemy.sql.functions.GenericFunction):
13861420
def __init__(self, *args, **kwargs):
13871421
expr = kwargs.pop("expr", None)

‎packages/sqlalchemy-bigquery/tests/unit/test_compiler.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import sqlalchemy.exc
2222
from sqlalchemy.sql.functions import cube, grouping_sets, rollup
2323

24+
from sqlalchemy_bigquery import SafeCast, safe_cast
25+
2426
from .conftest import setup_table, sqlalchemy_2_0_or_higher, sqlalchemy_before_2_0
2527

2628

@@ -487,3 +489,78 @@ def test_float_literal_pyformat_bindparam():
487489
stmt = sqlalchemy.select(sqlalchemy.literal(15.7563))
488490
compiled = stmt.compile(dialect=dialect)
489491
assert "%(param_1:FLOAT64)s" in str(compiled)
492+
493+
494+
SAFE_CAST_TYPE_CASES = [
495+
pytest.param(sqlalchemy.Integer, "INT64", id="integer_class"),
496+
pytest.param(sqlalchemy.Integer(), "INT64", id="integer_instance"),
497+
pytest.param(sqlalchemy.String, "STRING", id="string"),
498+
pytest.param(sqlalchemy.Float, "FLOAT64", id="float"),
499+
pytest.param(sqlalchemy.Boolean, "BOOL", id="boolean"),
500+
pytest.param(sqlalchemy.Date, "DATE", id="date"),
501+
pytest.param(sqlalchemy.Numeric, "NUMERIC", id="numeric"),
502+
]
503+
504+
505+
@pytest.mark.parametrize("target_type,expected_bq_type", SAFE_CAST_TYPE_CASES)
506+
def test_safe_cast_column(faux_conn, table, target_type, expected_bq_type):
507+
expr = safe_cast(table.c.foo, target_type)
508+
assert isinstance(expr, SafeCast)
509+
assert expr.inherit_cache is True
510+
expected_type_cls = (
511+
target_type if isinstance(target_type, type) else type(target_type)
512+
)
513+
assert isinstance(expr.type, expected_type_cls)
514+
515+
stmt = sqlalchemy.select(expr.label("casted"))
516+
found_sql = stmt.compile(faux_conn).string
517+
expected_sql = (
518+
f"SELECT SAFE_CAST(`table1`.`foo` AS {expected_bq_type}) AS `casted` \n"
519+
"FROM `table1`"
520+
)
521+
assert found_sql == expected_sql
522+
523+
524+
def test_safe_cast_literal(faux_conn):
525+
stmt = sqlalchemy.select(safe_cast(sqlalchemy.literal("123"), sqlalchemy.Integer))
526+
found_sql = stmt.compile(faux_conn, compile_kwargs={"literal_binds": True}).string
527+
assert found_sql == "SELECT SAFE_CAST('123' AS INT64) AS `anon_1`"
528+
529+
530+
def test_safe_cast_null(faux_conn):
531+
stmt = sqlalchemy.select(
532+
safe_cast(sqlalchemy.null(), sqlalchemy.Integer).label("val")
533+
)
534+
found_sql = stmt.compile(faux_conn).string
535+
assert found_sql == "SELECT SAFE_CAST(NULL AS INT64) AS `val`"
536+
537+
538+
def test_safe_cast_nested(faux_conn, table):
539+
inner = safe_cast(table.c.foo, sqlalchemy.String)
540+
outer = safe_cast(inner, sqlalchemy.Integer).label("casted")
541+
stmt = sqlalchemy.select(outer)
542+
found_sql = stmt.compile(faux_conn).string
543+
expected_sql = (
544+
"SELECT SAFE_CAST(SAFE_CAST(`table1`.`foo` AS STRING) AS INT64) AS `casted` \n"
545+
"FROM `table1`"
546+
)
547+
assert found_sql == expected_sql
548+
549+
550+
@pytest.mark.skipif(
551+
not hasattr(sqlalchemy, "try_cast"),
552+
reason="sqlalchemy.try_cast requires SQLAlchemy 2.0.14 or higher",
553+
)
554+
@pytest.mark.parametrize("target_type,expected_bq_type", SAFE_CAST_TYPE_CASES)
555+
def test_sqlalchemy_try_cast_compiles_to_safe_cast(
556+
faux_conn, table, target_type, expected_bq_type
557+
):
558+
stmt = sqlalchemy.select(
559+
sqlalchemy.try_cast(table.c.foo, target_type).label("casted")
560+
)
561+
found_sql = stmt.compile(faux_conn).string
562+
expected_sql = (
563+
f"SELECT SAFE_CAST(`table1`.`foo` AS {expected_bq_type}) AS `casted` \n"
564+
"FROM `table1`"
565+
)
566+
assert found_sql == expected_sql

0 commit comments

Comments
 (0)