@@ -63,29 +63,46 @@ def uri_to_display_name(uri: str) -> str:
6363
6464
6565def validate_cross_references (viz : CreatedVisualization ) -> tuple [bool , list [str ]]:
66- """Validate ranking-filter `using`/`attribute` resolve to correct URI prefixes."""
66+ """Validate ranking-filter `using`/`attribute` resolve to correct URI prefixes.
67+
68+ Always returns `(ok, errors)` — a malformed filter produces an error entry, never an
69+ exception. Anything unusable (None, empty, non-string) used to reach `.startswith()`
70+ or `dict.get()` and blow up with AttributeError/TypeError mid-evaluation.
71+
72+ `using` is required by the AAC schema, `attribute` is optional (see
73+ `_normalize_ranking_filter`), so an absent/None/empty `attribute` is accepted silently.
74+ """
6775 errors : list [str ] = []
6876 fields = viz .query .fields
6977 for filter_key , filter_dict in viz .query .filter_by .items ():
7078 if filter_dict .get ("type" ) != "ranking_filter" :
7179 continue
72- using_val = filter_dict .get ("using" , "" )
73- using_uri = _resolve_alias_to_uri (using_val , fields )
74- field_def = fields .get (using_val )
75- is_adhoc_agg = isinstance (field_def , AacQueryField ) and bool (field_def .aggregation )
76- if not using_uri .startswith (("metric/" , "fact/" )) and not is_adhoc_agg :
77- errors .append (
78- f"ranking filter '{ filter_key } ': using='{ using_val } ' "
79- f"resolves to '{ using_uri } ' — expected a metric/ or fact/ URI"
80- )
81- if "attribute" in filter_dict :
82- attr_val = filter_dict ["attribute" ]
83- attr_uri = _resolve_alias_to_uri (attr_val , fields )
84- if not attr_uri .startswith (("label/" , "attribute/" )):
80+ using_val = filter_dict .get ("using" )
81+ if not isinstance (using_val , str ) or not using_val :
82+ errors .append (f"ranking filter '{ filter_key } ': using={ using_val !r} — a metric/ or fact/ URI is required" )
83+ else :
84+ using_uri = _resolve_alias_to_uri (using_val , fields )
85+ field_def = fields .get (using_val )
86+ is_adhoc_agg = isinstance (field_def , AacQueryField ) and bool (field_def .aggregation )
87+ if not using_uri .startswith (("metric/" , "fact/" )) and not is_adhoc_agg :
8588 errors .append (
86- f"ranking filter '{ filter_key } ': attribute ='{ attr_val } ' "
87- f"resolves to '{ attr_uri } ' — expected a label / or attribute / URI"
89+ f"ranking filter '{ filter_key } ': using ='{ using_val } ' "
90+ f"resolves to '{ using_uri } ' — expected a metric / or fact / URI"
8891 )
92+ attr_val = filter_dict .get ("attribute" )
93+ if attr_val is None or attr_val == "" :
94+ continue
95+ if not isinstance (attr_val , str ):
96+ errors .append (
97+ f"ranking filter '{ filter_key } ': attribute={ attr_val !r} — expected a label/ or attribute/ URI"
98+ )
99+ continue
100+ attr_uri = _resolve_alias_to_uri (attr_val , fields )
101+ if not attr_uri .startswith (("label/" , "attribute/" )):
102+ errors .append (
103+ f"ranking filter '{ filter_key } ': attribute='{ attr_val } ' "
104+ f"resolves to '{ attr_uri } ' — expected a label/ or attribute/ URI"
105+ )
89106 return len (errors ) == 0 , errors
90107
91108
@@ -99,11 +116,43 @@ def _normalize_date_filter(filter_dict: dict, _fields: dict) -> dict:
99116 }
100117
101118
102- def _normalize_ranking_filter (filter_dict : dict , fields : dict [str , AacQueryField | str ]) -> dict :
119+ def _sole_dimension_uri (viz : CreatedVisualization ) -> str | None :
120+ """URI of the visualization's only dimension, or None when it has zero or several."""
121+ dim_uris = get_dimension_uri_set (viz )
122+ return next (iter (dim_uris )) if len (dim_uris ) == 1 else None
123+
124+
125+ def _normalize_ranking_filter (
126+ filter_dict : dict ,
127+ fields : dict [str , AacQueryField | str ],
128+ sole_dim_uri : str | None = None ,
129+ ) -> dict :
130+ """Canonicalize a ranking filter so equivalent filters compare equal.
131+
132+ `attribute` is optional in the AAC schema (gen-ai models it as `NotRequired[str]` /
133+ `str | None`), and when it is omitted AFM ranks over every dimension of the result. For a
134+ single-dimension visualization that is exactly "rank by that one dimension", so an omitted
135+ attribute is filled in with `sole_dim_uri` instead of comparing as an empty string — the
136+ agent and the dataset may legitimately express the same filter either way.
137+
138+ The substitution is deliberately gated on there being exactly ONE dimension: with two or
139+ more, omitting `attribute` ranks over the dimension *tuple*, which is a different filter,
140+ so those stay strict. Callers pass the sole dimension of the visualization the filter
141+ belongs to, which makes the comparison symmetric — it does not matter which side omitted it.
142+
143+ Missing, None and "" are all treated as "not specified"; so is a non-string, which
144+ `validate_cross_references` reports separately rather than crashing the comparison.
145+ """
146+ attr_val = filter_dict .get ("attribute" )
147+ if not isinstance (attr_val , str ) or not attr_val :
148+ dim_uri = sole_dim_uri or ""
149+ else :
150+ dim_uri = _resolve_alias_to_uri (attr_val , fields )
151+ using_val = filter_dict .get ("using" )
103152 entry : dict = {
104153 "type" : "ranking_filter" ,
105- "metric_uri" : _resolve_alias_to_uri (filter_dict . get ( "using" , "" ), fields ) ,
106- "dim_uri" : _resolve_alias_to_uri ( filter_dict . get ( "attribute" , "" ), fields ) ,
154+ "metric_uri" : _resolve_alias_to_uri (using_val , fields ) if isinstance ( using_val , str ) else "" ,
155+ "dim_uri" : dim_uri ,
107156 }
108157 if "top" in filter_dict :
109158 entry ["top" ] = filter_dict ["top" ]
@@ -127,12 +176,13 @@ def _split_and_normalize_filters(viz: CreatedVisualization) -> tuple[set[str], s
127176 ranking_set : set [str ] = set ()
128177 attr_set : set [str ] = set ()
129178 fields = viz .query .fields
179+ sole_dim_uri = _sole_dimension_uri (viz )
130180 for filter_dict in viz .query .filter_by .values ():
131181 ft = filter_dict .get ("type" )
132182 if ft == "date_filter" :
133183 date_set .add (json .dumps (_normalize_date_filter (filter_dict , fields ), sort_keys = True ))
134184 elif ft == "ranking_filter" :
135- ranking_set .add (json .dumps (_normalize_ranking_filter (filter_dict , fields ), sort_keys = True ))
185+ ranking_set .add (json .dumps (_normalize_ranking_filter (filter_dict , fields , sole_dim_uri ), sort_keys = True ))
136186 elif ft == "attribute_filter" :
137187 attr_set .add (json .dumps (_normalize_attribute_filter (filter_dict , fields ), sort_keys = True ))
138188 return date_set , ranking_set , attr_set
0 commit comments