Skip to content

Commit c1166dc

Browse files
committed
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # src/databricks/labs/dqx/check_funcs.py
2 parents 050e048 + ebcc8cc commit c1166dc

11 files changed

Lines changed: 513 additions & 60 deletions

File tree

docs/dqx/docs/guide/quality_checks_definition.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ Checks defined using declarative syntax must contain the following fields:
342342
- `arguments`: keyword arguments passed to the check function. Every parameter without a default in the function’s Python signature must appear here.
343343
- `for_each_column`: (optional) list of column names or expressions for which the same check should be applied. When saving with `DQEngine.save_checks` to Delta or Lakebase tables, DQX stores such checks in compact format (one row per check, `for_each_column` preserved).
344344
- (optional) `name`: name of the check: autogenerated if not provided.
345-
- (optional) `filter`: spark expression to filter the rows for which the check is applied (e.g. `"business_unit = 'Finance'"`). The check function will run only on the rows matching the filter condition. The condition can reference any column of the validated dataset, not only the one where you apply the check function. When using dataset-level checks, the filter condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results.
345+
- (optional) `filter`: spark expression to filter the rows for which the check is applied (e.g. `"business_unit = 'Finance'"`). The check function will run only on the rows matching the filter condition. The condition can reference any column of the validated dataset, not only the one where you apply the check function. When using dataset-level checks, the filter condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results. Subqueries are allowed (e.g. `"customer_id IN (SELECT customer_id FROM main.ref.active_customers)"`), so you can scope a check using a reference table. A filter containing a destructive SQL keyword (e.g. `DELETE`, `DROP`, `INSERT`, `UPDATE`, `TRUNCATE`) is treated as invalid: that check is skipped (reported with `skipped = true` and a message identifying the unsafe filter) while the remaining checks still run.
346346
- (optional) `user_metadata`: key-value pairs added to the row-level warnings and errors
347347

348348
## YAML format (declarative approach)
@@ -699,7 +699,7 @@ The table used to store checks has the following structure. You can use `for_eac
699699
| └─ `function` | `string` | Name of the DQX check function to apply. |
700700
| └─ `for_each_column` | `array<string>` | (Optional) List of columns for which the same check applies. Stored in compact format (one row per check). Manual tables can use compact or expanded (individual rules instead of for_each_column) format. |
701701
| └─ `arguments` | `map<string, string>` | Keyword arguments for the check function. All parameters without a default must be present (or supplied via `for_each_column` for `column` / `columns`). |
702-
| `filter` | `string` | (Optional) Spark SQL expression to filter rows to which the check is applied, e.g. `"business_unit = 'Finance'"`. The check function will run only on the rows matching the filter condition. The condition can reference any column of the validated dataset, not only the one where you apply the check function. When using dataset-level checks, the filter condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results. |
702+
| `filter` | `string` | (Optional) Spark SQL expression to filter rows to which the check is applied, e.g. `"business_unit = 'Finance'"`. The check function will run only on the rows matching the filter condition. The condition can reference any column of the validated dataset, not only the one where you apply the check function. When using dataset-level checks, the filter condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results. Subqueries are allowed (e.g. `"customer_id IN (SELECT customer_id FROM main.ref.active_customers)"`), so you can scope a check using a reference table. A filter containing a destructive SQL keyword (e.g. `DELETE`, `DROP`, `INSERT`, `UPDATE`, `TRUNCATE`) is treated as invalid: that check is skipped (reported with `skipped = true` and a message identifying the unsafe filter) while the remaining checks still run.|
703703
| `run_config_name` | `string` | Name of the run config name. Could be any string such as input table or job name (use "default" if not provided). Useful for selecting applicable checks. |
704704
| `user_metadata` | `map<string, string>` | (Optional) Custom metadata to add to any row-level warnings or errors generated by the check. |
705705
| `created_at` | `timestamp` | Current local date and time in UTC when the rule set was saved. |

mcp-server/notebooks/setup.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868

6969
import logging
7070
import re
71+
import time
7172

7273
logging.basicConfig(level=logging.INFO)
7374
logger = logging.getLogger("dqx-mcp-setup")
@@ -148,20 +149,53 @@ def _validate_principal(value, label):
148149

149150
# Look up the app's service principal
150151
from databricks.sdk import WorkspaceClient
152+
from databricks.sdk.errors import NotFound
153+
154+
# App provisioning is asynchronous: `bundle deploy` registers the app, but the app entity and its
155+
# service principal become queryable via the Apps API only some time later. Under account-wide load
156+
# (e.g. many CI runs deploying apps concurrently) that window widens, so a single immediate
157+
# ws.apps.get() right after deploy can race the provisioner and raise NotFound ("App ... does not
158+
# exist or is deleted"). Poll with backoff until the app exists AND its service_principal_client_id
159+
# is populated, rather than failing the whole setup on a transient not-ready state.
160+
_APP_LOOKUP_TIMEOUT_SECONDS = 300
161+
_APP_LOOKUP_POLL_SECONDS = 10
162+
163+
164+
def _get_app_with_sp(ws: WorkspaceClient, name: str):
165+
"""Return the app once it exists and its service principal client id is populated.
166+
167+
Retries on NotFound and on a not-yet-assigned SP for up to _APP_LOOKUP_TIMEOUT_SECONDS to
168+
tolerate asynchronous app provisioning after `bundle deploy`.
169+
"""
170+
deadline = time.monotonic() + _APP_LOOKUP_TIMEOUT_SECONDS
171+
last_reason = "app not found"
172+
while True:
173+
try:
174+
app = ws.apps.get(name)
175+
if app.service_principal_client_id:
176+
return app
177+
last_reason = "service principal not yet assigned"
178+
except NotFound:
179+
last_reason = "app not found"
180+
if time.monotonic() >= deadline:
181+
raise TimeoutError(
182+
f"App '{name}' was not fully provisioned within {_APP_LOOKUP_TIMEOUT_SECONDS}s "
183+
f"({last_reason}). Ensure `bundle deploy`/`bundle run` for the app succeeded before setup."
184+
)
185+
logger.info(
186+
f"Waiting for app '{name}' to be provisioned ({last_reason}); retrying in {_APP_LOOKUP_POLL_SECONDS}s"
187+
)
188+
time.sleep(_APP_LOOKUP_POLL_SECONDS)
189+
151190

152191
ws = WorkspaceClient()
153-
app = ws.apps.get(app_name)
192+
app = _get_app_with_sp(ws, app_name)
154193

155194
# UC GRANTs use the SP's application id (client id), not its display_name or numeric SCIM id.
156195
# The Apps API returns it directly as service_principal_client_id — use that instead of a
157196
# ws.service_principals.get() SCIM lookup, which requires workspace-admin rights (the SCIM
158197
# GET /ServicePrincipals/{id} endpoint is admin-only) and would fail for a non-admin deployer.
159198
sp_principal = app.service_principal_client_id
160-
if not sp_principal:
161-
raise ValueError(
162-
f"App '{app_name}' has no service_principal_client_id — cannot resolve the app SP application id "
163-
"for UC grants. Ensure the app is fully deployed before running setup."
164-
)
165199
# Interpolated into the GRANT statements below, so validate it (backtick-breakout guard), same as
166200
# the runner SP application id.
167201
_validate_sp_id(sp_principal, "app service_principal_client_id")

src/databricks/labs/dqx/anomaly/scoring_utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from databricks.labs.dqx.anomaly.anomaly_info_schema import ai_explanation_struct_schema, anomaly_info_struct_schema
1414
from databricks.labs.dqx.anomaly.segment_utils import canonicalize_segment_values
1515
from databricks.labs.dqx.errors import InvalidParameterError
16+
from databricks.labs.dqx.utils import safe_filter_expr
1617
from databricks.labs.dqx.schema.dq_info_schema import (
1718
build_dq_info_struct,
1819
register_dq_info_field,
@@ -248,4 +249,4 @@ def apply_row_filter(df: DataFrame, row_filter: str | None) -> DataFrame:
248249
row_filter is a SQL expression (e.g. \"region = 'US'\"). Only these rows are run
249250
through anomaly detection; elsewhere we join results back so output has same row count.
250251
"""
251-
return df.filter(F.expr(row_filter)) if row_filter else df
252+
return df.filter(safe_filter_expr(row_filter)) if row_filter else df

src/databricks/labs/dqx/check_funcs.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from databricks.labs.dqx.utils import (
1919
get_column_name_or_alias,
2020
is_sql_query_safe,
21+
safe_filter_expr,
2122
normalize_col_str,
2223
get_columns_as_strings,
2324
to_lowercase,
@@ -430,6 +431,10 @@ def sql_expression(
430431
431432
Args:
432433
expression: SQL expression. Fail if expression evaluates to False, pass if it evaluates to True.
434+
Security note: this parameter accepts arbitrary SQL and is evaluated as-is, so it may
435+
include subqueries and run with the permissions of the process executing the checks.
436+
Only use check definitions from trusted sources, especially in automated or multi-tenant
437+
pipelines.
433438
msg: optional message of the *Column* type, automatically generated if None
434439
name: optional name of the resulting column, automatically generated if None
435440
negate: if the condition should be negated (true) or not. For example, "col is not null" will mark null
@@ -1246,8 +1251,10 @@ def apply(df: DataFrame) -> DataFrame:
12461251
f"Column '{col_expr_str}' must be of numeric type to perform outlier detection using MAD method, "
12471252
f"but got type '{column_type.simpleString()}' instead."
12481253
)
1249-
filter_condition = F.expr(row_filter) if row_filter else F.lit(True)
1250-
median, mad = _calculate_median_absolute_deviation(df, col_expr_str, row_filter)
1254+
# Validate and compile the filter (rejecting unsafe SQL); keep None when no filter is given so
1255+
# the MAD calculation stays a true no-op instead of filtering on F.lit(True).
1256+
filter_condition = safe_filter_expr(row_filter) if row_filter else None
1257+
median, mad = _calculate_median_absolute_deviation(df, col_expr_str, filter_condition)
12511258
if median is not None and mad is not None:
12521259
median = float(median)
12531260
mad = float(mad)
@@ -1258,9 +1265,11 @@ def apply(df: DataFrame) -> DataFrame:
12581265
upper_bound_expr = get_limit_expr(upper_bound)
12591266

12601267
condition = (col_expr < (lower_bound_expr)) | (col_expr > (upper_bound_expr))
1268+
# Restrict the flagged rows to the filtered subset when a filter is present.
1269+
outlier_condition = (filter_condition & condition) if filter_condition is not None else condition
12611270

12621271
# Add outlier detection columns
1263-
result_df = df.withColumn(condition_col, F.when(filter_condition & condition, True).otherwise(False))
1272+
result_df = df.withColumn(condition_col, F.when(outlier_condition, True).otherwise(False))
12641273
else:
12651274
# If median or mad could not be calculated, no outliers can be detected
12661275
result_df = df.withColumn(condition_col, F.lit(False))
@@ -1337,7 +1346,7 @@ def apply(df: DataFrame) -> DataFrame:
13371346

13381347
filter_condition = F.lit(True)
13391348
if row_filter:
1340-
filter_condition = filter_condition & F.expr(row_filter)
1349+
filter_condition = filter_condition & safe_filter_expr(row_filter)
13411350

13421351
if nulls_distinct:
13431352
# All columns must be non-null
@@ -1466,7 +1475,7 @@ def apply(df: DataFrame, spark: SparkSession, ref_dfs: dict[str, DataFrame]) ->
14661475
ref_alias = f"__ref_{col_str_norm}_{unique_str}"
14671476
ref_df_distinct = ref_df.select(ref_col_expr.alias(ref_alias)).distinct()
14681477

1469-
filter_expr = F.expr(row_filter) if row_filter else F.lit(True)
1478+
filter_expr = safe_filter_expr(row_filter)
14701479

14711480
# col_expr.isNotNull() only filters rows in the single-column non-null-safe path;
14721481
# when col_expr is a struct (composite keys or null_safe=True), the struct is never NULL
@@ -1583,7 +1592,7 @@ def _replace_template(sql: str, replacements: dict[str, str]) -> str:
15831592
def apply(df: DataFrame, spark: SparkSession, ref_dfs: dict[str, DataFrame]) -> DataFrame:
15841593
filtered_df = df
15851594
if row_filter:
1586-
filtered_df = df.filter(F.expr(row_filter))
1595+
filtered_df = df.filter(safe_filter_expr(row_filter))
15871596

15881597
# since the check could be applied multiple times, the views created here must be unique
15891598
filtered_df.createOrReplaceTempView(unique_input_view)
@@ -1956,8 +1965,7 @@ def apply(df: DataFrame) -> DataFrame:
19561965
f"but got type '{time_col_type.simpleString()}' instead."
19571966
)
19581967

1959-
filter_col = F.expr(row_filter) if row_filter else F.lit(True)
1960-
filtered_expr = F.when(filter_col, aggr_col_expr) if row_filter else aggr_col_expr
1968+
filtered_expr = F.when(safe_filter_expr(row_filter), aggr_col_expr) if row_filter else aggr_col_expr
19611969
aggr_expr = _build_aggregate_expression(aggr_type, filtered_expr, aggr_params)
19621970

19631971
group_cols = [F.col(c) if isinstance(c, str) else c for c in (group_by or [])]
@@ -2358,7 +2366,7 @@ def apply(df: DataFrame, spark: SparkSession, ref_dfs: dict[str, DataFrame]) ->
23582366
skipped_columns = [col for col in df.columns if col not in compare_columns and col not in pk_column_names]
23592367

23602368
# apply filter before aliasing to avoid ambiguity
2361-
df = df.withColumn(filter_col, F.expr(row_filter) if row_filter else F.lit(True))
2369+
df = df.withColumn(filter_col, safe_filter_expr(row_filter))
23622370

23632371
df = df.alias("df")
23642372
ref_df = ref_df.alias("ref_df")
@@ -2465,7 +2473,7 @@ def apply(df: DataFrame) -> DataFrame:
24652473
# Build filter condition
24662474
filter_condition = F.lit(True)
24672475
if row_filter:
2468-
filter_condition = filter_condition & F.expr(row_filter)
2476+
filter_condition = filter_condition & safe_filter_expr(row_filter)
24692477

24702478
# Limit checking to be within the lookback window if needed
24712479
if lookback_windows is not None:
@@ -3584,14 +3592,14 @@ def apply(df: DataFrame) -> DataFrame:
35843592
Returns:
35853593
The DataFrame with additional condition and metric columns for aggregation validation.
35863594
"""
3587-
filter_col = F.expr(row_filter) if row_filter else F.lit(True)
35883595
if row_filter:
35893596
# aggr_col_str == "*" only for count(*) over all rows (the only valid use of column="*").
35903597
# F.expr("*") can't be embedded as the THEN value of a CASE WHEN: Spark's star-expansion
35913598
# resolves it against every column in scope instead of treating it as a placeholder value.
35923599
# count() only cares about nullness, so a non-null literal is a safe stand-in.
3600+
# safe_filter_expr rejects unsafe SQL in the filter (see #1303).
35933601
then_expr = F.lit(1) if aggr_col_str == "*" else aggr_col_expr
3594-
filtered_expr = F.when(filter_col, then_expr)
3602+
filtered_expr = F.when(safe_filter_expr(row_filter), then_expr)
35953603
else:
35963604
filtered_expr = aggr_col_expr
35973605

@@ -4101,7 +4109,7 @@ def _apply_dataset_level_sql_check(
41014109
# - If row_filter is provided, only rows matching the filter get the condition value
41024110
# - Rows not matching the filter get None (consistent with row-level checks)
41034111
if row_filter:
4104-
filter_expr = F.expr(row_filter)
4112+
filter_expr = safe_filter_expr(row_filter)
41054113
result_df = df.withColumn(
41064114
unique_condition_column, F.when(filter_expr, F.lit(condition_value)).otherwise(F.lit(None))
41074115
)
@@ -4143,7 +4151,9 @@ def _validate_sql_query_params(query: str, merge_columns: list[str] | None) -> N
41434151
raise InvalidParameterError("'merge_columns' entries must be non-empty strings.")
41444152

41454153

4146-
def _calculate_median_absolute_deviation(df: DataFrame, column: str, filter_condition: str | None) -> tuple[Any, Any]:
4154+
def _calculate_median_absolute_deviation(
4155+
df: DataFrame, column: str, filter_condition: Column | None
4156+
) -> tuple[Any, Any]:
41474157
"""
41484158
Calculate the Median Absolute Deviation (MAD) for a numeric column.
41494159

src/databricks/labs/dqx/geo/check_funcs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from databricks.labs.dqx.rule import register_rule, requires_dbr_version
1010
from databricks.labs.dqx.check_funcs import make_condition, get_normalized_column_and_expr, get_limit_expr
1111
from databricks.labs.dqx.errors import InvalidParameterError
12+
from databricks.labs.dqx.utils import safe_filter_expr
1213

1314
POINT_TYPE = "ST_Point"
1415
LINESTRING_TYPE = "ST_LineString"
@@ -939,7 +940,7 @@ def apply(df: DataFrame) -> DataFrame:
939940
df_with_hash = df.withColumn(row_id_col, F.xxhash64(F.struct("*")))
940941

941942
# Apply filter after hashing (so hash is available for final join to preserve all rows)
942-
filtered_df = df_with_hash.filter(F.expr(row_filter)) if row_filter is not None else df_with_hash
943+
filtered_df = df_with_hash.filter(safe_filter_expr(row_filter)) if row_filter is not None else df_with_hash
943944

944945
duplicates_df = filtered_df.groupBy(row_id_col).count().filter("count > 1").select(row_id_col)
945946

0 commit comments

Comments
 (0)