Skip to content

Commit 8aa3db3

Browse files
committed
Support multi-condition structured filters in DatasetHandle
Enhanced the DatasetHandle.apply_structured_filter method to support multiple filter conditions (AND logic) via a 'conditions' list in the filter payload. Refactored filter evaluation logic into helper methods for casting and condition evaluation, added logging for filter operations, and improved robustness for both single and multi-condition filters.
1 parent e993e8f commit 8aa3db3

1 file changed

Lines changed: 164 additions & 68 deletions

File tree

vdweb/core.py

Lines changed: 164 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import logging
1112
import collections
1213
import math
1314
import threading
@@ -18,6 +19,8 @@
1819

1920
import visidata
2021

22+
logger = logging.getLogger(__name__)
23+
2124

2225
@dataclass
2326
class 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

Comments
 (0)