88
99from __future__ import annotations
1010
11+ import logging
1112import collections
1213import math
1314import threading
1819
1920import visidata
2021
22+ logger = logging .getLogger (__name__ )
23+
2124
2225@dataclass
2326class ColumnInfo :
@@ -257,21 +260,17 @@ def apply_structured_filter(self, filter_payload: dict[str, Any] | None) -> None
257260 Apply a structured filter to the dataset.
258261
259262 Args:
260- filter_payload: Dict with 'column', 'operator', 'value'
263+ filter_payload: Dict with either:
264+ - Single condition: {'column', 'operator', 'value'}
265+ - Multiple conditions: {'type': 'basic', 'conditions': [...]}
261266 """
262267 with self ._lock :
268+ logger .info (f"Applying structured filter: { filter_payload } " )
263269 # Reset if payload is None or "reset"
264270 if not filter_payload or filter_payload == "reset" :
265271 self .clear_filter ()
266272 return
267273
268- column_name = filter_payload .get ("column" )
269- operator = filter_payload .get ("operator" )
270- value = filter_payload .get ("value" )
271-
272- if not column_name or not operator :
273- return
274-
275274 # Store original rows if this is the first operation
276275 if self ._original_rows is None :
277276 self ._original_rows = self .sheet .rows [:]
@@ -283,75 +282,172 @@ def apply_structured_filter(self, filter_payload: dict[str, Any] | None) -> None
283282 if hasattr (self .sheet , 'selected' ):
284283 self .sheet .selected = []
285284
286- col = next ((c for c in self .sheet .columns if c .name == column_name ), None )
287- if col is None :
288- raise ValueError (f"Column '{ column_name } ' not found" )
289-
290- # Determine target type for casting
291- col_type_name = _get_type_name (col .type )
292- is_numeric = col_type_name in ('float' , 'integer' , 'currency' )
293-
294- # Helper to cast value safely
295- def safe_cast (val , to_type ):
296- try :
297- if to_type == 'float' :
298- return float (val )
299- elif to_type == 'int' :
300- return int (val )
301- elif to_type == 'str' :
302- return str (val )
303- return val
304- except (ValueError , TypeError ):
305- return val
306-
307- # Cast the filter value
308- target_val = safe_cast (value , 'float' if is_numeric else 'str' )
309-
310- rows_to_select = []
311- for row in self .sheet .rows :
312- try :
313- cell_val = col .getTypedValue (row )
285+ # Check if this is a multi-condition filter
286+ conditions = filter_payload .get ("conditions" )
287+ if conditions and isinstance (conditions , list ):
288+ # Multi-condition filter (AND logic)
289+ rows_to_select = []
290+
291+ # Preprocess all conditions
292+ condition_data = []
293+ for cond in conditions :
294+ column_name = cond .get ("column" )
295+ operator = cond .get ("operator" )
296+ value = cond .get ("value" )
314297
315- # Handle None/Empty
316- if cell_val is None :
317- if operator == 'is_empty' :
318- rows_to_select .append (row )
298+ if not column_name or not operator :
319299 continue
320-
321- # Comparison Logic
322- match = False
323- if operator == 'eq' :
324- match = cell_val == target_val
325- elif operator == 'neq' :
326- match = cell_val != target_val
327- elif operator == 'gt' :
328- if is_numeric and isinstance (cell_val , (int , float )):
329- match = cell_val > target_val
330- elif operator == 'lt' :
331- if is_numeric and isinstance (cell_val , (int , float )):
332- match = cell_val < target_val
333- elif operator == 'contains' :
334- match = str (target_val ).lower () in str (cell_val ).lower ()
335- elif operator == 'is_empty' :
336- match = cell_val == ""
337-
338- if match :
300+
301+ col = next ((c for c in self .sheet .columns if c .name == column_name ), None )
302+ if col is None :
303+ continue
304+
305+ col_type_name = _get_type_name (col .type )
306+ is_numeric = col_type_name in ('float' , 'integer' , 'currency' )
307+
308+ # Cast value
309+ target_val = self ._safe_cast (value , 'float' if is_numeric else 'str' )
310+
311+ logger .info (f"Filter condition: col={ column_name } , op={ operator } , val={ value } -> target={ target_val } (numeric={ is_numeric } )" )
312+
313+ condition_data .append ({
314+ 'col' : col ,
315+ 'operator' : operator ,
316+ 'target_val' : target_val ,
317+ 'is_numeric' : is_numeric
318+ })
319+
320+ # Apply all conditions (AND logic)
321+ for row in self .sheet .rows :
322+ all_match = True
323+ for cond_info in condition_data :
324+ try :
325+ cell_val = cond_info ['col' ].getTypedValue (row )
326+
327+ # Handle None/Empty
328+ if cell_val is None :
329+ if cond_info ['operator' ] == 'is_empty' :
330+ continue # This condition passed
331+ else :
332+ all_match = False
333+ break
334+
335+ # Check this condition
336+ match = self ._evaluate_condition (
337+ cell_val ,
338+ cond_info ['operator' ],
339+ cond_info ['target_val' ],
340+ cond_info ['is_numeric' ]
341+ )
342+
343+ if not match :
344+ all_match = False
345+ break
346+ except Exception :
347+ all_match = False
348+ break
349+
350+ if all_match :
339351 rows_to_select .append (row )
352+
353+ self .sheet .rows = rows_to_select
354+ self ._current_filter = ("multiple" , f"{ len (conditions )} conditions" )
355+
356+ else :
357+ # Single condition filter (legacy)
358+ column_name = filter_payload .get ("column" )
359+ operator = filter_payload .get ("operator" )
360+ value = filter_payload .get ("value" )
340361
341- except Exception :
342- continue
362+ if not column_name or not operator :
363+ return
343364
344- # Use VisiData API to select rows
345- if hasattr (self .sheet , 'select' ):
346- self .sheet .select (rows_to_select )
347-
348- # Filter to show only selected (equivalent to sheet.only_selected())
349- self .sheet .rows = rows_to_select
365+ col = next ((c for c in self .sheet .columns if c .name == column_name ), None )
366+ if col is None :
367+ raise ValueError (f"Column '{ column_name } ' not found" )
368+
369+ # Determine target type for casting
370+ col_type_name = _get_type_name (col .type )
371+ is_numeric = col_type_name in ('float' , 'integer' , 'currency' )
372+
373+ # Cast the filter value
374+ target_val = self ._safe_cast (value , 'float' if is_numeric else 'str' )
375+
376+ rows_to_select = []
377+ for row in self .sheet .rows :
378+ try :
379+ cell_val = col .getTypedValue (row )
380+
381+ # Handle None/Empty
382+ if cell_val is None :
383+ if operator == 'is_empty' :
384+ rows_to_select .append (row )
385+ continue
386+
387+ # Check condition
388+ match = self ._evaluate_condition (cell_val , operator , target_val , is_numeric )
389+ if match :
390+ rows_to_select .append (row )
391+
392+ except Exception :
393+ continue
394+
395+ # Use VisiData API to select rows
396+ if hasattr (self .sheet , 'select' ):
397+ self .sheet .select (rows_to_select )
398+
399+ # Filter to show only selected
400+ self .sheet .rows = rows_to_select
401+
402+ self ._current_filter = (column_name , f"{ operator } { value } " )
350403
351- self ._current_filter = (column_name , f"{ operator } { value } " )
352404 self ._stats_cache .clear ()
353405 self ._sample_rows = None
354406
407+ def _safe_cast (self , val , to_type ):
408+ """Helper to cast value safely."""
409+ try :
410+ if to_type == 'float' :
411+ return float (val )
412+ elif to_type == 'int' :
413+ return int (val )
414+ elif to_type == 'str' :
415+ return str (val )
416+ return val
417+ except (ValueError , TypeError ):
418+ return val
419+
420+ def _evaluate_condition (self , cell_val , operator , target_val , is_numeric ):
421+ """Evaluate a single filter condition."""
422+ try :
423+ if operator == 'eq' :
424+ return cell_val == target_val
425+ elif operator == 'neq' :
426+ return cell_val != target_val
427+ elif operator == 'gt' :
428+ if is_numeric and isinstance (cell_val , (int , float )) and isinstance (target_val , (int , float )):
429+ return cell_val > target_val
430+ if not is_numeric and isinstance (cell_val , str ) and isinstance (target_val , str ):
431+ return cell_val > target_val
432+ return False
433+ elif operator == 'lt' :
434+ if is_numeric and isinstance (cell_val , (int , float )) and isinstance (target_val , (int , float )):
435+ return cell_val < target_val
436+ if not is_numeric and isinstance (cell_val , str ) and isinstance (target_val , str ):
437+ return cell_val < target_val
438+ return False
439+ elif operator == 'contains' :
440+ return str (target_val ).lower () in str (cell_val ).lower ()
441+ elif operator == 'regex_match' :
442+ import re
443+ return bool (re .search (str (target_val ), str (cell_val ), re .IGNORECASE ))
444+ elif operator == 'is_empty' :
445+ return cell_val == "" or cell_val is None
446+ return False
447+ except Exception as e :
448+ logger .warning (f"Error evaluating condition: { e } " )
449+ return False
450+
355451 def clear_filter (self ) -> None :
356452 """
357453 Clear all filters and restore original rows.
0 commit comments