Repository: mito-ds/mito

Issue:
Add startswith and endswith string filter conditions 
**Is your feature request related to a problem? Please describe.**
In filter options, it would be very useful to have a "Where" option to find strings that `beginswith` or `endswith` a pattern.  I often need to filter for strings that end with "-Prod" exactly, but I have some rows that contain "-Productivity" in the middle of the string.  A contains filter will include those, so I'd have to add a second filter that does not contain "-Productivity" to get the desired result.  There may be other use cases where beginswith and endswith would be more useful.



Discussion:

Resolving patch:
diff --git a/mitosheet/mitosheet/step_performers/filter.py b/mitosheet/mitosheet/step_performers/filter.py
--- a/mitosheet/mitosheet/step_performers/filter.py
+++ b/mitosheet/mitosheet/step_performers/filter.py
@@ -31,6 +31,8 @@
 FC_STRING_DOES_NOT_CONTAIN = "string_does_not_contain"
 FC_STRING_EXACTLY = "string_exactly"
 FC_STRING_NOT_EXACTLY = "string_not_exactly"
+FC_STRING_STARTS_WITH = "string_starts_with"
+FC_STRING_ENDS_WITH = "string_ends_with"
 
 FC_NUMBER_EXACTLY = "number_exactly"
 FC_NUMBER_NOT_EXACTLY = "number_not_exactly"
@@ -66,6 +68,8 @@
     FC_STRING_DOES_NOT_CONTAIN: "~{df_name}[{transpiled_column_header}].str.contains('{value}', na=False)",
     FC_STRING_EXACTLY: "{df_name}[{transpiled_column_header}] == '{value}'",
     FC_STRING_NOT_EXACTLY: "{df_name}[{transpiled_column_header}] != '{value}'",
+    FC_STRING_STARTS_WITH: "{df_name}[{transpiled_column_header}].str.startswith('{value}', na=False)",
+    FC_STRING_ENDS_WITH: "{df_name}[{transpiled_column_header}].str.endswith('{value}', na=False)",
     # DATES
     FC_DATETIME_EXACTLY: "{df_name}[{transpiled_column_header}] == pd.to_datetime('{value}')",
     FC_DATETIME_NOT_EXACTLY: "{df_name}[{transpiled_column_header}] != pd.to_datetime('{value}')",
@@ -134,6 +138,14 @@
         "Or": "{df_name}[{transpiled_column_header}].apply(lambda val: any(val != s for s in {values}))",
         "And": "{df_name}[{transpiled_column_header}].apply(lambda val: all(val != s for s in {values}))",
     },
+    FC_STRING_STARTS_WITH: {
+        "Or": "{df_name}[{transpiled_column_header}].apply(lambda val: any(str(val).startswith(s) for s in {values}))",
+        "And": "{df_name}[{transpiled_column_header}].apply(lambda val: all(str(val).startswith(s) for s in {values}))",
+    },
+    FC_STRING_ENDS_WITH: {
+        "Or": "{df_name}[{transpiled_column_header}].apply(lambda val: any(str(val).endswith(s) for s in {values}))",
+        "And": "{df_name}[{transpiled_column_header}].apply(lambda val: all(str(val).endswith(s) for s in {values}))",
+    },
     FC_DATETIME_EXACTLY: {
         "Or": "{df_name}[{transpiled_column_header}].isin({values})",
         "And": "{df_name}[{transpiled_column_header}].apply(lambda val: all(val == d for d in {values}))",
@@ -366,8 +378,12 @@ def get_applied_filter(
     # Then string
     if condition == FC_STRING_CONTAINS:
         return df[column_header].str.contains(value, na=False)
-    if condition == FC_STRING_DOES_NOT_CONTAIN:
+    elif condition == FC_STRING_DOES_NOT_CONTAIN:
         return ~df[column_header].str.contains(value, na=False)
+    elif condition == FC_STRING_STARTS_WITH:
+        return df[column_header].str.startswith(value, na=False)
+    elif condition == FC_STRING_ENDS_WITH:
+        return df[column_header].str.endswith(value, na=False)
     elif condition == FC_STRING_EXACTLY:
         return df[column_header] == value
     elif condition == FC_STRING_NOT_EXACTLY:


Test patch:
diff --git a/mitosheet/mitosheet/tests/steps_performers/test_filter.py b/mitosheet/mitosheet/tests/steps_performers/test_filter.py
--- a/mitosheet/mitosheet/tests/steps_performers/test_filter.py
+++ b/mitosheet/mitosheet/tests/steps_performers/test_filter.py
@@ -33,6 +33,8 @@
     FC_STRING_DOES_NOT_CONTAIN,
     FC_STRING_EXACTLY,
     FC_STRING_NOT_EXACTLY,
+    FC_STRING_STARTS_WITH,
+    FC_STRING_ENDS_WITH,
 )
 from mitosheet.tests.test_utils import create_mito_wrapper, create_mito_wrapper_dfs
 
@@ -181,6 +183,18 @@
         "1",
         pd.DataFrame(data={"A": ["3", "4", "5", "6"]}, index=list(range(2, 6))),
     ),
+    (
+        pd.DataFrame(data={"A": ["1", "12", "31"]}),
+        FC_STRING_STARTS_WITH,
+        "1",
+        pd.DataFrame(data={"A": ["1", "12"]}),
+    ),
+    (
+        pd.DataFrame(data={"A": ["12", "122222", "31"]}),
+        FC_STRING_ENDS_WITH,
+        "2",
+        pd.DataFrame(data={"A": ["12", "122222"]}),
+    ),
     (
         pd.DataFrame(data={"A": ["1", "12", "3", "4", "5", "6"]}),
         FC_STRING_EXACTLY,
@@ -874,6 +888,38 @@ def test_boolean_and_empty_collapses_to_one_check():
         2,
         "df1 = df1[df1['A'].apply(lambda val: any(val <= n for n in [1, 2]))]",
     ),
+    (
+        pd.DataFrame({"A": ["123", "1334", "4567"]}),
+        FC_STRING_STARTS_WITH,
+        "And",
+        "1",
+        "12",
+        "df1 = df1[df1['A'].apply(lambda val: all(str(val).startswith(s) for s in ['1', '12']))]",
+    ),
+    (
+        pd.DataFrame({"A": ["123", "1334", "4567"]}),
+        FC_STRING_STARTS_WITH,
+        "Or",
+        "1",
+        "4",
+        "df1 = df1[df1['A'].apply(lambda val: any(str(val).startswith(s) for s in ['1', '4']))]",
+    ),
+    (
+        pd.DataFrame({"A": ["123", "1334", "4567"]}),
+        FC_STRING_ENDS_WITH,
+        "And",
+        "1",
+        "12",
+        "df1 = df1[df1['A'].apply(lambda val: all(str(val).endswith(s) for s in ['1', '12']))]",
+    ),
+    (
+        pd.DataFrame({"A": ["123", "1334", "4567"]}),
+        FC_STRING_ENDS_WITH,
+        "Or",
+        "1",
+        "4",
+        "df1 = df1[df1['A'].apply(lambda val: any(str(val).endswith(s) for s in ['1', '4']))]",
+    ),
     (
         pd.DataFrame(
             {