Skip to content

Commit 44a6b54

Browse files
authored
add ansi-safe casting (#303)
* add ansi-safe casting * update broken sql * add unit tests * add unit tests for actions.py ansi error check * add 2 tests for general raise case in actions.py * unit tests: fixes and cover general raise condition * fix test_actions unit tests * add code coverage * update docs, rename tests
1 parent 22d5ef8 commit 44a6b54

9 files changed

Lines changed: 365 additions & 38 deletions

File tree

docs/user_guide/data_quality_rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ ADD CONSTRAINT action CHECK (
106106
**Tip:**
107107
- Match your expectation format to the rule type for correct validation.
108108
- Use `row_dq` for per-row checks, `agg_dq` for summary statistics, and `query_dq` for advanced SQL-based checks.
109+
- If running in Databricks serverless compute, expectations need to be ANSI compliant. See the ANSI Mode section of the [Serverless doc](serverless.md) for details.
109110

110111

111112
Below are the details and examples for each rule type:

docs/user_guide/serverless.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,9 @@ def process_data():
6666
result_df = process_data()
6767
```
6868

69+
### IMPORTANT: ANSI Mode
70+
When using serverless compute on Databricks, ANSI mode is **on by default**, which enforces stricter standards. If you use serverless compute you have two options:
71+
- Turn ANSI mode off: set `spark.sql.ansi.enabled` to `false`
72+
- Or, make sure your expectations are written in an ANSI-compliant way
73+
- Use `try_cast` instead of `CAST`
74+
- Please see Databricks documentation on ANSI mode for more guidance on other requirements for ANSI compliance

spark_expectations/core/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,13 @@ class SparkExpectationsInvalidAggDQExpectationException(Exception):
8080
"""
8181
Throw this exception when an invalid agg_dq expectation is encountered
8282
"""
83+
84+
def raise_if_ansi_exception(e: Exception, rule_name: str, rule_expectation: str) -> None:
85+
"""Check if exception is likely cast error due to ANSI mode. Add that info to error message and raise."""
86+
if "CAST_INVALID_INPUT" in str(e):
87+
raise SparkExpectationsMiscException(
88+
f"Cast error while evaluating rule '{rule_name}' with expectation/SQL '{rule_expectation}'. "
89+
f"This may be caused by rule expectation/SQL incompatibility with spark.sql.ansi.enabled=true "
90+
f"(default on Databricks Serverless). Please either update your expectation/SQL "
91+
f"to be compliant with ANSI mode, disable ANSI mode, or run not on serverless."
92+
) from e

spark_expectations/sinks/utils/report.py

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,15 @@
1414
size,
1515
concat_ws,
1616
abs,
17-
least,
18-
greatest,
1917
get_json_object,
2018
lit,
21-
trim,
2219
)
23-
from pyspark.sql.types import DoubleType, StringType, DecimalType, TimestampType
20+
from pyspark.sql.types import DoubleType, StringType, TimestampType
2421

2522
from spark_expectations import _log
2623
from spark_expectations.core.context import SparkExpectationsContext
2724
from spark_expectations.core.exceptions import SparkExpectationsMiscException
25+
from spark_expectations.utils.udf import safe_cast
2826

2927

3028
@dataclass
@@ -176,7 +174,7 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
176174
.withColumn(
177175
"total_records_only_nbr",
178176
when(col("_total_records_str") == "", lit(None).cast("bigint"))
179-
.otherwise(col("_total_records_str").cast("bigint")),
177+
.otherwise(safe_cast(self.spark, "_total_records_str", "bigint")),
180178
)
181179
.withColumn(
182180
"_valid_records_str",
@@ -185,7 +183,7 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
185183
.withColumn(
186184
"valid_records_only_nbr",
187185
when(col("_valid_records_str") == "", lit(None).cast("bigint"))
188-
.otherwise(col("_valid_records_str").cast("bigint")),
186+
.otherwise(safe_cast(self.spark, "_valid_records_str", "bigint")),
189187
)
190188
.drop("_total_records_str", "_valid_records_str")
191189
.withColumn(
@@ -194,27 +192,30 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
194192
col("total_records_only_nbr").isNull() & col("valid_records_only_nbr").isNull(),
195193
lit(100),
196194
)
197-
.when(
198-
col("total_records_only_nbr").isNull() & col("valid_records_only_nbr").isNull(),
199-
lit(100),
200-
)
201195
.when(
202196
col("total_records_only_nbr").isNotNull() & col("valid_records_only_nbr").isNull(),
203197
lit(0),
204198
)
205199
.otherwise(
206200
coalesce(
207-
(
208-
100
201+
safe_cast(
202+
self.spark,
203+
"""
204+
100
209205
* least(
210-
abs(trim(col("valid_records_only_nbr"))),
211-
abs(trim(col("total_records_only_nbr"))),
212-
)
213-
/ greatest(
214-
abs(trim(col("valid_records_only_nbr"))),
215-
abs(trim(col("total_records_only_nbr"))),
216-
)
217-
).cast(DecimalType(20, 2)),
206+
abs(valid_records_only_nbr),
207+
abs(total_records_only_nbr)
208+
) / nullif(
209+
greatest(
210+
abs(valid_records_only_nbr),
211+
abs(total_records_only_nbr)
212+
),
213+
0
214+
)
215+
""",
216+
"decimal(20, 2)"
217+
)
218+
,
218219
lit(0),
219220
)
220221
),
@@ -225,10 +226,6 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
225226
col("total_records_only_nbr").isNull() & col("valid_records_only_nbr").isNull(),
226227
lit(0),
227228
)
228-
.when(
229-
col("total_records_only_nbr").isNull() & col("valid_records_only_nbr").isNull(),
230-
lit(0),
231-
)
232229
.when(
233230
col("total_records_only_nbr").isNotNull() & col("valid_records_only_nbr").isNull(),
234231
lit(100),
@@ -255,11 +252,11 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
255252
abs(
256253
coalesce(
257254
coalesce(
258-
trim(col("total_records_only_nbr")).cast("bigint"),
255+
col("total_records_only_nbr"),
259256
lit(0),
260257
)
261258
- coalesce(
262-
trim(col("valid_records_only_nbr")).cast("bigint"),
259+
col("valid_records_only_nbr"),
263260
lit(0),
264261
),
265262
lit(0),
@@ -333,7 +330,7 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
333330
.withColumnRenamed("source_dq_error_row_count", "failed_records")
334331
.withColumn(
335332
"success_percentage",
336-
(col("valid_records").cast("double") / col("total_records").cast("double")) * 100,
333+
(safe_cast(self.spark, "valid_records", "double") / safe_cast(self.spark, "total_records", "double")) * 100,
337334
)
338335
)
339336

spark_expectations/utils/actions.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from spark_expectations.core.exceptions import (
2222
SparkExpectationsMiscException,
2323
SparkExpectOrFailException,
24+
raise_if_ansi_exception
2425
)
2526
from spark_expectations.utils.udf import get_actions_list, remove_empty_maps
2627

@@ -174,8 +175,11 @@ def agg_query_dq_detailed_result(
174175
_agg_dq_expectation_aggstring = _agg_dq_expectation_match.group(1)
175176
_agg_dq_expectation_expr = _agg_dq_expectation_match.group(2)
176177
_agg_dq_expectation_cond_expr = expr(_agg_dq_expectation_aggstring)
177-
178-
_agg_dq_actual_count_value_raw = df.agg(_agg_dq_expectation_cond_expr).collect()[0][0]
178+
try:
179+
_agg_dq_actual_count_value_raw = df.agg(_agg_dq_expectation_cond_expr).collect()[0][0]
180+
except Exception as e:
181+
raise_if_ansi_exception(e, _dq_rule["rule"], _dq_rule["expectation"])
182+
raise
179183

180184
# Handle NoneType (nulls)
181185
if _agg_dq_actual_count_value_raw is None:
@@ -252,7 +256,11 @@ def agg_query_dq_detailed_result(
252256

253257
_agg_dq_expectation_cond_expr = expr(_agg_dq_expectation_aggstring)
254258

255-
_agg_dq_actual_count_value = int(df.agg(_agg_dq_expectation_cond_expr).collect()[0][0])
259+
try:
260+
_agg_dq_actual_count_value = int(df.agg(_agg_dq_expectation_cond_expr).collect()[0][0])
261+
except Exception as e:
262+
raise_if_ansi_exception(e, _dq_rule["rule"], _agg_dq_expectation_aggstring)
263+
raise
256264

257265
_agg_dq_expression_str_lower = (
258266
str(_agg_dq_actual_count_value) + _agg_dq_expectation_expr_lowerbound
@@ -319,7 +327,11 @@ def agg_query_dq_detailed_result(
319327
)
320328
):
321329
for _key, _querydq_query in sub_key_value.items():
322-
_querydq_df = _context.spark.sql(_dq_rule["expectation" + "_" + _key])
330+
try:
331+
_querydq_df = _context.spark.sql(_dq_rule["expectation" + "_" + _key])
332+
except Exception as e:
333+
raise_if_ansi_exception(e, _dq_rule["rule"], _dq_rule["expectation" + "_" + _key])
334+
raise
323335
querydq_output.append(
324336
(
325337
_context.get_run_id,
@@ -357,7 +369,15 @@ def agg_query_dq_detailed_result(
357369
def execute_sql_and_get_result(
358370
_se_context: SparkExpectationsContext, query: str
359371
) -> Union[int, float, str]:
360-
return _se_context.spark.sql(f"SELECT ({query}) AS OUTPUT").collect()[0][0] if query else 0
372+
if query:
373+
try:
374+
result = _se_context.spark.sql(f"SELECT ({query}) AS OUTPUT").collect()[0][0]
375+
except Exception as e:
376+
raise_if_ansi_exception(e, _dq_rule["rule"], f"SELECT ({query}) AS OUTPUT")
377+
raise
378+
else:
379+
result = 0
380+
return result
361381

362382
# function to get the query outputs
363383
_querydq_source_query_output = execute_sql_and_get_result(_context, match.group(1))
@@ -380,8 +400,12 @@ def execute_sql_and_get_result(
380400

381401
_querydq_status_query = "SELECT (" + str(_dq_rule["expectation"]) + ") AS OUTPUT"
382402

383-
_query_dq_result = int(_context.spark.sql(_querydq_status_query).collect()[0][0])
384-
403+
try:
404+
_query_dq_result = int(_context.spark.sql(_querydq_status_query).collect()[0][0])
405+
except Exception as e:
406+
raise_if_ansi_exception(e, _dq_rule["rule"], _querydq_status_query)
407+
raise
408+
385409
status = "pass" if _query_dq_result else "fail"
386410

387411
if _source_dq_status:

spark_expectations/utils/udf.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from pyspark.sql import Column
2-
from pyspark.sql.functions import filter, size, transform, when, lit, array
1+
from pyspark.sql import SparkSession, Column
2+
from pyspark.sql.functions import filter, size, transform, when, lit, array, expr
33

44

55
def remove_empty_maps(column: Column) -> Column:
@@ -40,3 +40,20 @@ def get_actions_list(column: Column) -> Column:
4040
column = remove_passing_status_maps(column)
4141
action_if_failed = transform(column, lambda x: x["action_if_failed"])
4242
return when(size(action_if_failed) == 0, array(lit("ignore"))).otherwise(action_if_failed) # pragma: no cover
43+
44+
def safe_cast(spark: SparkSession, column: str, target_type: str) -> Column:
45+
"""
46+
Checks if ANSI mode is enabled. If enabled, uses try_cast to cast the column to the target type. If not, uses cast.
47+
Args:
48+
spark: SparkSession
49+
column: column to cast (provided as a string)
50+
target_type: target type to cast to
51+
52+
Returns:
53+
Column: the casted column
54+
"""
55+
ansi_enabled = spark.conf.get("spark.sql.ansi.enabled", "false").lower() == "true"
56+
if ansi_enabled:
57+
return expr(f"try_cast({column} as {target_type})")
58+
else:
59+
return expr(f"cast({column} as {target_type})")

tests/integration/utils/test_udf.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
from pyspark.sql.types import LongType, DoubleType
12
from spark_expectations.core import get_spark_session
2-
from spark_expectations.utils.udf import remove_empty_maps, get_actions_list
3+
from spark_expectations.utils.udf import remove_empty_maps, get_actions_list, safe_cast
34

45
spark = get_spark_session()
56

@@ -57,3 +58,42 @@ def test_get_actions_list():
5758

5859
for itr in range(0, 4):
5960
assert results[itr].actions == expected_output[itr]
61+
62+
def test_safe_cast_ansi_disabled():
63+
# with default spark.sql.ansi.enabled=false, safe cast should return
64+
# a column equavalent to cast(col as type)
65+
spark.conf.set("spark.sql.ansi.enabled", "false")
66+
67+
columns = ["Name", "Value"]
68+
data = [("thing", "123")]
69+
df = spark.createDataFrame(data, schema=columns)
70+
71+
result_df = df.withColumn("casted_value", safe_cast(spark, "Value", "bigint"))
72+
result = result_df.select("casted_value").collect()[0]["casted_value"]
73+
74+
assert result == 123
75+
assert result_df.schema["casted_value"].dataType == LongType()
76+
77+
78+
def test_safe_cast_ansi_enabled():
79+
# with spark.sql.ansi.enabled=true, safe cast should succeed if it's a castable value and should
80+
# return Null if it's not a valid cast (without safe cast it would throw an exception insead)
81+
spark.conf.set("spark.sql.ansi.enabled", "true")
82+
83+
columns = ["Name", "Value", "Color"]
84+
data = [("mug", "10", "red")]
85+
df = spark.createDataFrame(data, schema=columns)
86+
87+
success_result_df = df.withColumn("success_casted_value", safe_cast(spark, "Value", "double"))
88+
success_result = success_result_df.select("success_casted_value").collect()[0]["success_casted_value"]
89+
90+
fail_result_df = df.withColumn("fail_casted_value", safe_cast(spark, "Color", "double"))
91+
fail_result = fail_result_df.select("fail_casted_value").collect()[0]["fail_casted_value"]
92+
93+
# re-set ansi mode so it doesn't affect other tests
94+
spark.conf.set("spark.sql.ansi.enabled", "false")
95+
96+
assert success_result == 10
97+
assert success_result_df.schema["success_casted_value"].dataType == DoubleType()
98+
99+
assert fail_result is None

tests/unit/core/test_expectations.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
import pytest
55

6-
from spark_expectations.core.exceptions import SparkExpectationsUserInputOrConfigInvalidException
6+
from spark_expectations.core.exceptions import SparkExpectationsMiscException, SparkExpectationsUserInputOrConfigInvalidException, raise_if_ansi_exception
77
from spark_expectations.core.expectations import SparkExpectations, WrappedDataFrameWriter
88

99

@@ -472,3 +472,30 @@ def test_add_hash_columns_trims_whitespace_produces_valid_hash(
472472
assert row["id_hash"] is not None
473473
assert len(row["id_hash"]) == 32
474474
assert all(c in "0123456789abcdef" for c in row["id_hash"])
475+
476+
477+
class TestRaiseIfAnsiException:
478+
"""Test cases for SparkExpectations exception raise_if_ansi_exception method."""
479+
480+
def test_raise_if_ansi_exception(self):
481+
ansi_exception = Exception("SparkExpectationsMiscException: error occurred while processing spark expectations"
482+
" error occurred while executing func_process error occurred while running expectations error occurred"
483+
" while running agg_query_dq_detailed_result [CAST_INVALID_INPUT] The value '' of the type \"STRING\""
484+
" cannot be cast to \"BIGINT\" because it is malformed. Correct the value as per the syntax, or change its"
485+
" target type. Use `try_cast` to tolerate malformed input and return NULL instead.")
486+
487+
rule_name = "Column A Greater than Column B"
488+
rule_expectation = "[col_A] > [col_B]"
489+
490+
with pytest.raises(SparkExpectationsMiscException) as exception_info:
491+
raise_if_ansi_exception(ansi_exception, rule_name, rule_expectation)
492+
493+
assert f"{rule_name}" in str(exception_info.value)
494+
assert f"{rule_expectation}" in str(exception_info.value)
495+
496+
def test_no_raise_ansi_exception(self):
497+
non_ansi_exception = Exception("Exception message about some things that are not related to ANSI casting.")
498+
499+
result = raise_if_ansi_exception(non_ansi_exception, "Foo", "Bar")
500+
501+
assert result is None

0 commit comments

Comments
 (0)