Skip to content

Commit b54b90a

Browse files
2.2.0 fixes (#150)
Corrections for issues found on PR: #147 --------- Signed-off-by: Mike Fuller <mike@finops.org>
1 parent 8e606d3 commit b54b90a

2 files changed

Lines changed: 54 additions & 16 deletions

File tree

focus_validator/config_objects/focus_to_duckdb_converter.py

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,26 @@ def generateCheck(self) -> DuckDBColumnCheck:
12321232
)
12331233

12341234
schema = schema_entry["Schema"]
1235+
1236+
# Validate the schema up front so a malformed model schema fails fast as
1237+
# an InvalidRuleException instead of surfacing mid-run. If jsonschema is
1238+
# not installed, defer to the executor's clear RuntimeError at run time.
1239+
try:
1240+
from jsonschema import Draft202012Validator # type: ignore[import-untyped]
1241+
from jsonschema.exceptions import ( # type: ignore[import-untyped]
1242+
SchemaError,
1243+
)
1244+
except ModuleNotFoundError:
1245+
pass
1246+
else:
1247+
try:
1248+
Draft202012Validator.check_schema(schema)
1249+
except SchemaError as exc:
1250+
raise InvalidRuleException(
1251+
f"SchemaId '{schema_id}' referenced by rule {self.rule_id} "
1252+
f"has an invalid JSON schema: {exc.message}"
1253+
) from exc
1254+
12351255
path = getattr(self.params, "Path", "$")
12361256
col = self.params.ColumnName
12371257
where_clauses = [f"{col} IS NOT NULL"]
@@ -1251,11 +1271,9 @@ def _exec_json_schema(conn):
12511271
"CheckJSONSchema requires the 'jsonschema' package to be installed"
12521272
) from exc
12531273

1254-
Draft202012Validator.check_schema(schema)
12551274
validator = Draft202012Validator(schema)
12561275
table_name = getattr(self.params, "table_name", "focus_data")
12571276
sql = query.replace("{table_name}", table_name)
1258-
sql = sql.replace("{table_name}", table_name)
12591277
try:
12601278
rows = conn.execute(sql).fetchall()
12611279
except (duckdb.BinderException, duckdb.CatalogException) as exc:
@@ -1288,6 +1306,8 @@ def _exec_json_schema(conn):
12881306

12891307
failure_messages: list[str] = []
12901308
violations = 0
1309+
# row_num counts position within the filtered result set (non-null,
1310+
# row-condition-matching rows), not the source data row number.
12911311
for row_num, row in enumerate(rows, start=1):
12921312
raw_value = row[0] if isinstance(row, (tuple, list)) else row
12931313
try:
@@ -1298,7 +1318,9 @@ def _exec_json_schema(conn):
12981318
)
12991319
except Exception as exc:
13001320
violations += 1
1301-
failure_messages.append(f"row {row_num}: invalid JSON ({exc})")
1321+
failure_messages.append(
1322+
f"matching row {row_num}: invalid JSON ({exc})"
1323+
)
13021324
continue
13031325

13041326
instance = self._extract_path_value(payload, path)
@@ -1307,7 +1329,9 @@ def _exec_json_schema(conn):
13071329
)
13081330
if errors:
13091331
violations += 1
1310-
failure_messages.append(f"row {row_num}: {errors[0].message}")
1332+
failure_messages.append(
1333+
f"matching row {row_num}: {errors[0].message}"
1334+
)
13111335

13121336
ok = violations == 0
13131337
details = {
@@ -1918,23 +1942,34 @@ class CheckColumnComparisonGenerator(DuckDBCheckGenerator):
19181942

19191943
_VALID_COMPARATORS: ClassVar[Set[str]] = {"=", "!=", "<>", ">", ">=", "<", "<="}
19201944

1921-
def generateSql(self) -> SQLQuery:
1922-
col_a = self.params.ColumnAName
1923-
col_b = self.params.ColumnBName
1945+
def _validated_comparator(self) -> str:
19241946
comparator = self.params.Comparator
1925-
keyword = self._get_validation_keyword()
1926-
19271947
if comparator not in self._VALID_COMPARATORS:
19281948
raise InvalidRuleException(
19291949
f"Unsupported comparator for {self.rule_id}: {comparator}"
19301950
)
1951+
return comparator
1952+
1953+
def _violation_condition(self) -> str:
1954+
col_a = self.params.ColumnAName
1955+
col_b = self.params.ColumnBName
1956+
comparator = self._validated_comparator()
1957+
return (
1958+
f"{col_a} IS NOT NULL AND {col_b} IS NOT NULL "
1959+
f"AND NOT ({col_a} {comparator} {col_b})"
1960+
)
1961+
1962+
def generateSql(self) -> SQLQuery:
1963+
col_a = self.params.ColumnAName
1964+
col_b = self.params.ColumnBName
1965+
comparator = self._validated_comparator()
1966+
keyword = self._get_validation_keyword()
19311967

19321968
message = self.errorMessage or f"{col_a} {keyword} be {comparator} {col_b}."
19331969
msg_sql = message.replace("'", "''")
19341970

19351971
pass_predicate = f"{col_a} IS NOT NULL AND {col_b} IS NOT NULL AND {col_a} {comparator} {col_b}"
1936-
condition = f"{col_a} IS NOT NULL AND {col_b} IS NOT NULL AND NOT ({col_a} {comparator} {col_b})"
1937-
condition = self._apply_condition(condition)
1972+
condition = self._apply_condition(self._violation_condition())
19381973

19391974
requirement_sql = f"""
19401975
WITH invalid AS (
@@ -1956,9 +1991,7 @@ def generateSql(self) -> SQLQuery:
19561991
def get_sample_sql(self) -> str:
19571992
col_a = self.params.ColumnAName
19581993
col_b = self.params.ColumnBName
1959-
comparator = self.params.Comparator
1960-
condition = f"{col_a} IS NOT NULL AND {col_b} IS NOT NULL AND NOT ({col_a} {comparator} {col_b})"
1961-
condition = self._apply_condition(condition)
1994+
condition = self._apply_condition(self._violation_condition())
19621995

19631996
return f"""
19641997
SELECT {col_a}, {col_b}
@@ -2166,6 +2199,7 @@ def _exec_reference(_conn):
21662199
chk.special_executor = _exec_reference
21672200
chk.exec_mode = "reference"
21682201
chk.referenced_rule_id = target_id
2202+
chk.meta["special_executor_kind"] = "reference"
21692203
return chk
21702204

21712205

@@ -6451,6 +6485,7 @@ def _explain_check_sql(self, check) -> dict:
64516485
return {
64526486
"rule_id": rid,
64536487
"type": "special",
6488+
"special_kind": special_kind,
64546489
"check_type": ctype,
64556490
"generator": meta.get("generator"),
64566491
"row_condition_sql": meta.get("row_condition_sql"),
@@ -6541,7 +6576,7 @@ def print_sql_map(self, sql_map: dict):
65416576
print(
65426577
f"Composite: {info.get('aggregate')} with {len(info.get('children', []))} items"
65436578
)
6544-
elif t == "reference":
6579+
elif t == "special" and info.get("special_kind") == "reference":
65456580
print(f"Reference to: {info.get('referenced')}")
65466581
elif t == "skipped":
65476582
print(f"Skipped: {info.get('reason')}")

focus_validator/validator.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,10 @@ def print_sql_explanations(
324324
child_check = child.get("check_type", "")
325325

326326
# For reference type children, show the referenced rule instead of the parent rule_id
327-
if child_type == "reference":
327+
if (
328+
child_type == "special"
329+
and child.get("special_kind") == "reference"
330+
):
328331
referenced_id = child.get("referenced", child_id)
329332
if referenced_id and referenced_id != child_id:
330333
child_display_id = f"{child_id} -> {referenced_id}"

0 commit comments

Comments
 (0)