Repository: robocorp/rpaframework

Issue:
Documentation: Find Table Rows examples missing the table argument
The examples under the [Find table Rows keyword doc](https://rpaframework.org/libdoc/RPA_Tables.html#Find%20Table%20Rows) are as shown,

```robotframework
# Find all rows where price is over 200
@{rows}=    Find table rows    Price  >  ${200}

# Find all rows where the status does not contain "removed"
@{rows}=    Find table rows    Status    not contains    removed
```

are missing the **table** argument, which for completeness one might add the line `${orders}=    Read worksheet as table    orders.xlsx` so as to have a table as compared to the generic example `${table}`


Discussion:

Resolving patch:
diff --git a/packages/main/src/RPA/Excel/Files.py b/packages/main/src/RPA/Excel/Files.py
--- a/packages/main/src/RPA/Excel/Files.py
+++ b/packages/main/src/RPA/Excel/Files.py
@@ -606,7 +606,7 @@ def read_worksheet(
         name: Optional[str] = None,
         header: Optional[bool] = False,
         start: Optional[int] = None,
-    ) -> Union["XlsWorkbook", "XlsxWorkbook"]:
+    ) -> List[dict]:
         """Read the content of a worksheet into a list of dictionaries.
 
         Each key in the dictionary will be either values from the header row,
@@ -653,7 +653,7 @@ def read_worksheet_as_table(
         header: bool = False,
         trim: bool = True,
         start: Optional[int] = None,
-    ) -> Tables:
+    ) -> Table:
         """Read the contents of a worksheet into a Table container. Allows
         sorting/filtering/manipulating using the ``RPA.Tables`` library.
 
@@ -701,7 +701,7 @@ def append_rows_to_worksheet(
         name: Optional[str] = None,
         header: bool = False,
         start: Optional[int] = None,
-    ) -> Union["XlsWorkbook", "XlsxWorkbook"]:
+    ) -> List[dict]:
         """Append values to the end of the worksheet.
 
         :param content: Rows of values to append
@@ -1159,7 +1159,7 @@ def create_worksheet(self, name):
         self._book.create_sheet(title=name)
         self.active = name
 
-    def read_worksheet(self, name=None, header=False, start=None):
+    def read_worksheet(self, name=None, header=False, start=None) -> List[dict]:
         name = self._get_sheetname(name)
         sheet = self._book[name]
         start = self._to_index(start)
@@ -1436,7 +1436,7 @@ def create_worksheet(self, name):
 
         self.active = name
 
-    def read_worksheet(self, name=None, header=False, start=None):
+    def read_worksheet(self, name=None, header=False, start=None) -> List[dict]:
         name = self._get_sheetname(name)
         sheet = self._book.sheet_by_name(name)
         start = self._to_index(start)
diff --git a/packages/main/src/RPA/Tables.py b/packages/main/src/RPA/Tables.py
--- a/packages/main/src/RPA/Tables.py
+++ b/packages/main/src/RPA/Tables.py
@@ -1,16 +1,16 @@
-# pylint: disable=too-many-lines
-# TODO: Implement column slicing
-# TODO: Index name conflict in exports/imports
-# TODO: Return Robot Framework DotDict instead of dict?
 import copy
 import csv
 import logging
 import re
 from collections import OrderedDict, namedtuple
 from enum import Enum
+from itertools import groupby, zip_longest
 from keyword import iskeyword
+from numbers import Number
+from operator import itemgetter
 from typing import (
     Any,
+    Callable,
     Dict,
     Generator,
     Iterable,
@@ -21,18 +21,18 @@
     Union,
 )
 
-from itertools import groupby, zip_longest
-from numbers import Number
-from operator import itemgetter
-
 from robot.api.deco import keyword
+from robot.libraries.BuiltIn import BuiltIn
+from RPA.core.notebook import notebook_print, notebook_table
 from RPA.core.types import is_dict_like, is_list_like, is_namedtuple
-from RPA.core.notebook import notebook_table, notebook_print
 
-Index = int
+
+Index = Union[int, str]
 Column = Union[int, str]
 Row = Union[Dict, List, Tuple, NamedTuple, set]
-Data = Union[Dict[Column, Row], List[Row], "Table", None]
+Data = Optional[Union[Dict[Column, Row], List[Row], "Table"]]
+CellCondition = Callable[[Any], bool]
+RowCondition = Callable[[Union[Index, Row]], bool]
 
 
 def to_list(obj: Any, size: int = 1):
@@ -59,7 +59,7 @@ def to_identifier(val: Any):
     return val
 
 
-def to_condition(operator: str, value: Any):
+def to_condition(operator: str, value: Any) -> CellCondition:
     """Convert string operator into callable condition function."""
     operator = str(operator).lower().strip()
     condition = {
@@ -233,7 +233,7 @@ def data(self):
         return self._data.copy()
 
     @property
-    def size(self):
+    def size(self) -> int:
         return len(self._data)
 
     @property
@@ -349,7 +349,7 @@ def _validate_self(self):
             if len(head) != len(self._columns):
                 raise ValueError("Columns length does not match data")
 
-    def index_location(self, value):
+    def index_location(self, value: Index) -> int:
         try:
             value = int(value)
         except ValueError as err:
@@ -442,25 +442,8 @@ def __setitem__(self, key, value):
 
     def _slice_index(self, slicer):
         """Create list of index values from slice object."""
-        if slicer.start is not None:
-            start = slicer.start
-        else:
-            start = 0
-
-        if slicer.stop is not None:
-            end = slicer.stop
-        else:
-            end = self.size
-
-        if not isinstance(start, int) or not isinstance(end, int):
-            raise IndexError("Index slices should be integers")
-
-        if start < 0:
-            start = self.size + start
-
-        if end < 0:
-            end = self.size + end
-
+        start = self.index_location(slicer.start) if slicer.start is not None else 0
+        end = self.index_location(slicer.stop) if slicer.stop is not None else self.size
         return list(range(start, end))
 
     def copy(self):
@@ -515,7 +498,7 @@ def get_cell(self, index, column):
 
         return self._data[idx][col]
 
-    def get_row(self, index, columns=None, as_list=False):
+    def get_row(self, index: Index, columns=None, as_list=False):
         """Get column values from row.
 
         :param index:   Index for row
@@ -578,7 +561,7 @@ def get_table(self, indexes=None, columns=None, as_list=False):
         else:
             return Table(data=data, columns=columns)
 
-    def get_slice(self, start=None, end=None):
+    def get_slice(self, start: Optional[Index] = None, end: Optional[Index] = None):
         """Get a new table from rows between start and end index."""
         index = self._slice_index(slice(start, end))
         return self.get_table(index, self._columns)
@@ -700,9 +683,9 @@ def append_column(self, column=None, values=None):
 
         self.set_column(column, values)
 
-    def delete_rows(self, indexes):
+    def delete_rows(self, indexes: Union[Index, List[Index]]):
         """Remove rows with matching indexes."""
-        indexes = to_list(indexes)
+        indexes = [self.index_location(idx) for idx in to_list(indexes)]
 
         unknown = set(indexes) - set(self.index)
         if unknown:
@@ -791,19 +774,35 @@ def group_by_column(self, column):
 
         return result
 
-    def filter_by_column(self, column, condition):
-        """Remove rows by evaluating `condition` for all `column`
-        values. All rows where it evaluates to falsy are removed.
+    def _filter(self, condition: RowCondition):
+        filtered = list(filter(lambda idx: not condition(idx), self.index))
+        self.delete_rows(filtered)
+
+    def filter_all(self, condition: RowCondition):
+        """Remove rows by evaluating `condition` for every row.
 
-        The filtering will be done in-place.
+        The filtering will be done in-place and all the rows evaluating as falsy
+        through the provided condition will be removed.
         """
-        filtered = []
-        for index in self.index:
-            value = self.get_cell(index, column)
-            if not condition(value):
-                filtered.append(index)
 
-        self.delete_rows(filtered)
+        def _check_row(index: int) -> bool:
+            row = self.get_row(index)
+            return condition(row)
+
+        self._filter(_check_row)
+
+    def filter_by_column(self, column: Column, condition: CellCondition):
+        """Remove rows by evaluating `condition` for cells in `column`.
+
+        The filtering will be done in-place and all the rows where it evaluates to
+        falsy are removed.
+        """
+
+        def _check_cell(index: int) -> bool:
+            cell = self.get_cell(index, column)
+            return condition(cell)
+
+        self._filter(_check_cell)
 
     def iter_lists(self, with_index=True):
         """Iterate rows with values as lists."""
@@ -1579,14 +1578,14 @@ def set_table_cell(self, table: Table, row: Index, column: Column, value: Any):
         table.set_cell(row, column, value)
 
     def find_table_rows(self, table: Table, column: Column, operator: str, value: Any):
-        """Find all rows in a table which match a condition for a
-        given column.
+        """Find all the rows in a table which match a condition for a given column.
 
-        :param table:    Table to find from
-        :param column:   Name of column to search
-        :param operator: Comparison operator
-        :param value:    Value to compare against
-        :return:         Table object that matches the row conditions
+        :param table: Table to search into.
+        :param column: Name or position of the column to compare with.
+        :param operator: Comparison operator used with every cell value on the
+            specified column.
+        :param value: Value to compare against.
+        :return: New `Table` object containing all the rows matching the condition.
 
         Supported operators:
 
@@ -1607,17 +1606,17 @@ def find_table_rows(self, table: Table, column: Column, operator: str, value: An
         not in       Cell value is not in given value
         ============ ========================================
 
-        Returns the matches as a new Table instance.
+        Returns the matches as a new `Table` instance.
 
         Examples:
 
         .. code-block:: robotframework
 
             # Find all rows where price is over 200
-            @{rows}=    Find table rows    Price  >  ${200}
+            @{rows} =    Find table rows    ${table}    Price  >  ${200}
 
             # Find all rows where the status does not contain "removed"
-            @{rows}=    Find table rows    Status    not contains    removed
+            @{rows} =    Find table rows    ${table}    Status  not contains  removed
         """
         self._requires_table(table)
 
@@ -1715,6 +1714,62 @@ def filter_table_by_column(
 
         self.logger.info("Filtered %d rows", after - before)
 
+    def filter_table_with_keyword(self, table: Table, name: str, *args):
+        """Run a keyword for each row of a table, then remove all rows where the called
+        keyword returns a falsy value.
+
+        Can be used to create custom RF keyword based filters.
+
+        :param table: Table to modify.
+        :param name: Keyword name used as filter.
+        :param args: Additional keyword arguments to be passed. (optional)
+
+        The row object will be given as the first argument to the filtering keyword.
+        """
+        self._requires_table(table)
+
+        def condition(row: Row) -> bool:
+            return BuiltIn().run_keyword(name, row, *args)
+
+        before = len(table)
+        table.filter_all(condition)
+        after = len(table)
+
+        self.logger.info("Removed %d row(s)", before - after)
+
+    def map_column_values(self, table: Table, column: Column, name: str, *args):
+        """Run a keyword for each cell in a given column, and replace its content with
+        the return value.
+
+        Can be used to easily convert column types or values in-place.
+
+        :param table: Table to modify.
+        :param column: Column to modify.
+        :param name: Mapping keyword name.
+        :param args: Additional keyword arguments. (optional)
+
+        The cell value will be given as the first argument to the mapping keyword.
+
+        Examples:
+
+        .. code-block:: robotframework
+
+            # Convert all columns values to a different type
+            Map column values    ${table}    Price    Convert to integer
+
+            # Look up values with a custom keyword
+            Map column values    ${table}    User     Map user ID to name
+        """
+        self._requires_table(table)
+
+        values = []
+        for index in table.index:
+            cell = table.get_cell(index, column)
+            output = BuiltIn().run_keyword(name, cell, *args)
+            values.append(output)
+
+        table.set_column(column, values)
+
     def filter_empty_rows(self, table: Table):
         """Remove all rows from a table which have only ``None`` values.
 


Test patch:
diff --git a/packages/main/tests/robot/test_tables.robot b/packages/main/tests/robot/test_tables.robot
--- a/packages/main/tests/robot/test_tables.robot
+++ b/packages/main/tests/robot/test_tables.robot
@@ -1,4 +1,6 @@
 *** Settings ***
+Library           Collections
+Library           DateTime
 Library           RPA.Tables
 Library           RPA.FileSystem
 Library           RPA.Excel.Files
@@ -39,14 +41,41 @@ Table With Non-identifier Columns
     END
 
 Get Table Cell Errors
-    ${table}=    Create table    [[1,2,3], [4,5,6]]    columns=['One','Two','Three']
-    Assert cell value    ${table}    0    0    1
-    Assert cell value    ${table}    1    1    5
-    Assert cell value    ${table}    1    Three    6
-    Assert cell error    ${table}    5    0    *out of range*
-    Assert cell error    ${table}    1    3    *out of range*
-    Assert cell error    ${table}    1    Four    *Unknown column name*
-    Assert cell error    ${table}    Test    0    *integer*
+    ${table}=    Create table
+    ...   [[1,2,3], [4,5,6]]
+    ...   columns=["One","Two","Three"]
+
+    Assert cell value    ${table}    0     0        ${1}
+    Assert cell value    ${table}    1     1        ${5}
+    Assert cell value    ${table}    1     Three    ${6}
+
+    Assert cell error    ${table}    5     0       *out of range*
+    Assert cell error    ${table}    1     3       *out of range*
+    Assert cell error    ${table}    1     Four    *Unknown column name*
+    Assert cell error    ${table}    Test  0       *not a number*
+
+Convert Column Values
+    ${table}=    Create table
+    ...   [[1,"one","45"],[2,"two","102"],[3,"three",100]]
+    ...   columns=["ID","User","Price"]
+
+    Map Column Values    ${table}    Price    Convert to integer
+    Map Column Values    ${table}    User     Find user name
+
+    Assert cell value    ${table}    0     Price    ${45}
+    Assert cell value    ${table}    2     Price    ${100}
+    Assert cell value    ${table}    0     User     Teppo
+    Assert cell value    ${table}    2     User     Cosmin
+
+Remove Rows With Filtering
+    ${table}=    Create table
+    ...    [["One", "5/3/22 9:44"], ["Two", "5/4/22 9:25"], ["Three", "5/3/22 10:21"]]
+    ...    columns=["ID", "Submit Date"]
+
+    Filter Table With Keyword    ${table}
+    ...     Match Date    target=05.03.2022  # kwarg passed as string arg
+    Assert cell value    ${table}    0    ID    One
+    Assert cell value    ${table}    1    ID    Three
 
 *** Keywords ***
 List group IDs
@@ -58,9 +87,31 @@ List group IDs
 Assert cell value
     [Arguments]    ${table}    ${row}    ${column}    ${value}
     ${result}=    Get table cell    ${table}    ${row}    ${column}
-    Should be equal as integers    ${result}    ${value}
+    Should be equal    ${result}    ${value}
 
 Assert cell error
     [Arguments]    ${table}    ${row}    ${column}    ${error}
     Run keyword and expect error    ${error}
     ...    Get table cell    ${table}    ${row}    ${column}
+
+Find user name
+    [Arguments]    ${key}
+    ${mapping}=    Create dictionary
+    ...    one=Teppo
+    ...    two=Mika
+    ...    three=Cosmin
+    ${output}=     Get from dictionary    ${mapping}    ${key}
+    [Return]     ${output}
+
+Match Date
+    [Arguments]    ${row}    ${target}
+    ${submit_date}=    Convert date
+    ...    ${row}[Submit Date]
+    ...    date_format=%d/%m/%y %H:%M
+    ...    result_format=%d.%m.%Y
+    ${target_date}=    Convert date
+    ...    ${target}
+    ...    date_format=%d.%m.%Y
+    ...    result_format=%d.%m.%Y
+    ${is_same_date}=    Evaluate    $submit_date == $target_date
+    [Return]    ${is_same_date}