Repository: PostHog/posthog
Pull request: Support `href`/`text` element filters
## Changes

Resolves #5598.

## Checklist

- [x] All querysets/queries filter by Organization, by Team, and by User
- [x] Django backend tests

Problem statement:
Filtering element `href` by regex does not work
Trying to add an href filter containing URL path (/admin/sites/*/courses/42400-example-course), but it’s returning 0 results, when I use a wildcard (% or *), but when I search a specific path (/admin/sites/56306/courses/42400-example-course), it returns results. What am I doing wrong?

Hints from issue discussion:
Can you add a screenshot of the specific filter? Are you using `equals` or `matches regex` or something else?

In this case you want regexes - see an example in the image below.

![image](https://user-images.githubusercontent.com/148820/129593641-7e93a3ec-b54d-49ff-bfd7-eabca203cc6b.png)

![Screen Shot 2021-08-17 at 10 07 49 AM](https://user-images.githubusercontent.com/91264/129741051-c298fcbf-b125-4551-97bb-cf1c3bee1b57.png)

Still returning 0 results. 

This is the string I'm using: https://regex101.com/r/su1hbC/1
I haven't tested this, but you might not need the \ escaping here. See also the screenshot I linked above :)
I'e tried both ways and still no success. Really weird!
Note: We did a quick call, the original intent was to track certain links via actions. I suggested using `data-attributes` for identifying links. More info on this here: https://posthog.com/docs/user-guides/actions#matching-selectors

Follow-up action for me: check if regexes behave as intended on actions page :sweat_smile: 
So this is a real bug.

## Description

When searching based on `href` element property based on a regex, the regex is incorrectly escaped.

## How to reproduce

- Go to events
- Filter by href, select regex, choose one href it autosuggests. See events
- Replace part of the URL with `.*` 
- See no results

## Expected outcome

Same results

## Additional context

Same occurs on `actions` page.

Here's an example clickhouse query demonstrating the escaping. See escaped `.*` at the end

```sql
SELECT uuid,
       event,
       properties,
       timestamp,
       team_id,
       distinct_id,
       elements_chain,
       created_at
  FROM events
 WHERE team_id = 1
   AND timestamp < '2021-08-19 07:46:19.016569'
   AND ((event = '$autocapture' AND (match(elements_chain, '.*?(href="/action/1\\#backTo=Actions\\&backToURL=/events/\\.\\*").*?'))))
 ORDER BY toDate(timestamp) DESC, timestamp DESC
 LIMIT 101
```

Commit message: Fix `icontains` and support `href/`text` element filters

Patch to review:
diff --git a/ee/clickhouse/models/action.py b/ee/clickhouse/models/action.py
index 33a7eb71c8dd3..5076f910a5206 100644
--- a/ee/clickhouse/models/action.py
+++ b/ee/clickhouse/models/action.py
@@ -25,7 +25,7 @@ def format_action_filter(
         if step.event == AUTOCAPTURE_EVENT:
             from ee.clickhouse.models.property import filter_element  # prevent circular import
 
-            el_condition, element_params = filter_element(model_to_dict(step), f"{action.pk}_{index}{prepend}")
+            el_condition, element_params = filter_element(model_to_dict(step), prepend=f"{action.pk}_{index}{prepend}")
             params = {**params, **element_params}
             if len(el_condition) > 0:
                 conditions.append(el_condition)
diff --git a/ee/clickhouse/models/property.py b/ee/clickhouse/models/property.py
index ddbe585eadc3a..7c58a78be863d 100644
--- a/ee/clickhouse/models/property.py
+++ b/ee/clickhouse/models/property.py
@@ -11,7 +11,7 @@
 from ee.clickhouse.sql.person import GET_DISTINCT_IDS_BY_PROPERTY_SQL
 from posthog.models.cohort import Cohort
 from posthog.models.event import Selector
-from posthog.models.property import Property, PropertyName, PropertyType
+from posthog.models.property import NEGATED_OPERATORS, OperatorType, Property, PropertyName, PropertyType
 from posthog.models.team import Team
 from posthog.utils import is_valid_regex, relative_date_parse
 
@@ -64,7 +64,9 @@ def parse_prop_clauses(
                 )
                 params.update(filter_params)
         elif prop.type == "element":
-            query, filter_params = filter_element({prop.key: prop.value}, prepend="{}_".format(idx))
+            query, filter_params = filter_element(
+                {prop.key: prop.value}, operator=prop.operator, prepend="{}_".format(idx)
+            )
             final.append(f" AND {query if len(query) > 0 else '1=2'}")
             params.update(filter_params)
         else:
@@ -101,14 +103,14 @@ def prop_filter_json_extract(
         value = "%{}%".format(prop.value)
         params = {"k{}_{}".format(prepend, idx): prop.key, "v{}_{}".format(prepend, idx): value}
         return (
-            "AND {left} LIKE %(v{prepend}_{idx})s".format(idx=idx, prepend=prepend, left=property_expr),
+            "AND {left} ILIKE %(v{prepend}_{idx})s".format(idx=idx, prepend=prepend, left=property_expr),
             params,
         )
     elif operator == "not_icontains":
         value = "%{}%".format(prop.value)
         params = {"k{}_{}".format(prepend, idx): prop.key, "v{}_{}".format(prepend, idx): value}
         return (
-            "AND NOT ({left} LIKE %(v{prepend}_{idx})s)".format(idx=idx, prepend=prepend, left=property_expr),
+            "AND NOT ({left} ILIKE %(v{prepend}_{idx})s)".format(idx=idx, prepend=prepend, left=property_expr),
             params,
         )
     elif operator in ("regex", "not_regex"):
@@ -225,50 +227,60 @@ def get_property_values_for_key(key: str, team: Team, value: Optional[str] = Non
     )
 
 
-def filter_element(filters: Dict, prepend: str = "") -> Tuple[str, Dict]:
+def filter_element(filters: Dict, *, operator: Optional[OperatorType] = None, prepend: str = "") -> Tuple[str, Dict]:
+    if not operator:
+        operator = "exact"
+
     params = {}
     conditions = []
 
     if filters.get("selector"):
-        or_conditions = []
         selectors = filters["selector"] if isinstance(filters["selector"], list) else [filters["selector"]]
         for idx, query in enumerate(selectors):
             selector = Selector(query, escape_slashes=False)
             key = f"{prepend}_{idx}_selector_regex"
             params[key] = _create_regex(selector)
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     if filters.get("tag_name"):
-        or_conditions = []
         tag_names = filters["tag_name"] if isinstance(filters["tag_name"], list) else [filters["tag_name"]]
         for idx, tag_name in enumerate(tag_names):
             key = f"{prepend}_{idx}_tag_name_regex"
             params[key] = rf"(^|;){tag_name}(\.|$|;|:)"
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     attributes: Dict[str, List] = {}
-
     for key in ["href", "text"]:
-        vals = filters.get(key)
-        if filters.get(key):
-            attributes[key] = [re.escape(vals)] if isinstance(vals, str) else [re.escape(text) for text in filters[key]]
-
-    if len(attributes.keys()) > 0:
-        or_conditions = []
+        if vals := filters.get(key):
+            if operator.endswith("_set"):
+                attributes[key] = [r".*"]
+            else:
+                vals = [vals] if isinstance(vals, str) else vals
+                if operator.endswith("icontains"):
+                    attributes[key] = [f".*{re.escape(text)}.*" for text in vals]
+                elif operator.endswith("regex"):
+                    attributes[key] = vals
+                else:
+                    attributes[key] = [re.escape(text) for text in vals]
+    is_negated = operator in NEGATED_OPERATORS
+    if attributes:
         for key, value_list in attributes.items():
             for idx, value in enumerate(value_list):
-                params["{}_{}_{}_attributes_regex".format(prepend, key, idx)] = ".*?({}).*?".format(
-                    ".*?".join(['{}="{}"'.format(key, value)])
-                )
-                or_conditions.append("match(elements_chain, %({}_{}_{}_attributes_regex)s)".format(prepend, key, idx))
-            if len(or_conditions) > 0:
-                conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+                optional_flag = "(?i)" if operator.endswith("icontains") else ""
+                params[f"{prepend}_{key}_{idx}_attributes_regex"] = f'{optional_flag}({key}="{value}")'
+                conditions.append(f"match(elements_chain, %({prepend}_{key}_{idx}_attributes_regex)s)")
+            if len(conditions) > 1:
+                conditions.append(f"({' AND '.join(conditions)})")
+    if conditions:
+        final_conditions = f"{'NOT ' if is_negated else ''}({' OR '.join(conditions)})"
+    else:
+        final_conditions = "" if is_negated else "0 = 192"
 
-    return (" AND ".join(conditions), params)
+    return final_conditions, params
 
 
 def _create_regex(selector: Selector) -> str:
diff --git a/ee/clickhouse/models/test/test_property.py b/ee/clickhouse/models/test/test_property.py
index cbe3b89ee3b32..0484ce665d3bc 100644
--- a/ee/clickhouse/models/test/test_property.py
+++ b/ee/clickhouse/models/test/test_property.py
@@ -60,8 +60,19 @@ def test_prop_event(self):
             event="$pageview", team=self.team, distinct_id="whatever", properties={"attr": "some_val"},
         )
 
-        filter = Filter(data={"properties": [{"key": "attr", "value": "some_val"}],})
-        self.assertEqual(len(self._run_query(filter)), 1)
+        filter_exact = Filter(data={"properties": [{"key": "attr", "value": "some_val"}],})
+        self.assertEqual(len(self._run_query(filter_exact)), 1)
+
+        filter_regex = Filter(data={"properties": [{"key": "attr", "value": "some_.+_val", "operator": "regex"}],})
+        self.assertEqual(len(self._run_query(filter_regex)), 1)
+
+        filter_icontains = Filter(data={"properties": [{"key": "attr", "value": "Some_Val", "operator": "icontains"}],})
+        self.assertEqual(len(self._run_query(filter_icontains)), 1)
+
+        filter_not_icontains = Filter(
+            data={"properties": [{"key": "attr", "value": "other", "operator": "not_icontains"}],}
+        )
+        self.assertEqual(len(self._run_query(filter_not_icontains)), 1)
 
     def test_prop_selector_tag_name(self):
         _create_event(
@@ -85,7 +96,7 @@ def test_prop_selector_tag_name(self):
             ],
         )
         _create_event(
-            event="$pageview",
+            event="$autocapture",
             team=self.team,
             distinct_id="whatever",
             properties={"attr": "some_val"},
@@ -104,18 +115,27 @@ def test_prop_selector_tag_name(self):
                 Element(tag_name="img", nth_child=0, nth_of_type=0, attr_id="nested",),
             ],
         )
+        _create_event(
+            event="$autocapture",
+            team=self.team,
+            distinct_id="whatever",
+            elements=[
+                Element(tag_name="a", href="/789", nth_child=0, nth_of_type=0,),
+                Element(tag_name="button", attr_class=["btn", "btn-tertiary"], nth_child=0, nth_of_type=0),
+            ],
+        )
 
         # selector
 
         filter = Filter(
             data={"properties": [{"key": "selector", "value": [".btn"], "operator": "exact", "type": "element"}]}
         )
-        self.assertEqual(len(self._run_query(filter)), 2)
+        self.assertEqual(len(self._run_query(filter)), 3)
 
         filter = Filter(
             data={"properties": [{"key": "selector", "value": ".btn", "operator": "exact", "type": "element"}]}
         )
-        self.assertEqual(len(self._run_query(filter)), 2)
+        self.assertEqual(len(self._run_query(filter)), 3)
 
         filter = Filter(
             data={
@@ -179,6 +199,53 @@ def test_prop_selector_tag_name(self):
         )
         self.assertEqual(len(self._run_query(filter)), 2)
 
+        # href
+
+        filter_href_exact = Filter(
+            data={"properties": [{"key": "href", "value": ["/a-url"], "operator": "exact", "type": "element"}]}
+        )
+        self.assertEqual(len(self._run_query(filter_href_exact)), 2)
+
+        filter_href_exact_double = Filter(
+            data={"properties": [{"key": "href", "value": ["/a-url", "/789"], "operator": "exact", "type": "element"}]}
+        )
+        self.assertEqual(len(self._run_query(filter_href_exact_double)), 3)
+
+        filter_href_is_not = Filter(
+            data={"properties": [{"key": "href", "value": ["/a-url"], "operator": "is_not", "type": "element"}]}
+        )
+        self.assertEqual(len(self._run_query(filter_href_is_not)), 1)
+
+        filter_href_is_not_double = Filter(
+            data={"properties": [{"key": "href", "value": ["/a-url", "/789"], "operator": "is_not", "type": "element"}]}
+        )
+        self.assertEqual(len(self._run_query(filter_href_is_not_double)), 0)
+
+        filter_href_exact_with_tag_name_is_not = Filter(
+            data={
+                "properties": [
+                    {"key": "href", "value": ["/a-url"], "type": "element"},
+                    {"key": "tag_name", "value": ["marquee"], "operator": "is_not", "type": "element"},
+                ]
+            }
+        )
+        self.assertEqual(len(self._run_query(filter_href_exact_with_tag_name_is_not)), 2)
+
+        filter_href_icontains = Filter(
+            data={"properties": [{"key": "href", "value": ["UrL"], "operator": "icontains", "type": "element"}]}
+        )
+        self.assertEqual(len(self._run_query(filter_href_icontains)), 2)
+
+        filter_href_regex = Filter(
+            data={"properties": [{"key": "href", "value": "/a-.+", "operator": "regex", "type": "element"}]}
+        )
+        self.assertEqual(len(self._run_query(filter_href_regex)), 2)
+
+        filter_href_not_regex = Filter(
+            data={"properties": [{"key": "href", "value": r"/\d+", "operator": "not_regex", "type": "element"}]}
+        )
+        self.assertEqual(len(self._run_query(filter_href_not_regex)), 2)
+
     def test_prop_ints_saved_as_strings(self):
         _create_event(
             event="$pageview", team=self.team, distinct_id="whatever", properties={"test_prop": "0"},
diff --git a/ee/clickhouse/queries/event_query.py b/ee/clickhouse/queries/event_query.py
index 5cd60caaea0cc..9911476c6a3c0 100644
--- a/ee/clickhouse/queries/event_query.py
+++ b/ee/clickhouse/queries/event_query.py
@@ -171,7 +171,9 @@ def _get_props(self, filters: List[Property], allow_denormalized_props: bool = F
                 final.append(filter_query)
                 params.update(filter_params)
             elif prop.type == "element":
-                query, filter_params = filter_element({prop.key: prop.value}, prepend="{}_".format(idx))
+                query, filter_params = filter_element(
+                    {prop.key: prop.value}, operator=prop.operator, prepend="{}_".format(idx)
+                )
                 final.append(f" AND {query if len(query) > 0 else '1=2'}")
                 params.update(filter_params)
             else:
diff --git a/posthog/models/property.py b/posthog/models/property.py
index 19ea27e4ac064..054b79f8d8d59 100644
--- a/posthog/models/property.py
+++ b/posthog/models/property.py
@@ -12,6 +12,8 @@
     "exact", "is_not", "icontains", "not_icontains", "regex", "not_regex", "gt", "lt", "is_set", "is_not_set",
 ]
 
+NEGATED_OPERATORS = ["is_not", "not_icontains", "not_regex", "is_not_set"]
+
 
 class Property:
     key: str


Code review comments:
diff_hunk: @@ -225,50 +227,60 @@ def get_property_values_for_key(key: str, team: Team, value: Optional[str] = Non
     )
 
 
-def filter_element(filters: Dict, prepend: str = "") -> Tuple[str, Dict]:
+def filter_element(filters: Dict, *, operator: Optional[OperatorType] = None, prepend: str = "") -> Tuple[str, Dict]:
+    if not operator:
+        operator = "exact"
+
     params = {}
     conditions = []
 
     if filters.get("selector"):
-        or_conditions = []
         selectors = filters["selector"] if isinstance(filters["selector"], list) else [filters["selector"]]
         for idx, query in enumerate(selectors):
             selector = Selector(query, escape_slashes=False)
             key = f"{prepend}_{idx}_selector_regex"
             params[key] = _create_regex(selector)
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     if filters.get("tag_name"):
-        or_conditions = []
         tag_names = filters["tag_name"] if isinstance(filters["tag_name"], list) else [filters["tag_name"]]
         for idx, tag_name in enumerate(tag_names):
             key = f"{prepend}_{idx}_tag_name_regex"
             params[key] = rf"(^|;){tag_name}(\.|$|;|:)"
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     attributes: Dict[str, List] = {}
-
     for key in ["href", "text"]:
-        vals = filters.get(key)
-        if filters.get(key):
-            attributes[key] = [re.escape(vals)] if isinstance(vals, str) else [re.escape(text) for text in filters[key]]
-
-    if len(attributes.keys()) > 0:
-        or_conditions = []
+        if vals := filters.get(key):
+            if operator.endswith("_set"):
+                attributes[key] = [r".*"]
+            else:
+                vals = [vals] if isinstance(vals, str) else vals
+                if operator.endswith("icontains"):
+                    attributes[key] = [f".*{re.escape(text)}.*" for text in vals]
+                elif operator.endswith("regex"):
+                    attributes[key] = vals
+                else:
+                    attributes[key] = [re.escape(text) for text in vals]
+    is_negated = operator in NEGATED_OPERATORS
+    if attributes:
         for key, value_list in attributes.items():
             for idx, value in enumerate(value_list):
-                params["{}_{}_{}_attributes_regex".format(prepend, key, idx)] = ".*?({}).*?".format(
-                    ".*?".join(['{}="{}"'.format(key, value)])
-                )
-                or_conditions.append("match(elements_chain, %({}_{}_{}_attributes_regex)s)".format(prepend, key, idx))
-            if len(or_conditions) > 0:
-                conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+                optional_flag = "(?i)" if operator.endswith("icontains") else ""
+                params[f"{prepend}_{key}_{idx}_attributes_regex"] = f'{optional_flag}({key}="{value}")'
+                conditions.append(f"match(elements_chain, %({prepend}_{key}_{idx}_attributes_regex)s)")
+            if len(conditions) > 1:
+                conditions.append(f"({' AND '.join(conditions)})")
+    if conditions:
+        final_conditions = f"{'NOT ' if is_negated else ''}({' OR '.join(conditions)})"
+    else:
+        final_conditions = "" if is_negated else "0 = 192"
path: ee/clickhouse/models/property.py
text: @user1:
This does not match the previous behavior - this is a new bug!

@author:
I don't think I see the bug, could you set out an example?

@user1:
The behavior changed when calling `filter_element` from `format_action_filter` on an autocapture event. Having no conditions is completely valid there - previously this function returned an empty string, now it returns `0 = 1`

@author:
Right – made things smarter.

@user1:
+1 good catch re behavior in models/property here :sweat_smile:

diff_hunk: @@ -225,50 +227,60 @@ def get_property_values_for_key(key: str, team: Team, value: Optional[str] = Non
     )
 
 
-def filter_element(filters: Dict, prepend: str = "") -> Tuple[str, Dict]:
+def filter_element(filters: Dict, *, operator: Optional[OperatorType] = None, prepend: str = "") -> Tuple[str, Dict]:
+    if not operator:
+        operator = "exact"
+
     params = {}
     conditions = []
 
     if filters.get("selector"):
-        or_conditions = []
         selectors = filters["selector"] if isinstance(filters["selector"], list) else [filters["selector"]]
         for idx, query in enumerate(selectors):
             selector = Selector(query, escape_slashes=False)
             key = f"{prepend}_{idx}_selector_regex"
             params[key] = _create_regex(selector)
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     if filters.get("tag_name"):
-        or_conditions = []
         tag_names = filters["tag_name"] if isinstance(filters["tag_name"], list) else [filters["tag_name"]]
         for idx, tag_name in enumerate(tag_names):
             key = f"{prepend}_{idx}_tag_name_regex"
             params[key] = rf"(^|;){tag_name}(\.|$|;|:)"
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     attributes: Dict[str, List] = {}
-
     for key in ["href", "text"]:
-        vals = filters.get(key)
-        if filters.get(key):
-            attributes[key] = [re.escape(vals)] if isinstance(vals, str) else [re.escape(text) for text in filters[key]]
-
-    if len(attributes.keys()) > 0:
-        or_conditions = []
+        if vals := filters.get(key):
+            if operator.endswith("_set"):
+                attributes[key] = [r".*"]
+            else:
+                vals = [vals] if isinstance(vals, str) else vals
+                if operator.endswith("icontains"):
+                    attributes[key] = [f".*{re.escape(text)}.*" for text in vals]
+                elif operator.endswith("regex"):
+                    attributes[key] = vals
+                else:
+                    attributes[key] = [re.escape(text) for text in vals]
+    is_negated = operator in NEGATED_OPERATORS
+    if attributes:
         for key, value_list in attributes.items():
             for idx, value in enumerate(value_list):
-                params["{}_{}_{}_attributes_regex".format(prepend, key, idx)] = ".*?({}).*?".format(
-                    ".*?".join(['{}="{}"'.format(key, value)])
-                )
-                or_conditions.append("match(elements_chain, %({}_{}_{}_attributes_regex)s)".format(prepend, key, idx))
-            if len(or_conditions) > 0:
-                conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+                optional_flag = "(?i)" if operator.endswith("icontains") else ""
path: ee/clickhouse/models/property.py
text: @user1:
This is out of sync with what we were doing in property.py.

We should use `ILIKE` here as well instead of a custom solution.

@author:
We already use regex for everything in here, I think this significantly simpler and less error-prone without an extra branch for `(not_)icontains`-only `ILIKE`.

@user1:
See the main comment I made around this - I think this is scope creep and shouldn't be in this PR.


That said, a proper solution would still unify this with `properties.py` in terms of approaches to icontains. Having similar, but different approaches is just a recipe for bugs/inconsistencies down the line.

@author:
Normal property filtering involves values from parsed JSON though, while in this case we use the elements chain directly, so the benefit of a regex is that we can handle e.g. double quote characters better.

diff_hunk: @@ -225,50 +227,60 @@ def get_property_values_for_key(key: str, team: Team, value: Optional[str] = Non
     )
 
 
-def filter_element(filters: Dict, prepend: str = "") -> Tuple[str, Dict]:
+def filter_element(filters: Dict, *, operator: Optional[OperatorType] = None, prepend: str = "") -> Tuple[str, Dict]:
+    if not operator:
+        operator = "exact"
+
     params = {}
     conditions = []
 
     if filters.get("selector"):
-        or_conditions = []
         selectors = filters["selector"] if isinstance(filters["selector"], list) else [filters["selector"]]
         for idx, query in enumerate(selectors):
             selector = Selector(query, escape_slashes=False)
             key = f"{prepend}_{idx}_selector_regex"
             params[key] = _create_regex(selector)
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     if filters.get("tag_name"):
-        or_conditions = []
         tag_names = filters["tag_name"] if isinstance(filters["tag_name"], list) else [filters["tag_name"]]
         for idx, tag_name in enumerate(tag_names):
             key = f"{prepend}_{idx}_tag_name_regex"
             params[key] = rf"(^|;){tag_name}(\.|$|;|:)"
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     attributes: Dict[str, List] = {}
-
     for key in ["href", "text"]:
-        vals = filters.get(key)
-        if filters.get(key):
-            attributes[key] = [re.escape(vals)] if isinstance(vals, str) else [re.escape(text) for text in filters[key]]
-
-    if len(attributes.keys()) > 0:
-        or_conditions = []
+        if vals := filters.get(key):
+            if operator.endswith("_set"):
+                attributes[key] = [r".*"]
+            else:
+                vals = [vals] if isinstance(vals, str) else vals
+                if operator.endswith("icontains"):
+                    attributes[key] = [f".*{re.escape(text)}.*" for text in vals]
+                elif operator.endswith("regex"):
+                    attributes[key] = vals
+                else:
+                    attributes[key] = [re.escape(text) for text in vals]
+    is_negated = operator in NEGATED_OPERATORS
+    if attributes:
path: ee/clickhouse/models/property.py
text: @user1:
Relying on python empty objects being falsy is a bit shaky. Better to be explicit here.

@author:
It's not shaky, it's the "pythonic" way of doing this.

@user1:
Hmm, checked out [pep8](https://www.python.org/dev/peps/pep-0008/). Fair regarding sequences: 

`For sequences, (strings, lists, tuples), use the fact that empty sequences are false:`

This does not mention dicts but w/e. :) Might be a footgun in there if using your own collections class.

But keep this also in mind then:

```
Comparisons to singletons like None should always be done with is or is not, never the equality operators.

Also, beware of writing if x when you really mean if x is not None -- e.g. when testing whether a variable or argument that defaults to None was set to some other value. The other value might have a type (such as a container) that could be false in a boolean context!
```

We violate this just above in this file.

@author:
Yeah, the lacking null checks are a fair point, improved that.

diff_hunk: @@ -225,50 +227,60 @@ def get_property_values_for_key(key: str, team: Team, value: Optional[str] = Non
     )
 
 
-def filter_element(filters: Dict, prepend: str = "") -> Tuple[str, Dict]:
+def filter_element(filters: Dict, *, operator: Optional[OperatorType] = None, prepend: str = "") -> Tuple[str, Dict]:
+    if not operator:
+        operator = "exact"
+
     params = {}
     conditions = []
 
     if filters.get("selector"):
-        or_conditions = []
         selectors = filters["selector"] if isinstance(filters["selector"], list) else [filters["selector"]]
         for idx, query in enumerate(selectors):
             selector = Selector(query, escape_slashes=False)
             key = f"{prepend}_{idx}_selector_regex"
             params[key] = _create_regex(selector)
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     if filters.get("tag_name"):
-        or_conditions = []
         tag_names = filters["tag_name"] if isinstance(filters["tag_name"], list) else [filters["tag_name"]]
         for idx, tag_name in enumerate(tag_names):
             key = f"{prepend}_{idx}_tag_name_regex"
             params[key] = rf"(^|;){tag_name}(\.|$|;|:)"
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     attributes: Dict[str, List] = {}
-
     for key in ["href", "text"]:
-        vals = filters.get(key)
-        if filters.get(key):
-            attributes[key] = [re.escape(vals)] if isinstance(vals, str) else [re.escape(text) for text in filters[key]]
-
-    if len(attributes.keys()) > 0:
-        or_conditions = []
+        if vals := filters.get(key):
+            if operator.endswith("_set"):
+                attributes[key] = [r".*"]
+            else:
+                vals = [vals] if isinstance(vals, str) else vals
+                if operator.endswith("icontains"):
+                    attributes[key] = [f".*{re.escape(text)}.*" for text in vals]
+                elif operator.endswith("regex"):
+                    attributes[key] = vals
+                else:
+                    attributes[key] = [re.escape(text) for text in vals]
+    is_negated = operator in NEGATED_OPERATORS
path: ee/clickhouse/models/property.py
text: @user1:
Style nit: I'd inline this given it's used N lines down and breaks up the flow of reading this complicated function.

diff_hunk: @@ -225,50 +227,60 @@ def get_property_values_for_key(key: str, team: Team, value: Optional[str] = Non
     )
 
 
-def filter_element(filters: Dict, prepend: str = "") -> Tuple[str, Dict]:
+def filter_element(filters: Dict, *, operator: Optional[OperatorType] = None, prepend: str = "") -> Tuple[str, Dict]:
+    if not operator:
+        operator = "exact"
+
     params = {}
     conditions = []
 
     if filters.get("selector"):
-        or_conditions = []
         selectors = filters["selector"] if isinstance(filters["selector"], list) else [filters["selector"]]
         for idx, query in enumerate(selectors):
             selector = Selector(query, escape_slashes=False)
             key = f"{prepend}_{idx}_selector_regex"
             params[key] = _create_regex(selector)
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     if filters.get("tag_name"):
-        or_conditions = []
         tag_names = filters["tag_name"] if isinstance(filters["tag_name"], list) else [filters["tag_name"]]
         for idx, tag_name in enumerate(tag_names):
             key = f"{prepend}_{idx}_tag_name_regex"
             params[key] = rf"(^|;){tag_name}(\.|$|;|:)"
-            or_conditions.append(f"match(elements_chain, %({key})s)")
-        if len(or_conditions) > 0:
-            conditions.append("(" + (" OR ".join(or_conditions)) + ")")
+            conditions.append(f"match(elements_chain, %({key})s)")
+        if len(conditions) > 0:
+            conditions.append("(" + (" OR ".join(conditions)) + ")")
 
     attributes: Dict[str, List] = {}
-
     for key in ["href", "text"]:
-        vals = filters.get(key)
-        if filters.get(key):
-            attributes[key] = [re.escape(vals)] if isinstance(vals, str) else [re.escape(text) for text in filters[key]]
-
-    if len(attributes.keys()) > 0:
-        or_conditions = []
+        if vals := filters.get(key):
path: ee/clickhouse/models/property.py
text: @user1:
Code style nit: IMO relying in None here is shaky and makes this hard to follow since vals is only used N lines down.

How about:

```
if key in filters
```

However looking at the code a bit more I think it could benefit from extracting a function.

```
if key in filters:
  attributes[key] = get_element_match(filters[key], operator or "exact")

def get_element_match(values: Union[str, List[str]], operator: OperatorType):
  # Do the if-else here
```