5858
5959import json
6060import logging
61+ from collections .abc import Mapping
6162from typing import Any
6263
6364from databricks .labs .dqx .errors import UnsafeSqlQueryError
@@ -373,8 +374,55 @@ def materialize_binding(self, binding_id: str) -> list[str]:
373374 self ._cleanup_orphans (applied_ids = applied_ids , written_ids = written_ids )
374375 return sorted (written_ids )
375376
377+ def _resolve_registry (
378+ self ,
379+ applied : AppliedRule ,
380+ * ,
381+ rules : Mapping [str , RegistryRule ] | None = None ,
382+ versions : Mapping [tuple [str , int ], RuleVersion ] | None = None ,
383+ ) -> tuple [RegistryRule , int , RuleVersion ] | None :
384+ """Resolve an applied rule to its ``(registry_rule, version_number, snapshot)``.
385+
386+ This is the ONLY place the ``get_rule`` + ``get_version`` OLTP lookups
387+ happen for a render. When *rules* / *versions* preloaded maps are
388+ supplied (batch path), they are consulted instead of the per-id
389+ ``RegistryService`` calls — so a batch caller pays two grouped queries
390+ for N applications rather than ``2N`` sequential round-trips. The
391+ resolution logic (missing rule, unpublished version, missing snapshot)
392+ is identical either way, so single- and batch-path renders stay
393+ byte-identical.
394+
395+ Returns ``None`` (with the same warning logs as before) when the applied
396+ rule can't be resolved at all.
397+ """
398+ registry_rule = rules .get (applied .rule_id ) if rules is not None else self ._registry .get_rule (applied .rule_id )
399+ if registry_rule is None or not applied .id :
400+ logger .warning ("Skipping applied rule %s: registry rule %s not found" , applied .id , applied .rule_id )
401+ return None
402+
403+ version_number = applied .pinned_version or registry_rule .version
404+ if version_number <= 0 :
405+ logger .warning ("Skipping applied rule %s: rule %s has no published version" , applied .id , applied .rule_id )
406+ return None
407+
408+ if versions is not None :
409+ version_snapshot = versions .get ((applied .rule_id , version_number ))
410+ else :
411+ version_snapshot = self ._registry .get_version (applied .rule_id , version_number )
412+ if version_snapshot is None :
413+ logger .warning (
414+ "Skipping applied rule %s: version %d of rule %s not found" , applied .id , version_number , applied .rule_id
415+ )
416+ return None
417+ return registry_rule , version_number , version_snapshot
418+
376419 def _iter_rendered_checks (
377- self , table_fqn : str , applied : AppliedRule
420+ self ,
421+ table_fqn : str ,
422+ applied : AppliedRule ,
423+ * ,
424+ rules : Mapping [str , RegistryRule ] | None = None ,
425+ versions : Mapping [tuple [str , int ], RuleVersion ] | None = None ,
378426 ) -> list [tuple [str , str , dict [str , Any ]]] | None :
379427 """Resolve + render an applied rule's mapping groups WITHOUT writing anything.
380428
@@ -393,24 +441,20 @@ def _iter_rendered_checks(
393441 order — possibly EMPTY when every group failed to render, which the
394442 materializer treats as "this application now renders no rows" and
395443 cleans up accordingly.
396- """
397- registry_rule = self ._registry .get_rule (applied .rule_id )
398- if registry_rule is None or not applied .id :
399- logger .warning ("Skipping applied rule %s: registry rule %s not found" , applied .id , applied .rule_id )
400- return None
401-
402- version_number = applied .pinned_version or registry_rule .version
403- if version_number <= 0 :
404- logger .warning ("Skipping applied rule %s: rule %s has no published version" , applied .id , applied .rule_id )
405- return None
406444
407- version_snapshot = self ._registry .get_version (applied .rule_id , version_number )
408- if version_snapshot is None :
409- logger .warning (
410- "Skipping applied rule %s: version %d of rule %s not found" , applied .id , version_number , applied .rule_id
411- )
445+ Registry resolution (the ``get_rule`` + ``get_version`` round-trips) is
446+ delegated to :meth:`_resolve_registry`; passing a preloaded
447+ *rules*/*versions* map lets a batch caller resolve every application's
448+ registry rows in two grouped queries and share them here without any
449+ per-application round-trip (see :meth:`render_binding_checks_many`).
450+ """
451+ resolved = self ._resolve_registry (applied , rules = rules , versions = versions )
452+ if resolved is None :
412453 return None
413-
454+ registry_rule , version_number , version_snapshot = resolved
455+ # ``_resolve_registry`` already rejected a falsy ``applied.id`` (returning
456+ # None), so it is a real str here — narrow it for the type checker.
457+ applied_id = applied .id or ""
414458 effective_severity = (
415459 applied .severity_override or get_rule_severity (version_snapshot .user_metadata ) or _DEFAULT_SEVERITY
416460 )
@@ -424,7 +468,7 @@ def _iter_rendered_checks(
424468 rendered_mode = version_snapshot .mode or registry_rule .mode
425469 rendered : list [tuple [str , str , dict [str , Any ]]] = []
426470 for idx , group in enumerate (applied .column_mapping ):
427- row_id = f"{ applied . id } -{ idx } "
471+ row_id = f"{ applied_id } -{ idx } "
428472 try :
429473 check , is_tableless = render_check (
430474 mode = rendered_mode ,
@@ -434,7 +478,7 @@ def _iter_rendered_checks(
434478 per_application_tags = applied .user_metadata ,
435479 registry_rule_id = applied .rule_id ,
436480 registry_version = version_number ,
437- applied_rule_id = applied . id ,
481+ applied_rule_id = applied_id ,
438482 app_settings = self ._app_settings ,
439483 )
440484 except (ValueError , UnsafeSqlQueryError ):
@@ -689,6 +733,68 @@ def render_binding_checks(self, binding_id: str) -> list[dict[str, Any]]:
689733 checks .extend (check for _row_id , _row_fqn , check in rendered )
690734 return checks
691735
736+ def render_binding_checks_counts_many (self , bindings : list [tuple [str , str ]]) -> dict [str , int ]:
737+ """Batched draft-render CHECK COUNT for many *(binding_id, table_fqn)* pairs.
738+
739+ The bounded-query counterpart of calling :meth:`render_binding_checks`
740+ in a loop: it produces the SAME per-binding count (the number of checks
741+ a draft run would render) but resolves the registry rows for EVERY
742+ binding's applications in a constant handful of grouped queries instead
743+ of ``~3N + 2·ΣR`` sequential round-trips (N bindings, R applied rules
744+ each):
745+
746+ 1. ONE grouped ``dq_applied_rules`` query for all bindings' applications
747+ (:meth:`MonitoredTableService.list_applied_rules_many`);
748+ 2. ONE ``dq_rules`` ``IN (...)`` query for the distinct rule ids
749+ (:meth:`RegistryService.get_rules_many`);
750+ 3. ONE ``dq_rule_versions`` predicate-OR query for the distinct
751+ ``(rule_id, version)`` pairs (:meth:`RegistryService.get_versions_many`).
752+
753+ Rendering then runs purely in-memory per binding through the SAME
754+ :meth:`_iter_rendered_checks` path — so each count is byte-identical to
755+ what the per-binding :meth:`render_binding_checks` would return. A
756+ binding whose applications all fail to resolve counts 0. Bindings not
757+ present in *bindings* are absent from the result; an empty input issues
758+ no query and returns ``{{}}``.
759+ """
760+ if not bindings :
761+ return {}
762+ binding_fqns = {binding_id : table_fqn for binding_id , table_fqn in bindings }
763+ applied_by_binding = self ._monitored_tables .list_applied_rules_many (list (binding_fqns ))
764+
765+ rule_ids : set [str ] = set ()
766+ for applied_rules in applied_by_binding .values ():
767+ for applied in applied_rules :
768+ if applied .id :
769+ rule_ids .add (applied .rule_id )
770+ rules = self ._registry .get_rules_many (rule_ids )
771+
772+ version_pairs : set [tuple [str , int ]] = set ()
773+ for applied_rules in applied_by_binding .values ():
774+ for applied in applied_rules :
775+ if not applied .id :
776+ continue
777+ registry_rule = rules .get (applied .rule_id )
778+ if registry_rule is None :
779+ continue
780+ version_number = applied .pinned_version or registry_rule .version
781+ if version_number > 0 :
782+ version_pairs .add ((applied .rule_id , version_number ))
783+ versions = self ._registry .get_versions_many (version_pairs )
784+
785+ counts : dict [str , int ] = {}
786+ for binding_id , table_fqn in binding_fqns .items ():
787+ count = 0
788+ for applied in applied_by_binding .get (binding_id , []):
789+ if not applied .id :
790+ continue
791+ rendered = self ._iter_rendered_checks (table_fqn , applied , rules = rules , versions = versions )
792+ if rendered is None :
793+ continue
794+ count += len (rendered )
795+ counts [binding_id ] = count
796+ return counts
797+
692798 def rematerialize_for_rule (self , rule_id : str ) -> list [str ]:
693799 """Re-materialize every binding with a FOLLOWING (unpinned) application of *rule_id*.
694800
0 commit comments