Repository: biolab/orange3

Issue:
Select Rows: Add 'any' as variable option
<!--
This is an issue template. Please fill in the relevant details in the
sections below.
-->

##### Orange version
<!-- From menu _Help→About→Version_ or code `Orange.version.full_version` -->
3.5.dev

##### Desired behavior
Filter out instances that contain a specified value in ANY of the variables.


##### Additional info (worksheets, data, screenshots, ...)
Rationale: social sciences data include 'indifferent' or 'didn't respond' as value in any of the attributes. Provide the option to filter out all such instances at once and retain only 'full' tables.


[data.filter.IsDefined] Casting error when filtering on meta variable in object array
##### Orange version
3.17.0


##### Expected behavior
`Orange.data.filter.IsDefined` filter gracefully handles the case where the metas array is an object array.

The SelectRows widget seems to do this by not actually using the generic `Orange.data.filter.IsDefined` code path.


##### Actual behavior
Crashes, see backtrace below.


##### Steps to reproduce the behavior
Using "Bone Healing" dataset, use `Orange.data.filter.IsDefined` as follows in a Python Script window:

````
import Orange

var = in_data.domain.metas[0]
def_filter = Orange.data.filter.IsDefined(columns=[var], negate=True)
def_filter(in_data)
````


##### Additional info (worksheets, data, screenshots, ...)
Backtrace:
````
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "<string>", line 10, in <module>
  File "C:\Users\reads\AppData\Local\Continuum\miniconda3\envs\orange-spectroscopy\lib\site-packages\Orange\data\filter.py", line 64, in __call__
    return data._filter_is_defined(self.columns, self.negate)
  File "C:\Users\reads\AppData\Local\Continuum\miniconda3\envs\orange-spectroscopy\lib\site-packages\Orange\data\table.py", line 1141, in _filter_is_defined
    remove = np.logical_or(remove, bn.anynan([col], axis=0))
  File "C:\Users\reads\AppData\Local\Continuum\miniconda3\envs\orange-spectroscopy\lib\site-packages\bottleneck\slow\reduce.py", line 86, in anynan
    return np.isnan(a).any(axis)
TypeError: ufunc 'isnan' not supported for the input types, and the inputs could not be safely coerced to any supported types according to the casting rule ''safe''
````

May be related to numpy gh-9009 as bottleneck uses the "slow" numpy code for this operation.


Discussion:
@ajdapretnar, I suppose this "any variable" should behave differently for different types.

The first combo contains an attribute name. We should probably add "Any numeric variable", "Any categorical variable" and "Any string variable". This is a bit long, though. Is it OK if we omit "variable"?

The second defines the operator. It would have the same operators as it has for the corresponding variable type, right?

The third one is a bit complicated for categorical variables. Does it contain the values that appear in all variables? Or in any (which may be a long list!)? Or do we show a line edit instead of a combo, so the user can enter the value?
I agree that where things get complicated is when considering what different types do. This was mainly intended for removing rows where 'any numeric variable is nan', for example. Or any string variable is nan. For categorical... I don't know. We have to think of a use case for this one. I suppose there is a chance one would want to remove rows where any of the survey responses was 'I don't know' or 'Prefer not to answer'. 🤔 I think visually it would be best the user can enter the value herself.


Resolving patch:
diff --git a/Orange/data/filter.py b/Orange/data/filter.py
--- a/Orange/data/filter.py
+++ b/Orange/data/filter.py
@@ -255,11 +255,6 @@ class ValueFilter(Filter):
     def __init__(self, column):
         super().__init__()
         self.column = column
-        self.last_domain = None
-
-    def cache_position(self, domain):
-        self.pos_cache = domain.index(self.column)
-        self.last_domain = domain
 
 
 class FilterDiscrete(ValueFilter):
@@ -283,9 +278,7 @@ def __init__(self, column, values):
         self.values = values
 
     def __call__(self, inst):
-        if inst.domain is not self.last_domain:
-            self.cache_position(inst.domain)
-        value = inst[self.pos_cache]
+        value = inst[inst.domain.index(self.column)]
         if self.values is None:
             return not isnan(value)
         else:
@@ -342,9 +335,7 @@ def min(self, value):
         self.ref = value
 
     def __call__(self, inst):
-        if inst.domain is not self.last_domain:
-            self.cache_position(inst.domain)
-        value = inst[self.pos_cache]
+        value = inst[inst.domain.index(self.column)]
         if isnan(value):
             return self.oper == self.Equal and isnan(self.ref)
         if self.oper == self.Equal:
@@ -453,9 +444,8 @@ def min(self, value):
         self.ref = value
 
     def __call__(self, inst):
-        if inst.domain is not self.last_domain:
-            self.cache_position(inst.domain)
-        value = inst[self.pos_cache]
+        # the function is a large 'switch'; pylint: disable=too-many-branches
+        value = inst[inst.domain.index(self.column)]
         if self.oper == self.IsDefined:
             return not np.isnan(value)
         if self.case_sensitive:
@@ -524,9 +514,7 @@ def values(self, values):
         self.values_lower = [x.lower() for x in values]
 
     def __call__(self, inst):
-        if inst.domain is not self.last_domain:
-            self.cache_position(inst.domain)
-        value = inst[self.pos_cache]
+        value = inst[inst.domain.index(self.column)]
         if self.case_sensitive:
             return value in self._values
         else:
diff --git a/Orange/data/table.py b/Orange/data/table.py
--- a/Orange/data/table.py
+++ b/Orange/data/table.py
@@ -1184,25 +1184,38 @@ def rx(M):
             return rx(self.metas[:, -1 - index])
 
     def _filter_is_defined(self, columns=None, negate=False):
+        # structure of function is obvious; pylint: disable=too-many-branches
+        def _sp_anynan(a):
+            return a.indptr[1:] != a[-1:] + a.shape[1]
+
         if columns is None:
             if sp.issparse(self.X):
-                remove = (self.X.indptr[1:] !=
-                          self.X.indptr[-1:] + self.X.shape[1])
+                remove = _sp_anynan(self.X)
             else:
                 remove = bn.anynan(self.X, axis=1)
             if sp.issparse(self._Y):
-                remove = np.logical_or(remove, self._Y.indptr[1:] !=
-                                       self._Y.indptr[-1:] + self._Y.shape[1])
+                remove += _sp_anynan(self._Y)
+            else:
+                remove += bn.anynan(self._Y, axis=1)
+            if sp.issparse(self.metas):
+                remove += _sp_anynan(self._metas)
             else:
-                remove = np.logical_or(remove, bn.anynan(self._Y, axis=1))
+                for i, var in enumerate(self.domain.metas):
+                    col = self.metas[:, i].flatten()
+                    if var.is_primitive():
+                        remove += np.isnan(col.astype(float))
+                    else:
+                        remove += ~col.astype(bool)
         else:
             remove = np.zeros(len(self), dtype=bool)
             for column in columns:
                 col, sparse = self.get_column_view(column)
                 if sparse:
-                    remove = np.logical_or(remove, col == 0)
+                    remove += col == 0
+                elif self.domain[column].is_primitive():
+                    remove += bn.anynan([col.astype(float)], axis=0)
                 else:
-                    remove = np.logical_or(remove, bn.anynan([col], axis=0))
+                    remove += col.astype(bool)
         retain = remove if negate else np.logical_not(remove)
         return self.from_table_rows(self, retain)
 
@@ -1283,34 +1296,60 @@ def _filter_to_indicator(self, filter):
         """
         from Orange.data.filter import (
             FilterContinuous, FilterDiscrete, FilterRegex, FilterString,
-            FilterStringList, Values
+            FilterStringList, IsDefined, Values
         )
         if isinstance(filter, Values):
             return self._values_filter_to_indicator(filter)
 
-        col = self.get_column_view(filter.column)[0]
-
-        if isinstance(filter, FilterDiscrete):
-            return self._discrete_filter_to_indicator(filter, col)
-
-        if isinstance(filter, FilterContinuous):
-            return self._continuous_filter_to_indicator(filter, col)
-
-        if isinstance(filter, FilterString):
-            return self._string_filter_to_indicator(filter, col)
-
-        if isinstance(filter, FilterStringList):
-            if not filter.case_sensitive:
-                col = np.char.lower(np.array(col, dtype=str))
-                vals = [val.lower() for val in filter.values]
-            else:
-                vals = filter.values
-            return reduce(operator.add, (col == val for val in vals))
-
-        if isinstance(filter, FilterRegex):
-            return np.vectorize(filter)(col)
-
-        raise TypeError("Invalid filter")
+        def get_col_indices():
+            cols = chain(self.domain.variables, self.domain.metas)
+            if isinstance(filter, IsDefined):
+                return list(cols)
+
+            if filter.column is not None:
+                return [filter.column]
+
+            if isinstance(filter, FilterDiscrete):
+                raise ValueError("Discrete filter can't be applied across rows")
+            if isinstance(filter, FilterContinuous):
+                return [col for col in cols if col.is_continuous]
+            if isinstance(filter,
+                          (FilterString, FilterStringList, FilterRegex)):
+                return [col for col in cols if col.is_string]
+            raise TypeError("Invalid filter")
+
+        def col_filter(col_idx):
+            col = self.get_column_view(col_idx)[0]
+            if isinstance(filter, IsDefined):
+                if self.domain[col_idx].is_primitive():
+                    return ~np.isnan(col.astype(float))
+                else:
+                    return col.astype(np.bool)
+            if isinstance(filter, FilterDiscrete):
+                return self._discrete_filter_to_indicator(filter, col)
+            if isinstance(filter, FilterContinuous):
+                return self._continuous_filter_to_indicator(filter, col)
+            if isinstance(filter, FilterString):
+                return self._string_filter_to_indicator(filter, col)
+            if isinstance(filter, FilterStringList):
+                if not filter.case_sensitive:
+                    col = np.char.lower(np.array(col, dtype=str))
+                    vals = [val.lower() for val in filter.values]
+                else:
+                    vals = filter.values
+                return reduce(operator.add, (col == val for val in vals))
+            if isinstance(filter, FilterRegex):
+                return np.vectorize(filter)(col)
+            raise TypeError("Invalid filter")
+
+        col_indices = get_col_indices()
+        if len(col_indices) == 1:
+            return col_filter(col_indices[0])
+
+        sel = np.ones(len(self), dtype=bool)
+        for col_idx in col_indices:
+            sel *= col_filter(col_idx)
+        return sel
 
     def _discrete_filter_to_indicator(self, filter, col):
         """Return selection of rows matched by the given discrete filter.
@@ -1392,24 +1431,25 @@ def _string_filter_to_indicator(self, filter, col):
 
     @staticmethod
     def _range_filter_to_indicator(filter, col, fmin, fmax):
-        if filter.oper == filter.Equal:
-            return col == fmin
-        if filter.oper == filter.NotEqual:
-            return col != fmin
-        if filter.oper == filter.Less:
-            return col < fmin
-        if filter.oper == filter.LessEqual:
-            return col <= fmin
-        if filter.oper == filter.Greater:
-            return col > fmin
-        if filter.oper == filter.GreaterEqual:
-            return col >= fmin
-        if filter.oper == filter.Between:
-            return (col >= fmin) * (col <= fmax)
-        if filter.oper == filter.Outside:
-            return (col < fmin) + (col > fmax)
-
-        raise TypeError("Invalid operator")
+        with np.errstate(invalid="ignore"):   # nan's are properly discarded
+            if filter.oper == filter.Equal:
+                return col == fmin
+            if filter.oper == filter.NotEqual:
+                return col != fmin
+            if filter.oper == filter.Less:
+                return col < fmin
+            if filter.oper == filter.LessEqual:
+                return col <= fmin
+            if filter.oper == filter.Greater:
+                return col > fmin
+            if filter.oper == filter.GreaterEqual:
+                return col >= fmin
+            if filter.oper == filter.Between:
+                return (col >= fmin) * (col <= fmax)
+            if filter.oper == filter.Outside:
+                return (col < fmin) + (col > fmax)
+
+            raise TypeError("Invalid operator")
 
     def _compute_basic_stats(self, columns=None,
                              include_metas=False, compute_variance=False):
diff --git a/Orange/widgets/data/owselectrows.py b/Orange/widgets/data/owselectrows.py
--- a/Orange/widgets/data/owselectrows.py
+++ b/Orange/widgets/data/owselectrows.py
@@ -14,8 +14,11 @@
 )
 from AnyQt.QtCore import Qt, QPoint, QRegExp, QPersistentModelIndex, QLocale
 
-from Orange.data import (ContinuousVariable, DiscreteVariable, StringVariable,
-                         Table, TimeVariable)
+from Orange.data import (
+    Variable, ContinuousVariable, DiscreteVariable, StringVariable,
+    TimeVariable,
+    Table
+)
 import Orange.data.filter as data_filter
 from Orange.data.filter import FilterContinuous, FilterString
 from Orange.data.domain import filter_visible
@@ -69,6 +72,13 @@ class FilterDiscreteType(enum.Enum):
     IsDefined = "IsDefined"
 
 
+def _plural(s):
+    s = s.replace("is ", "are ")
+    for word in ("equals", "contains", "begins", "ends"):
+        s = s.replace(word, word[:-1])
+    return s
+
+
 class OWSelectRows(widget.OWWidget):
     name = "Select Rows"
     id = "Orange.widgets.data.file"
@@ -128,8 +138,20 @@ class Outputs:
             (FilterString.IsDefined, "is defined"),
         ]
     }
+
     Operators[TimeVariable] = Operators[ContinuousVariable]
 
+    AllTypes = {}
+    for _all_name, _all_type, _all_ops in (
+            ("All variables", 0,
+             [(None, "are defined")]),
+            ("All numeric variables", 2,
+             [(v, _plural(t)) for v, t in Operators[ContinuousVariable]]),
+            ("All string variables", 3,
+             [(v, _plural(t)) for v, t in Operators[StringVariable]])):
+        Operators[_all_name] = _all_ops
+        AllTypes[_all_name] = _all_type
+
     operator_names = {vtype: [name for _, name in filters]
                       for vtype, filters in Operators.items()}
 
@@ -211,8 +233,16 @@ def add_row(self, attr=None, condition_type=None, condition_value=None):
             sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon)
         attr_combo.row = row
         for var in self._visible_variables(self.data.domain):
-            attr_combo.addItem(*gui.attributeItem(var))
-        attr_combo.setCurrentIndex(attr or 0)
+            if isinstance(var, Variable):
+                attr_combo.addItem(*gui.attributeItem(var))
+            else:
+                attr_combo.addItem(var)
+        if isinstance(attr, str):
+            attr_combo.setCurrentText(attr)
+        else:
+            attr_combo.setCurrentIndex(
+                attr or
+                len(self.AllTypes) - (attr_combo.count() == len(self.AllTypes)))
         self.cond_list.setCellWidget(row, 0, attr_combo)
 
         index = QPersistentModelIndex(model.index(row, 3))
@@ -230,12 +260,14 @@ def add_row(self, attr=None, condition_type=None, condition_value=None):
 
         self.cond_list.resizeRowToContents(row)
 
-    @staticmethod
-    def _visible_variables(domain):
+    @classmethod
+    def _visible_variables(cls, domain):
         """Generate variables in order they should be presented in in combos."""
-        return filter_visible(chain(domain.class_vars,
-                                    domain.metas,
-                                    domain.attributes))
+        return chain(
+            cls.AllTypes,
+            filter_visible(chain(domain.class_vars,
+                                 domain.metas,
+                                 domain.attributes)))
 
     def add_all(self):
         if self.cond_list.rowCount():
@@ -273,8 +305,12 @@ def set_new_operators(self, attr_combo, adding_all,
         oper_combo = QComboBox()
         oper_combo.row = attr_combo.row
         oper_combo.attr_combo = attr_combo
-        var = self.data.domain[attr_combo.currentText()]
-        oper_combo.addItems(self.operator_names[type(var)])
+        attr_name = attr_combo.currentText()
+        if attr_name in self.AllTypes:
+            oper_combo.addItems(self.operator_names[attr_name])
+        else:
+            var = self.data.domain[attr_name]
+            oper_combo.addItems(self.operator_names[type(var)])
         oper_combo.setCurrentIndex(selected_index or 0)
         self.cond_list.setCellWidget(oper_combo.row, 1, oper_combo)
         self.set_new_values(oper_combo, adding_all, selected_values)
@@ -345,27 +381,32 @@ def add_datetime(contents):
             le.setValidator(QRegExpValidator(QRegExp(TimeVariable.REGEX)))
             return le
 
-        var = self.data.domain[oper_combo.attr_combo.currentText()]
         box = self.cond_list.cellWidget(oper_combo.row, 2)
-        if selected_values is not None:
-            lc = list(selected_values) + ["", ""]
-            lc = [str(x) for x in lc[:2]]
+        lc = ["", ""]
+        oper = oper_combo.currentIndex()
+        attr_name = oper_combo.attr_combo.currentText()
+        if attr_name in self.AllTypes:
+            vtype = self.AllTypes[attr_name]
+            var = None
         else:
-            lc = ["", ""]
-        if box and vartype(var) == box.var_type:
+            var = self.data.domain[attr_name]
+            vtype = vartype(var)
+            if selected_values is not None:
+                lc = list(selected_values) + ["", ""]
+                lc = [str(x) for x in lc[:2]]
+        if box and vtype == box.var_type:
             lc = self._get_lineedit_contents(box) + lc
-        oper = oper_combo.currentIndex()
 
-        if oper_combo.currentText() == "is defined":
+        if oper_combo.currentText().endswith(" defined"):
             label = QLabel()
-            label.var_type = vartype(var)
+            label.var_type = vtype
             self.cond_list.setCellWidget(oper_combo.row, 2, label)
-        elif var.is_discrete:
-            if oper_combo.currentText() == "is one of":
+        elif var is not None and var.is_discrete:
+            if oper_combo.currentText().endswith(" one of"):
                 if selected_values:
                     lc = [x for x in list(selected_values)]
                 button = DropDownToolButton(self, var, lc)
-                button.var_type = vartype(var)
+                button.var_type = vtype
                 self.cond_list.setCellWidget(oper_combo.row, 2, button)
             else:
                 combo = QComboBox()
@@ -379,15 +420,15 @@ def add_datetime(contents):
                 combo.currentIndexChanged.connect(self.conditions_changed)
         else:
             box = gui.hBox(self, addToLayout=False)
-            box.var_type = vartype(var)
+            box.var_type = vtype
             self.cond_list.setCellWidget(oper_combo.row, 2, box)
-            if var.is_continuous:
+            if vtype in (2, 4):  # continuous, time:
                 validator = add_datetime if isinstance(var, TimeVariable) else add_numeric
                 box.controls = [validator(lc[0])]
                 if oper > 5:
                     gui.widgetLabel(box, " and ")
                     box.controls.append(validator(lc[1]))
-            elif var.is_string:
+            elif vtype == 3:  # string:
                 box.controls = [add_textual(lc[0])]
                 if oper in [6, 7]:
                     gui.widgetLabel(box, " and ")
@@ -420,12 +461,14 @@ def set_data(self, data):
             pass
 
         variables = list(self._visible_variables(self.data.domain))
-        varnames = [v.name for v in variables]
+        varnames = [v.name if isinstance(v, Variable) else v for v in variables]
         if self.conditions:
             for attr, cond_type, cond_value in self.conditions:
                 if attr in varnames:
                     self.add_row(varnames.index(attr), cond_type, cond_value)
-        elif variables:
+                elif attr in self.AllTypes:
+                    self.add_row(attr, cond_type, cond_value)
+        else:
             self.add_row()
 
         self.update_info(data, self.data_in_variables, "In: ")
@@ -478,11 +521,19 @@ def commit(self):
             domain = self.data.domain
             conditions = []
             for attr_name, oper_idx, values in self.conditions:
-                attr_index = domain.index(attr_name)
-                attr = domain[attr_index]
-                operators = self.Operators[type(attr)]
+                if attr_name in self.AllTypes:
+                    attr_index = attr = None
+                    attr_type = self.AllTypes[attr_name]
+                    operators = self.Operators[attr_name]
+                else:
+                    attr_index = domain.index(attr_name)
+                    attr = domain[attr_index]
+                    attr_type = vartype(attr)
+                    operators = self.Operators[type(attr)]
                 opertype, _ = operators[oper_idx]
-                if attr.is_continuous:
+                if attr_type == 0:
+                    filter = data_filter.IsDefined()
+                elif attr_type in (2, 4):  # continuous, time
                     try:
                         floats = self._values_to_floats(attr, values)
                     except ValueError as e:
@@ -492,7 +543,7 @@ def commit(self):
                         continue
                     filter = data_filter.FilterContinuous(
                         attr_index, opertype, *floats)
-                elif attr.is_string:
+                elif attr_type == 3:  # string
                     filter = data_filter.FilterString(
                         attr_index, opertype, *[str(v) for v in values])
                 else:
@@ -590,35 +641,35 @@ def send_report(self):
         conditions = []
         domain = self.data.domain
         for attr_name, oper, values in self.conditions:
-            attr_index = domain.index(attr_name)
-            attr = domain[attr_index]
-            names = self.operator_names[type(attr)]
+            if attr_name in self.AllTypes:
+                attr = attr_name
+                names = self.operator_names[attr_name]
+                var_type = self.AllTypes[attr_name]
+            else:
+                attr = domain[attr_name]
+                var_type = vartype(attr)
+                names = self.operator_names[type(attr)]
             name = names[oper]
             if oper == len(names) - 1:
                 conditions.append("{} {}".format(attr, name))
-            elif attr.is_discrete:
+            elif var_type == 1:  # discrete
                 if name == "is one of":
-                    if len(values) == 1:
-                        conditions.append("{} is {}".format(
-                            attr, attr.values[values[0] - 1]))
-                    elif len(values) > 1:
-                        conditions.append("{} is {} or {}".format(
-                            attr,
-                            ", ".join(attr.values[v - 1] for v in values[:-1]),
-                            attr.values[values[-1] - 1]))
-                else:
-                    if not (values and values[0]):
+                    valnames = [attr.values[v - 1] for v in values]
+                    if not valnames:
                         continue
+                    if len(valnames) == 1:
+                        valstr = valnames[0]
+                    else:
+                        valstr = f"{', '.join(valnames[:-1])} or {valnames[-1]}"
+                    conditions.append(f"{attr} is {valstr}")
+                elif values and values[0]:
                     value = values[0] - 1
-                    conditions.append("{} {} {}".
-                                      format(attr, name, attr.values[value]))
-            else:
-                if len(values) == 1:
-                    conditions.append("{} {} {}".
-                                      format(attr, name, *values))
-                else:
-                    conditions.append("{} {} {} and {}".
-                                      format(attr, name, *values))
+                    conditions.append(f"{attr} {name} {attr.values[value]}")
+            elif var_type == 3:  # string variable
+                conditions.append(
+                    f"{attr} {name} {' and '.join(map(repr, values))}")
+            elif all(x for x in values):  # numeric variable
+                conditions.append(f"{attr} {name} {' and '.join(values)}")
         items = OrderedDict()
         if describe_domain:
             items.update(self.data_desc)


Test patch:
diff --git a/Orange/data/tests/test_table.py b/Orange/data/tests/test_table.py
new file mode 100644
--- /dev/null
+++ b/Orange/data/tests/test_table.py
@@ -0,0 +1,106 @@
+import unittest
+
+import numpy as np
+
+from Orange.data import (
+    ContinuousVariable, DiscreteVariable, StringVariable,
+    Domain, Table, IsDefined, FilterContinuous, Values, FilterString,
+    FilterDiscrete, FilterStringList, FilterRegex)
+
+
+class TestTableFilters(unittest.TestCase):
+    def setUp(self):
+        self.domain = Domain(
+            [ContinuousVariable("c1"),
+             ContinuousVariable("c2"),
+             DiscreteVariable("d1", values=["a", "b"])],
+            ContinuousVariable("y"),
+            [ContinuousVariable("c3"),
+             DiscreteVariable("d2", values=["c", "d"]),
+             StringVariable("s1"),
+             StringVariable("s2")]
+        )
+        metas = np.array(
+            [0, 1, 0, 1, 1, np.nan, 1] +
+            [0, 0, 0, 0, np.nan, 1, 1] +
+            "a  b  c  d  e     f    g".split() +
+            list("ABCDEF") + [""], dtype=object).reshape(-1, 7).T
+        self.table = Table.from_numpy(
+            self.domain,
+            np.array(
+                [[0, 0, 0],
+                 [0, -1, 0],
+                 [np.nan, 1, 0],
+                 [1, 1, np.nan],
+                 [1, 1, 1],
+                 [1, 1, 1],
+                 [1, 1, 1]]),
+            np.array(
+                [0, 1, 0, 1, np.nan, 1, 1]),
+            metas
+        )
+
+    def test_row_filters_is_defined(self):
+        filtered = IsDefined()(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("ab"))
+
+        val_filter = Values([
+            FilterContinuous(None, FilterContinuous.IsDefined)])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("abdg"))
+
+        val_filter = Values([FilterString(None, FilterString.IsDefined)])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("abcdef"))
+
+    def test_row_filter_no_discrete(self):
+        val_filter = Values([FilterDiscrete(None, "a")])
+        self.assertRaises(ValueError, val_filter, self.table)
+
+    def test_row_filter_continuous(self):
+        val_filter = Values([
+            FilterContinuous(None, FilterContinuous.GreaterEqual, 0)])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("adg"))
+
+        val_filter = Values([
+            FilterContinuous(None, FilterContinuous.Greater, 0)])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("dg"))
+
+        val_filter = Values([
+            FilterContinuous(None, FilterContinuous.Less, 1)])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), ["a"])
+
+    def test_row_filter_string(self):
+        self.table.metas[:, -1] = self.table.metas[::-1, -2]
+        val_filter = Values([
+            FilterString(None, FilterString.Between, "c", "e")])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("cde"))
+
+    def test_row_stringlist(self):
+        val_filter = Values([
+            FilterStringList(None, list("bBdDe"))])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("bd"))
+
+        val_filter = Values([
+            FilterStringList(None, list("bDe"), case_sensitive=False)])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("bde"))
+
+    def test_row_stringregex(self):
+        val_filter = Values([FilterRegex(None, "[bBdDe]")])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("bd"))
+
+    def test_is_defined(self):
+        val_filter = IsDefined(columns=["c3"])
+        filtered = val_filter(self.table)
+        self.assertEqual(list(filtered.metas[:, -2].flatten()), list("abcdeg"))
+
+
+if __name__ == "__main__":
+    unittest.main()