]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
icontains,istartswith,iendswith operators
authorMatias Martinez Rebori <matias.martinez@dinapi.gov.py>
Wed, 7 Sep 2022 12:42:57 +0000 (08:42 -0400)
committerMatias Martinez Rebori <matias.martinez@dinapi.gov.py>
Wed, 7 Sep 2022 12:42:57 +0000 (08:42 -0400)
lib/sqlalchemy/sql/compiler.py
lib/sqlalchemy/sql/default_comparator.py
lib/sqlalchemy/sql/operators.py
test/sql/test_operators.py

index 6d9d47388d20301ff281b1780f1a8920d813d4fd..13774b512d78409f7f857b13391a9ea7b19d6a5f 100644 (file)
@@ -2700,6 +2700,20 @@ class SQLCompiler(Compiled):
         binary.right = percent.concat(binary.right).concat(percent)
         return self.visit_not_like_op_binary(binary, operator, **kw)
 
+    def visit_icontains_op_binary(self, binary, operator, **kw):
+        binary = binary._clone()
+        percent = self._like_percent_literal
+        binary.left = functions.func.lower(binary.left)
+        binary.right = percent.concat(functions.func.lower(binary.right)).concat(percent)
+        return self.visit_like_op_binary(binary, operator, **kw)
+
+    def visit_not_icontains_op_binary(self, binary, operator, **kw):
+        binary = binary._clone()
+        percent = self._like_percent_literal
+        binary.left = functions.func.lower(binary.left)
+        binary.right = percent.concat(functions.func.lower(binary.right)).concat(percent)
+        return self.visit_not_like_op_binary(binary, operator, **kw)
+
     def visit_startswith_op_binary(self, binary, operator, **kw):
         binary = binary._clone()
         percent = self._like_percent_literal
@@ -2712,6 +2726,20 @@ class SQLCompiler(Compiled):
         binary.right = percent._rconcat(binary.right)
         return self.visit_not_like_op_binary(binary, operator, **kw)
 
+    def visit_istartswith_op_binary(self, binary, operator, **kw):
+        binary = binary._clone()
+        percent = self._like_percent_literal
+        binary.left = functions.func.lower(binary.left)
+        binary.right = percent._rconcat(functions.func.lower(binary.right))
+        return self.visit_like_op_binary(binary, operator, **kw)
+
+    def visit_not_istartswith_op_binary(self, binary, operator, **kw):
+        binary = binary._clone()
+        percent = self._like_percent_literal
+        binary.left = functions.func.lower(binary.left)
+        binary.right = percent._rconcat(functions.func.lower(binary.right))
+        return self.visit_not_like_op_binary(binary, operator, **kw)
+
     def visit_endswith_op_binary(self, binary, operator, **kw):
         binary = binary._clone()
         percent = self._like_percent_literal
@@ -2724,6 +2752,20 @@ class SQLCompiler(Compiled):
         binary.right = percent.concat(binary.right)
         return self.visit_not_like_op_binary(binary, operator, **kw)
 
+    def visit_iendswith_op_binary(self, binary, operator, **kw):
+        binary = binary._clone()
+        percent = self._like_percent_literal
+        binary.left = functions.func.lower(binary.left)
+        binary.right = percent.concat(functions.func.lower(binary.right))
+        return self.visit_like_op_binary(binary, operator, **kw)
+
+    def visit_not_iendswith_op_binary(self, binary, operator, **kw):
+        binary = binary._clone()
+        percent = self._like_percent_literal
+        binary.left = functions.func.lower(binary.left)
+        binary.right = percent.concat(functions.func.lower(binary.right))
+        return self.visit_not_like_op_binary(binary, operator, **kw)
+
     def visit_like_op_binary(self, binary, operator, **kw):
         escape = binary.modifiers.get("escape", None)
 
index 619be2cd1ee1cd1b48e4fa3646fe246277800485..49ca05dad64613b6fe9626be2cc26c842aff2a22 100644 (file)
@@ -468,14 +468,26 @@ operator_lookup: Dict[
         _boolean_compare,
         util.immutabledict({"negate_op": operators.not_contains_op}),
     ),
+    "icontains_op": (
+        _boolean_compare,
+        util.immutabledict({"negate_op": operators.not_icontains_op}),
+    ),
     "startswith_op": (
         _boolean_compare,
         util.immutabledict({"negate_op": operators.not_startswith_op}),
     ),
+    "istartswith_op": (
+        _boolean_compare,
+        util.immutabledict({"negate_op": operators.not_istartswith_op}),
+    ),
     "endswith_op": (
         _boolean_compare,
         util.immutabledict({"negate_op": operators.not_endswith_op}),
     ),
+    "iendswith_op": (
+        _boolean_compare,
+        util.immutabledict({"negate_op": operators.not_iendswith_op}),
+    ),
     "desc_op": (
         _scalar,
         util.immutabledict({"fn": UnaryExpression._create_desc}),
index 44d63b3987556aa01c27c3edf2cb3b441076f1ec..f9e4a995f0de8878718be3cfb7c0124a3f582328 100644 (file)
@@ -65,11 +65,11 @@ class OperatorType(Protocol):
     __name__: str
 
     def __call__(
-        self,
-        left: "Operators",
-        right: Optional[Any] = None,
-        *other: Any,
-        **kwargs: Any,
+            self,
+            left: "Operators",
+            right: Optional[Any] = None,
+            *other: Any,
+            **kwargs: Any,
     ) -> "Operators":
         ...
 
@@ -179,14 +179,14 @@ class Operators:
         return self.operate(inv)
 
     def op(
-        self,
-        opstring: str,
-        precedence: int = 0,
-        is_comparison: bool = False,
-        return_type: Optional[
-            Union[Type["TypeEngine[Any]"], "TypeEngine[Any]"]
-        ] = None,
-        python_impl: Optional[Callable[..., Any]] = None,
+            self,
+            opstring: str,
+            precedence: int = 0,
+            is_comparison: bool = False,
+            return_type: Optional[
+                Union[Type["TypeEngine[Any]"], "TypeEngine[Any]"]
+            ] = None,
+            python_impl: Optional[Callable[..., Any]] = None,
     ) -> Callable[[Any], Operators]:
         """Produce a generic operator function.
 
@@ -279,10 +279,10 @@ class Operators:
         return against
 
     def bool_op(
-        self,
-        opstring: str,
-        precedence: int = 0,
-        python_impl: Optional[Callable[..., Any]] = None,
+            self,
+            opstring: str,
+            precedence: int = 0,
+            python_impl: Optional[Callable[..., Any]] = None,
     ) -> Callable[[Any], Operators]:
         """Return a custom boolean operator.
 
@@ -306,7 +306,7 @@ class Operators:
         )
 
     def operate(
-        self, op: OperatorType, *other: Any, **kwargs: Any
+            self, op: OperatorType, *other: Any, **kwargs: Any
     ) -> Operators:
         r"""Operate on an argument.
 
@@ -336,7 +336,7 @@ class Operators:
     __sa_operate__ = operate
 
     def reverse_operate(
-        self, op: OperatorType, other: Any, **kwargs: Any
+            self, op: OperatorType, other: Any, **kwargs: Any
     ) -> Operators:
         """Reverse operate on an argument.
 
@@ -385,16 +385,16 @@ class custom_op(OperatorType, Generic[_T]):
     )
 
     def __init__(
-        self,
-        opstring: str,
-        precedence: int = 0,
-        is_comparison: bool = False,
-        return_type: Optional[
-            Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"]
-        ] = None,
-        natural_self_precedent: bool = False,
-        eager_grouping: bool = False,
-        python_impl: Optional[Callable[..., Any]] = None,
+            self,
+            opstring: str,
+            precedence: int = 0,
+            is_comparison: bool = False,
+            return_type: Optional[
+                Union[Type["TypeEngine[_T]"], "TypeEngine[_T]"]
+            ] = None,
+            natural_self_precedent: bool = False,
+            eager_grouping: bool = False,
+            python_impl: Optional[Callable[..., Any]] = None,
     ):
         self.opstring = opstring
         self.precedence = precedence
@@ -413,11 +413,11 @@ class custom_op(OperatorType, Generic[_T]):
         return id(self)
 
     def __call__(
-        self,
-        left: Operators,
-        right: Optional[Any] = None,
-        *other: Any,
-        **kwargs: Any,
+            self,
+            left: Operators,
+            right: Optional[Any] = None,
+            *other: Any,
+            **kwargs: Any,
     ) -> Operators:
         if hasattr(left, "__sa_operate__"):
             return left.operate(self, right, *other, **kwargs)
@@ -475,14 +475,13 @@ class ColumnOperators(Operators):
     """Hack, allows datetime objects to be compared on the LHS."""
 
     if typing.TYPE_CHECKING:
-
         def operate(
-            self, op: OperatorType, *other: Any, **kwargs: Any
+                self, op: OperatorType, *other: Any, **kwargs: Any
         ) -> ColumnOperators:
             ...
 
         def reverse_operate(
-            self, op: OperatorType, other: Any, **kwargs: Any
+                self, op: OperatorType, other: Any, **kwargs: Any
         ) -> ColumnOperators:
             ...
 
@@ -626,7 +625,7 @@ class ColumnOperators(Operators):
         return self.reverse_operate(concat_op, other)
 
     def like(
-        self, other: Any, escape: Optional[str] = None
+            self, other: Any, escape: Optional[str] = None
     ) -> ColumnOperators:
         r"""Implement the ``like`` operator.
 
@@ -653,7 +652,7 @@ class ColumnOperators(Operators):
         return self.operate(like_op, other, escape=escape)
 
     def ilike(
-        self, other: Any, escape: Optional[str] = None
+            self, other: Any, escape: Optional[str] = None
     ) -> ColumnOperators:
         r"""Implement the ``ilike`` operator, e.g. case insensitive LIKE.
 
@@ -804,7 +803,7 @@ class ColumnOperators(Operators):
     notin_ = not_in
 
     def not_like(
-        self, other: Any, escape: Optional[str] = None
+            self, other: Any, escape: Optional[str] = None
     ) -> ColumnOperators:
         """implement the ``NOT LIKE`` operator.
 
@@ -826,7 +825,7 @@ class ColumnOperators(Operators):
     notlike = not_like
 
     def not_ilike(
-        self, other: Any, escape: Optional[str] = None
+            self, other: Any, escape: Optional[str] = None
     ) -> ColumnOperators:
         """implement the ``NOT ILIKE`` operator.
 
@@ -881,10 +880,10 @@ class ColumnOperators(Operators):
     isnot = is_not
 
     def startswith(
-        self,
-        other: Any,
-        escape: Optional[str] = None,
-        autoescape: bool = False,
+            self,
+            other: Any,
+            escape: Optional[str] = None,
+            autoescape: bool = False,
     ) -> ColumnOperators:
         r"""Implement the ``startswith`` operator.
 
@@ -966,11 +965,93 @@ class ColumnOperators(Operators):
             startswith_op, other, escape=escape, autoescape=autoescape
         )
 
+    def istartswith(
+            self,
+            other: Any,
+            escape: Optional[str] = None,
+            autoescape: bool = False,
+    ) -> ColumnOperators:
+        r"""Implement the ``istartswith`` operator, e.g. case insensitive
+        version of :meth:`.ColumnOperators.startswith`.
+
+                Produces a LIKE expression that tests against an insensitive match for the start
+        of a string value::
+
+            lower(column) LIKE lower(<other>) || '%'
+
+        E.g.::
+
+            stmt = select(sometable).\
+                where(sometable.c.column.istartswith("foobar"))
+
+        Since the operator uses ``LIKE``, wildcard characters
+        ``"%"`` and ``"_"`` that are present inside the <other> expression
+        will behave like wildcards as well.   For literal string
+        values, the :paramref:`.ColumnOperators.istartswith.autoescape` flag
+        may be set to ``True`` to apply escaping to occurrences of these
+        characters within the string value so that they match as themselves
+        and not as wildcard characters.  Alternatively, the
+        :paramref:`.ColumnOperators.istartswith.escape` parameter will establish
+        a given character as an escape character which can be of use when
+        the target expression is not a literal string.
+
+        :param other: expression to be compared.   This is usually a plain
+          string value, but can also be an arbitrary SQL expression.  LIKE
+          wildcard characters ``%`` and ``_`` are not escaped by default unless
+          the :paramref:`.ColumnOperators.istartswith.autoescape` flag is
+          set to True.
+
+        :param autoescape: boolean; when True, establishes an escape character
+          within the LIKE expression, then applies it to all occurrences of
+          ``"%"``, ``"_"`` and the escape character itself within the
+          comparison value, which is assumed to be a literal string and not a
+          SQL expression.
+
+          An expression such as::
+
+            somecolumn.istartswith("foo%bar", autoescape=True)
+
+          Will render as::
+
+            lower(somecolumn) LIKE lower(:param) || '%' ESCAPE '/'
+
+          With the value of ``:param`` as ``"foo/%bar"``.
+
+        :param escape: a character which when given will render with the
+          ``ESCAPE`` keyword to establish that character as the escape
+          character.  This character can then be placed preceding occurrences
+          of ``%`` and ``_`` to allow them to act as themselves and not
+          wildcard characters.
+
+          An expression such as::
+
+            somecolumn.istartswith("foo/%bar", escape="^")
+
+          Will render as::
+
+            lower(somecolumn) LIKE lower(:param) || '%' ESCAPE '^'
+
+          The parameter may also be combined with
+          :paramref:`.ColumnOperators.istartswith.autoescape`::
+
+            somecolumn.istartswith("foo%bar^bat", escape="^", autoescape=True)
+
+          Where above, the given literal parameter will be converted to
+          ``"foo^%bar^^bat"`` before being passed to the database.
+
+        .. seealso::
+
+            :meth:`.ColumnOperators.startswith`
+        """
+        return self.operate(
+            istartswith_op, other, escape=escape, autoescape=autoescape
+        )
+
     def endswith(
-        self,
-        other: Any,
-        escape: Optional[str] = None,
-        autoescape: bool = False,
+            self,
+            other: Any,
+            escape: Optional[str] = None,
+            autoescape: bool = False,
     ) -> ColumnOperators:
         r"""Implement the 'endswith' operator.
 
@@ -1052,6 +1133,88 @@ class ColumnOperators(Operators):
             endswith_op, other, escape=escape, autoescape=autoescape
         )
 
+    def iendswith(
+            self,
+            other: Any,
+            escape: Optional[str] = None,
+            autoescape: bool = False,
+    ) -> ColumnOperators:
+        r"""Implement the ``iendswith`` operator, e.g. case insensitive
+        version of :meth:`.ColumnOperators.endswith`.
+
+                Produces a LIKE expression that tests against an insensitive match for the end
+        of a string value::
+
+            lower(column) LIKE '%' || lower(<other>)
+
+        E.g.::
+
+            stmt = select(sometable).\
+                where(sometable.c.column.iendswith("foobar"))
+
+        Since the operator uses ``LIKE``, wildcard characters
+        ``"%"`` and ``"_"`` that are present inside the <other> expression
+        will behave like wildcards as well.   For literal string
+        values, the :paramref:`.ColumnOperators.iendswith.autoescape` flag
+        may be set to ``True`` to apply escaping to occurrences of these
+        characters within the string value so that they match as themselves
+        and not as wildcard characters.  Alternatively, the
+        :paramref:`.ColumnOperators.iendswith.escape` parameter will establish
+        a given character as an escape character which can be of use when
+        the target expression is not a literal string.
+
+        :param other: expression to be compared.   This is usually a plain
+          string value, but can also be an arbitrary SQL expression.  LIKE
+          wildcard characters ``%`` and ``_`` are not escaped by default unless
+          the :paramref:`.ColumnOperators.iendswith.autoescape` flag is
+          set to True.
+
+        :param autoescape: boolean; when True, establishes an escape character
+          within the LIKE expression, then applies it to all occurrences of
+          ``"%"``, ``"_"`` and the escape character itself within the
+          comparison value, which is assumed to be a literal string and not a
+          SQL expression.
+
+          An expression such as::
+
+            somecolumn.iendswith("foo%bar", autoescape=True)
+
+          Will render as::
+
+            lower(somecolumn) LIKE '%' || lower(:param) ESCAPE '/'
+
+          With the value of ``:param`` as ``"foo/%bar"``.
+
+        :param escape: a character which when given will render with the
+          ``ESCAPE`` keyword to establish that character as the escape
+          character.  This character can then be placed preceding occurrences
+          of ``%`` and ``_`` to allow them to act as themselves and not
+          wildcard characters.
+
+          An expression such as::
+
+            somecolumn.iendswith("foo/%bar", escape="^")
+
+          Will render as::
+
+            lower(somecolumn) LIKE '%' || lower(:param) ESCAPE '^'
+
+          The parameter may also be combined with
+          :paramref:`.ColumnOperators.iendswith.autoescape`::
+
+            somecolumn.endswith("foo%bar^bat", escape="^", autoescape=True)
+
+          Where above, the given literal parameter will be converted to
+          ``"foo^%bar^^bat"`` before being passed to the database.
+
+        .. seealso::
+
+            :meth:`.ColumnOperators.endswith`
+        """
+        return self.operate(
+            iendswith_op, other, escape=escape, autoescape=autoescape
+        )
+
     def contains(self, other: Any, **kw: Any) -> ColumnOperators:
         r"""Implement the 'contains' operator.
 
@@ -1132,6 +1295,82 @@ class ColumnOperators(Operators):
         """
         return self.operate(contains_op, other, **kw)
 
+    def icontains(self, other: Any, **kw: Any) -> ColumnOperators:
+        r"""Implement the ``icontains`` operator, e.g. case insensitive
+        version of :meth:`.ColumnOperators.contains`.
+
+                Produces a LIKE expression that tests against an insensitive match for the middle
+        of a string value::
+
+            lower(column) LIKE '%' || lower(<other>) || '%'
+
+        E.g.::
+
+            stmt = select(sometable).\
+                where(sometable.c.column.icontains("foobar"))
+
+        Since the operator uses ``LIKE``, wildcard characters
+        ``"%"`` and ``"_"`` that are present inside the <other> expression
+        will behave like wildcards as well.   For literal string
+        values, the :paramref:`.ColumnOperators.icontains.autoescape` flag
+        may be set to ``True`` to apply escaping to occurrences of these
+        characters within the string value so that they match as themselves
+        and not as wildcard characters.  Alternatively, the
+        :paramref:`.ColumnOperators.icontains.escape` parameter will establish
+        a given character as an escape character which can be of use when
+        the target expression is not a literal string.
+
+        :param other: expression to be compared.   This is usually a plain
+          string value, but can also be an arbitrary SQL expression.  LIKE
+          wildcard characters ``%`` and ``_`` are not escaped by default unless
+          the :paramref:`.ColumnOperators.icontains.autoescape` flag is
+          set to True.
+
+        :param autoescape: boolean; when True, establishes an escape character
+          within the LIKE expression, then applies it to all occurrences of
+          ``"%"``, ``"_"`` and the escape character itself within the
+          comparison value, which is assumed to be a literal string and not a
+          SQL expression.
+
+          An expression such as::
+
+            somecolumn.icontains("foo%bar", autoescape=True)
+
+          Will render as::
+
+            lower(somecolumn) LIKE '%' || lower(:param) || '%' ESCAPE '/'
+
+          With the value of ``:param`` as ``"foo/%bar"``.
+
+        :param escape: a character which when given will render with the
+          ``ESCAPE`` keyword to establish that character as the escape
+          character.  This character can then be placed preceding occurrences
+          of ``%`` and ``_`` to allow them to act as themselves and not
+          wildcard characters.
+
+          An expression such as::
+
+            somecolumn.icontains("foo/%bar", escape="^")
+
+          Will render as::
+
+            lower(somecolumn) LIKE '%' || lower(:param) || '%' ESCAPE '^'
+
+          The parameter may also be combined with
+          :paramref:`.ColumnOperators.contains.autoescape`::
+
+            somecolumn.icontains("foo%bar^bat", escape="^", autoescape=True)
+
+          Where above, the given literal parameter will be converted to
+          ``"foo^%bar^^bat"`` before being passed to the database.
+
+        .. seealso::
+
+            :meth:`.ColumnOperators.contains`
+
+        """
+        return self.operate(icontains_op, other, **kw)
+
     def match(self, other: Any, **kwargs: Any) -> ColumnOperators:
         """Implements a database-specific 'match' operator.
 
@@ -1163,7 +1402,7 @@ class ColumnOperators(Operators):
         return self.operate(match_op, other, **kwargs)
 
     def regexp_match(
-        self, pattern: Any, flags: Optional[str] = None
+            self, pattern: Any, flags: Optional[str] = None
     ) -> ColumnOperators:
         """Implements a database-specific 'regexp match' operator.
 
@@ -1213,7 +1452,7 @@ class ColumnOperators(Operators):
         return self.operate(regexp_match_op, pattern, flags=flags)
 
     def regexp_replace(
-        self, pattern: Any, replacement: Any, flags: Optional[str] = None
+            self, pattern: Any, replacement: Any, flags: Optional[str] = None
     ) -> ColumnOperators:
         """Implements a database-specific 'regexp replace' operator.
 
@@ -1339,7 +1578,7 @@ class ColumnOperators(Operators):
         return self.reverse_operate(mod, other)
 
     def between(
-        self, cleft: Any, cright: Any, symmetric: bool = False
+            self, cleft: Any, cright: Any, symmetric: bool = False
     ) -> ColumnOperators:
         """Produce a :func:`_expression.between` clause against
         the parent object, given the lower and upper range.
@@ -1646,7 +1885,7 @@ def all_op(a: Any) -> Any:
 
 
 def _escaped_like_impl(
-    fn: Callable[..., Any], other: Any, escape: Optional[str], autoescape: bool
+        fn: Callable[..., Any], other: Any, escape: Optional[str], autoescape: bool
 ) -> Any:
     if autoescape:
         if autoescape is not True:
@@ -1670,7 +1909,7 @@ def _escaped_like_impl(
 @comparison_op
 @_operator_fn
 def startswith_op(
-    a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
 ) -> Any:
     return _escaped_like_impl(a.startswith, b, escape, autoescape)
 
@@ -1678,7 +1917,7 @@ def startswith_op(
 @comparison_op
 @_operator_fn
 def not_startswith_op(
-    a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
 ) -> Any:
     return ~_escaped_like_impl(a.startswith, b, escape, autoescape)
 
@@ -1687,10 +1926,26 @@ def not_startswith_op(
 notstartswith_op = not_startswith_op
 
 
+@comparison_op
+@_operator_fn
+def istartswith_op(
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+) -> Any:
+    return _escaped_like_impl(a.istartswith, b, escape, autoescape)
+
+
+@comparison_op
+@_operator_fn
+def not_istartswith_op(
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+) -> Any:
+    return ~_escaped_like_impl(a.istartswith, b, escape, autoescape)
+
+
 @comparison_op
 @_operator_fn
 def endswith_op(
-    a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
 ) -> Any:
     return _escaped_like_impl(a.endswith, b, escape, autoescape)
 
@@ -1698,7 +1953,7 @@ def endswith_op(
 @comparison_op
 @_operator_fn
 def not_endswith_op(
-    a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
 ) -> Any:
     return ~_escaped_like_impl(a.endswith, b, escape, autoescape)
 
@@ -1707,10 +1962,26 @@ def not_endswith_op(
 notendswith_op = not_endswith_op
 
 
+@comparison_op
+@_operator_fn
+def iendswith_op(
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+) -> Any:
+    return _escaped_like_impl(a.iendswith, b, escape, autoescape)
+
+
+@comparison_op
+@_operator_fn
+def not_iendswith_op(
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+) -> Any:
+    return ~_escaped_like_impl(a.iendswith, b, escape, autoescape)
+
+
 @comparison_op
 @_operator_fn
 def contains_op(
-    a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
 ) -> Any:
     return _escaped_like_impl(a.contains, b, escape, autoescape)
 
@@ -1718,7 +1989,7 @@ def contains_op(
 @comparison_op
 @_operator_fn
 def not_contains_op(
-    a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
 ) -> Any:
     return ~_escaped_like_impl(a.contains, b, escape, autoescape)
 
@@ -1727,6 +1998,22 @@ def not_contains_op(
 notcontains_op = not_contains_op
 
 
+@comparison_op
+@_operator_fn
+def icontains_op(
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+) -> Any:
+    return _escaped_like_impl(a.icontains, b, escape, autoescape)
+
+
+@comparison_op
+@_operator_fn
+def not_icontains_op(
+        a: Any, b: Any, escape: Optional[str] = None, autoescape: bool = False
+) -> Any:
+    return ~_escaped_like_impl(a.icontains, b, escape, autoescape)
+
+
 @comparison_op
 @_operator_fn
 def match_op(a: Any, b: Any, **kw: Any) -> Any:
@@ -1747,7 +2034,7 @@ def not_regexp_match_op(a: Any, b: Any, flags: Optional[str] = None) -> Any:
 
 @_operator_fn
 def regexp_replace_op(
-    a: Any, b: Any, replacement: Any, flags: Optional[str] = None
+        a: Any, b: Any, replacement: Any, flags: Optional[str] = None
 ) -> Any:
     return a.regexp_replace(b, replacement=replacement, flags=flags)
 
@@ -1834,9 +2121,9 @@ def is_ordering_modifier(op: OperatorType) -> bool:
 
 def is_natural_self_precedent(op: OperatorType) -> bool:
     return (
-        op in _natural_self_precedent
-        or isinstance(op, custom_op)
-        and op.natural_self_precedent
+            op in _natural_self_precedent
+            or isinstance(op, custom_op)
+            and op.natural_self_precedent
     )
 
 
index 830a5eb0f1203171865037a9016ceff4ca61b1bc..374d144bafd02efb0536ed92e82ea2fed1f20677 100644 (file)
@@ -3198,6 +3198,155 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             dialect=mysql.dialect(),
         )
 
+    def test_icontains(self):
+        """
+        case insensitive contains method
+        """
+        self.assert_compile(
+            column("x").icontains("y"),
+            "lower(x) LIKE '%' || lower(:x_1) || '%'",
+            checkparams={"x_1": "y"},
+        )
+
+    def test_icontains_encoded(self):
+        self.assert_compile(
+            column("x").icontains(b"y"),
+            "lower(x) LIKE '%' || lower(:x_1) || '%'",
+            checkparams={"x_1": b"y"},
+        )
+
+    def test_not_icontains_encoded(self):
+        self.assert_compile(
+            ~column("x").icontains(b"y"),
+            "lower(x) NOT LIKE '%' || lower(:x_1) || '%'",
+            checkparams={"x_1": b"y"},
+        )
+
+    def test_icontains_encoded_mysql(self):
+        self.assert_compile(
+            column("x").icontains(b"y"),
+            "lower(x) LIKE concat('%%', lower(%s), '%%')",
+            checkparams={"x_1": b"y"},
+            dialect="mysql",
+        )
+
+    def test_not_icontains_encoded_mysql(self):
+        self.assert_compile(
+            ~column("x").icontains(b"y"),
+            "lower(x) NOT LIKE concat('%%', lower(%s), '%%')",
+            checkparams={"x_1": b"y"},
+            dialect="mysql",
+        )
+
+    def test_not_icontains(self):
+        """
+        same as test_icontains but negate the statement
+        """
+        self.assert_compile(
+            ~column("x").icontains("y"),
+            "lower(x) NOT LIKE '%' || lower(:x_1) || '%'",
+            checkparams={"x_1": "y"},
+        )
+
+    def test_icontains_escape(self):
+        """
+        render same SQL statement as icontains but include the ESCAPE
+        keyword to establish that character as the escape character
+        """
+        self.assert_compile(
+            column("x").icontains("a%b_c", escape="\\"),
+            "lower(x) LIKE '%' || lower(:x_1) || '%' ESCAPE '\\'",
+            checkparams={"x_1": "a%b_c"},
+        )
+
+    def test_not_icontains_escape(self):
+        """
+        same as test_icontains_escape but negate the statement
+        """
+        self.assert_compile(
+            ~column("x").icontains("a%b_c", escape="\\"),
+            "lower(x) NOT LIKE '%' || lower(:x_1) || '%' ESCAPE '\\'",
+            checkparams={"x_1": "a%b_c"},
+        )
+
+    def test_icontains_autoescape(self):
+        """
+        apply the escape character to all occurrences of "%", "_" and
+        the escape character itself
+        """
+        self.assert_compile(
+            column("x").icontains("a%b_c/d", autoescape=True),
+            "lower(x) LIKE '%' || lower(:x_1) || '%' ESCAPE '/'",
+            checkparams={"x_1": "a/%b/_c//d"},
+        )
+
+    def test_icontains_autoescape_custom_escape(self):
+        """
+        set an escape character to all occurrences of "%", "_" and
+        the escape character itself
+        """
+        self.assert_compile(
+            column("x").icontains("foo%bar^bat", escape="^", autoescape=True),
+            "lower(x) LIKE '%' || lower(:x_1) || '%' ESCAPE '^'",
+            checkparams={"x_1": "foo^%bar^^bat"},
+        )
+
+    def test_not_icontains_autoescape(self):
+        """
+        same as test_icontains_autoescape but negate the statement
+        """
+        self.assert_compile(
+            ~column("x").icontains("a%b_c/d", autoescape=True),
+            "lower(x) NOT LIKE '%' || lower(:x_1) || '%' ESCAPE '/'",
+            checkparams={"x_1": "a/%b/_c//d"},
+        )
+
+    def test_icontains_literal(self):
+        self.assert_compile(
+            column("x").icontains(literal_column("y")),
+            "lower(x) LIKE '%' || lower(y) || '%'",
+            checkparams={},
+        )
+
+    def test_icontains_text(self):
+        self.assert_compile(
+            column("x").icontains(text("y")),
+            "lower(x) LIKE '%' || lower(y) || '%'",
+            checkparams={},
+        )
+
+    def test_icontains_concat(self):
+        self.assert_compile(
+            column("x").icontains("y"),
+            "lower(x) LIKE concat('%%', lower(%s), '%%')",
+            checkparams={"x_1": "y"},
+            dialect=mysql.dialect(),
+        )
+
+    def test_not_icontains_concat(self):
+        self.assert_compile(
+            ~column("x").icontains("y"),
+            "lower(x) NOT LIKE concat('%%', lower(%s), '%%')",
+            checkparams={"x_1": "y"},
+            dialect=mysql.dialect(),
+        )
+
+    def test_icontains_literal_concat(self):
+        self.assert_compile(
+            column("x").icontains(literal_column("y")),
+            "lower(x) LIKE concat('%%', lower(y), '%%')",
+            checkparams={},
+            dialect=mysql.dialect(),
+        )
+
+    def test_icontains_text_concat(self):
+        self.assert_compile(
+            column("x").icontains(text("y")),
+            "lower(x) LIKE concat('%%', lower(y), '%%')",
+            checkparams={},
+            dialect=mysql.dialect(),
+        )
+
     def test_like(self):
         self.assert_compile(
             column("x").like("y"), "x LIKE :x_1", checkparams={"x_1": "y"}
@@ -3377,6 +3526,149 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             dialect=mysql.dialect(),
         )
 
+    def test_istartswith(self):
+        """
+        case insensitive startswith method
+        """
+        self.assert_compile(
+            column("x").istartswith("y"),
+            "lower(x) LIKE lower(:x_1) || '%'",
+            checkparams={"x_1": "y"},
+        )
+
+    def test_not_istartswith(self):
+        """
+        same as test_istartswith but negate the statement
+        """
+        self.assert_compile(
+            ~column("x").istartswith("y"),
+            "lower(x) NOT LIKE lower(:x_1) || '%'",
+            checkparams={"x_1": "y"},
+        )
+
+    def test_istartswith_escape(self):
+        """
+        render same SQL statement as istartswith but include the ESCAPE
+        keyword to establish that character as the escape character
+        """
+        self.assert_compile(
+            column("x").istartswith("a%b_c", escape="\\"),
+            "lower(x) LIKE lower(:x_1) || '%' ESCAPE '\\'",
+            checkparams={"x_1": "a%b_c"},
+        )
+
+    def test_not_istartswith_escape(self):
+        self.assert_compile(
+            ~column("x").istartswith("a%b_c", escape="\\"),
+            "lower(x) NOT LIKE lower(:x_1) || '%' ESCAPE '\\'",
+            checkparams={"x_1": "a%b_c"},
+        )
+
+    def test_istartswith_autoescape(self):
+        """
+        apply the escape character to all occurrences of "%", "_" and
+        the escape character itself
+        """
+        self.assert_compile(
+            column("x").istartswith("a%b_c/d", autoescape=True),
+            "lower(x) LIKE lower(:x_1) || '%' ESCAPE '/'",
+            checkparams={"x_1": "a/%b/_c//d"},
+        )
+
+    def test_not_istartswith_autoescape(self):
+        self.assert_compile(
+            ~column("x").istartswith("a%b_c/d", autoescape=True),
+            "lower(x) NOT LIKE lower(:x_1) || '%' ESCAPE '/'",
+            checkparams={"x_1": "a/%b/_c//d"},
+        )
+
+    def test_istartswith_autoescape_custom_escape(self):
+        """
+        set an escape character to all occurrences of "%", "_" and
+        the escape character itself
+        """
+        self.assert_compile(
+            column("x").istartswith("a%b_c/d^e", autoescape=True, escape="^"),
+            "lower(x) LIKE lower(:x_1) || '%' ESCAPE '^'",
+            checkparams={"x_1": "a^%b^_c/d^^e"},
+        )
+
+    def test_istartswith_encoded(self):
+        self.assert_compile(
+            column("x").istartswith(b"y"),
+            "lower(x) LIKE lower(:x_1) || '%'",
+            checkparams={"x_1": b"y"},
+        )
+
+    def test_not_istartswith_encoded(self):
+        self.assert_compile(
+            ~column("x").istartswith(b"y"),
+            "lower(x) NOT LIKE lower(:x_1) || '%'",
+            checkparams={"x_1": b"y"},
+        )
+
+    def test_istartswith_encoded_mysql(self):
+        self.assert_compile(
+            column("x").istartswith(b"y"),
+            "lower(x) LIKE concat(lower(%s), '%%')",
+            checkparams={"x_1": b"y"},
+            dialect="mysql",
+        )
+
+    def test_not_istartswith_encoded_mysql(self):
+        self.assert_compile(
+            ~column("x").istartswith(b"y"),
+            "lower(x) NOT LIKE concat(lower(%s), '%%')",
+            checkparams={"x_1": b"y"},
+            dialect="mysql",
+        )
+
+    def test_istartswith_literal(self):
+        self.assert_compile(
+            column("x").istartswith(literal_column("y")),
+            "lower(x) LIKE lower(y) || '%'",
+            checkparams={},
+        )
+
+    def test_istartswith_text(self):
+        self.assert_compile(
+            column("x").istartswith(text("y")),
+            "lower(x) LIKE lower(y) || '%'",
+            checkparams={},
+        )
+
+    def test_istartswith_concat(self):
+        self.assert_compile(
+            column("x").istartswith("y"),
+            "lower(x) LIKE concat(lower(%s), '%%')",
+            checkparams={"x_1": "y"},
+            dialect=mysql.dialect(),
+        )
+
+    def test_not_istartswith_concat(self):
+        self.assert_compile(
+            ~column("x").istartswith("y"),
+            "lower(x) NOT LIKE concat(lower(%s), '%%')",
+            checkparams={"x_1": "y"},
+            dialect=mysql.dialect(),
+        )
+
+    def test_istartswith_literal_mysql(self):
+        self.assert_compile(
+            column("x").istartswith(literal_column("y")),
+            "lower(x) LIKE concat(lower(y), '%%')",
+            checkparams={},
+            dialect=mysql.dialect(),
+        )
+
+    def test_istartswith_text_mysql(self):
+        self.assert_compile(
+            column("x").istartswith(text("y")),
+            "lower(x) LIKE concat(lower(y), '%%')",
+            checkparams={},
+            dialect=mysql.dialect(),
+        )
+
     def test_endswith(self):
         self.assert_compile(
             column("x").endswith("y"),
@@ -3509,6 +3801,156 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             dialect=mysql.dialect(),
         )
 
+    def test_iendswith(self):
+        """
+        case insensitive endswith method
+        """
+        self.assert_compile(
+            column("x").iendswith("y"),
+            "lower(x) LIKE '%' || lower(:x_1)",
+            checkparams={"x_1": "y"},
+        )
+
+    def test_not_iendswith(self):
+        """
+        same as test_iendswith but negate the statement
+        """
+        self.assert_compile(
+            ~column("x").iendswith("y"),
+            "lower(x) NOT LIKE '%' || lower(:x_1)",
+            checkparams={"x_1": "y"},
+        )
+
+    def test_iendswith_encoded(self):
+        self.assert_compile(
+            column("x").iendswith(b"y"),
+            "lower(x) LIKE '%' || lower(:x_1)",
+            checkparams={"x_1": b"y"},
+        )
+
+    def test_not_iendswith_encoded(self):
+        self.assert_compile(
+            ~column("x").iendswith(b"y"),
+            "lower(x) NOT LIKE '%' || lower(:x_1)",
+            checkparams={"x_1": b"y"},
+        )
+
+    def test_iendswith_encoded_mysql(self):
+        self.assert_compile(
+            column("x").iendswith(b"y"),
+            "lower(x) LIKE concat('%%', lower(%s))",
+            checkparams={"x_1": b"y"},
+            dialect="mysql",
+        )
+
+    def test_iendswith_escape(self):
+        """
+        render same SQL statement as iendswith but include the ESCAPE
+        keyword to establish that character as the escape character
+        """
+        self.assert_compile(
+            column("x").iendswith("a%b_c", escape="\\"),
+            "lower(x) LIKE '%' || lower(:x_1) ESCAPE '\\'",
+            checkparams={"x_1": "a%b_c"},
+        )
+
+    def test_not_iendswith_escape(self):
+        self.assert_compile(
+            ~column("x").iendswith("a%b_c", escape="\\"),
+            "lower(x) NOT LIKE '%' || lower(:x_1) ESCAPE '\\'",
+            checkparams={"x_1": "a%b_c"},
+        )
+
+    def test_iendswith_autoescape(self):
+        """
+        apply the escape character to all occurrences of "%", "_" and
+        the escape character itself
+        """
+        self.assert_compile(
+            column("x").iendswith("a%b_c/d", autoescape=True),
+            "lower(x) LIKE '%' || lower(:x_1) ESCAPE '/'",
+            checkparams={"x_1": "a/%b/_c//d"},
+        )
+
+    def test_not_iendswith_autoescape(self):
+        self.assert_compile(
+            ~column("x").iendswith("a%b_c/d", autoescape=True),
+            "lower(x) NOT LIKE '%' || lower(:x_1) ESCAPE '/'",
+            checkparams={"x_1": "a/%b/_c//d"},
+        )
+
+    def test_iendswith_autoescape_custom_escape(self):
+        """
+        set an escape character to all occurrences of "%", "_" and
+        the escape character itself
+        """
+        self.assert_compile(
+            column("x").iendswith("a%b_c/d^e", autoescape=True, escape="^"),
+            "lower(x) LIKE '%' || lower(:x_1) ESCAPE '^'",
+            checkparams={"x_1": "a^%b^_c/d^^e"},
+        )
+
+    def test_iendswith_autoescape_warning(self):
+        with expect_warnings("The autoescape parameter is now a simple"):
+            self.assert_compile(
+                column("x").iendswith("a%b_c/d", autoescape="P"),
+                "lower(x) LIKE '%' || lower(:x_1) ESCAPE '/'",
+                checkparams={"x_1": "a/%b/_c//d"},
+            )
+
+    def test_iendswith_autoescape_nosqlexpr(self):
+        assert_raises_message(
+            TypeError,
+            "String value expected when autoescape=True",
+            column("x").iendswith,
+            literal_column("'a%b_c/d'"),
+            autoescape=True,
+        )
+
+    def test_iendswith_literal(self):
+        self.assert_compile(
+            column("x").iendswith(literal_column("y")),
+            "lower(x) LIKE '%' || lower(y)",
+            checkparams={},
+        )
+
+    def test_iendswith_text(self):
+        self.assert_compile(
+            column("x").iendswith(text("y")),
+            "lower(x) LIKE '%' || lower(y)", checkparams={}
+        )
+
+    def test_iendswith_mysql(self):
+        self.assert_compile(
+            column("x").iendswith("y"),
+            "lower(x) LIKE concat('%%', lower(%s))",
+            checkparams={"x_1": "y"},
+            dialect=mysql.dialect(),
+        )
+
+    def test_not_iendswith_mysql(self):
+        self.assert_compile(
+            ~column("x").iendswith("y"),
+            "lower(x) NOT LIKE concat('%%', lower(%s))",
+            checkparams={"x_1": "y"},
+            dialect=mysql.dialect(),
+        )
+
+    def test_iendswith_literal_mysql(self):
+        self.assert_compile(
+            column("x").iendswith(literal_column("y")),
+            "lower(x) LIKE concat('%%', lower(y))",
+            checkparams={},
+            dialect=mysql.dialect(),
+        )
+
+    def test_iendswith_text_mysql(self):
+        self.assert_compile(
+            column("x").iendswith(text("y")),
+            "lower(x) LIKE concat('%%', lower(y))",
+            checkparams={},
+            dialect=mysql.dialect(),
+        )
 
 class CustomOpTest(fixtures.TestBase):
     def test_is_comparison_legacy(self):