]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
implement icontains, istartswith, iendswith operators
authorMatias Martinez Rebori <matias.martinez@dinapi.gov.py>
Wed, 7 Sep 2022 16:36:06 +0000 (12:36 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Thu, 8 Sep 2022 16:15:23 +0000 (12:15 -0400)
Added long-requested case-insensitive string operators
:meth:`_sql.ColumnOperators.icontains`,
:meth:`_sql.ColumnOperators.istartswith`,
:meth:`_sql.ColumnOperators.iendswith`, which produce case-insensitive
LIKE compositions (using ILIKE on PostgreSQL, and the LOWER() function on
all other backends) to complement the existing LIKE composition operators
:meth:`_sql.ColumnOperators.contains`,
:meth:`_sql.ColumnOperators.startswith`, etc. Huge thanks to Matias
Martinez Rebori for their meticulous and complete efforts in implementing
these new methods.

Fixes: #3482
Closes: #8496
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/8496
Pull-request-sha: 7287e2c436959fac4fef022f359fcc73d1528211

Change-Id: I9fcdd603716218067547cc92a2b07bd02a2c366b

doc/build/changelog/unreleased_20/3482.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/postgresql/base.py
lib/sqlalchemy/sql/coercions.py
lib/sqlalchemy/sql/compiler.py
lib/sqlalchemy/sql/default_comparator.py
lib/sqlalchemy/sql/elements.py
lib/sqlalchemy/sql/operators.py
test/sql/test_operators.py

diff --git a/doc/build/changelog/unreleased_20/3482.rst b/doc/build/changelog/unreleased_20/3482.rst
new file mode 100644 (file)
index 0000000..c18606c
--- /dev/null
@@ -0,0 +1,14 @@
+.. change::
+    :tags: feature, sql
+    :tickets: 3482
+
+      Added long-requested case-insensitive string operators
+      :meth:`_sql.ColumnOperators.icontains`,
+      :meth:`_sql.ColumnOperators.istartswith`,
+      :meth:`_sql.ColumnOperators.iendswith`, which produce case-insensitive
+      LIKE compositions (using ILIKE on PostgreSQL, and the LOWER() function on
+      all other backends) to complement the existing LIKE composition operators
+      :meth:`_sql.ColumnOperators.contains`,
+      :meth:`_sql.ColumnOperators.startswith`, etc. Huge thanks to Matias
+      Martinez Rebori for their meticulous and complete efforts in implementing
+      these new methods.
\ No newline at end of file
index 75fa3d7c77dbc98c267d769b9684d59fe9028f47..18a7c0a86bdc3341faf4edfb13d2b8288ced80cb 100644 (file)
@@ -1645,6 +1645,9 @@ class PGCompiler(compiler.SQLCompiler):
             self.process(binary.right, **kw),
         )
 
+    def visit_ilike_case_insensitive_operand(self, element, **kw):
+        return element.element._compiler_dispatch(self, **kw)
+
     def visit_ilike_op_binary(self, binary, operator, **kw):
         escape = binary.modifiers.get("escape", None)
 
index 0b250a28ec50c45b476e75f6ab6f0e97456ebfd7..8074bcf8b1fb62c3f4f8c60dfe67a1fcf219903d 100644 (file)
@@ -354,7 +354,11 @@ def expect(
 
     if not isinstance(
         element,
-        (elements.ClauseElement, schema.SchemaItem, schema.FetchedValue),
+        (
+            elements.CompilerElement,
+            schema.SchemaItem,
+            schema.FetchedValue,
+        ),
     ):
         resolved = None
 
index 8c2699879d37357a6f3ec323f61bc4a4372440b3..45b5eab562a2511fec953e655f07d0dd2682785f 100644 (file)
@@ -59,6 +59,7 @@ from . import crud
 from . import elements
 from . import functions
 from . import operators
+from . import roles
 from . import schema
 from . import selectable
 from . import sqltypes
@@ -686,7 +687,9 @@ class TypeCompiler(util.EnsureKWArg):
 
 # this was a Visitable, but to allow accurate detection of
 # column elements this is actually a column element
-class _CompileLabel(elements.CompilerColumnElement):
+class _CompileLabel(
+    roles.BinaryElementRole[Any], elements.CompilerColumnElement
+):
 
     """lightweight label object which acts as an expression.Label."""
 
@@ -710,6 +713,44 @@ class _CompileLabel(elements.CompilerColumnElement):
         return self
 
 
+class ilike_case_insensitive(
+    roles.BinaryElementRole[Any], elements.CompilerColumnElement
+):
+    """produce a wrapping element for a case-insensitive portion of
+    an ILIKE construct.
+
+    The construct usually renders the ``lower()`` function, but on
+    PostgreSQL will pass silently with the assumption that "ILIKE"
+    is being used.
+
+    .. versionadded:: 2.0
+
+    """
+
+    __visit_name__ = "ilike_case_insensitive_operand"
+    __slots__ = "element", "comparator"
+
+    def __init__(self, element):
+        self.element = element
+        self.comparator = element.comparator
+
+    @property
+    def proxy_set(self):
+        return self.element.proxy_set
+
+    @property
+    def type(self):
+        return self.element.type
+
+    def self_group(self, **kw):
+        return self
+
+    def _with_binary_element_type(self, type_):
+        return ilike_case_insensitive(
+            self.element._with_binary_element_type(type_)
+        )
+
+
 class SQLCompiler(Compiled):
     """Default implementation of :class:`.Compiled`.
 
@@ -2688,6 +2729,9 @@ class SQLCompiler(Compiled):
     def _like_percent_literal(self):
         return elements.literal_column("'%'", type_=sqltypes.STRINGTYPE)
 
+    def visit_ilike_case_insensitive_operand(self, element, **kw):
+        return f"lower({element.element._compiler_dispatch(self, **kw)})"
+
     def visit_contains_op_binary(self, binary, operator, **kw):
         binary = binary._clone()
         percent = self._like_percent_literal
@@ -2700,6 +2744,24 @@ 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 = ilike_case_insensitive(binary.left)
+        binary.right = percent.concat(
+            ilike_case_insensitive(binary.right)
+        ).concat(percent)
+        return self.visit_ilike_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 = ilike_case_insensitive(binary.left)
+        binary.right = percent.concat(
+            ilike_case_insensitive(binary.right)
+        ).concat(percent)
+        return self.visit_not_ilike_op_binary(binary, operator, **kw)
+
     def visit_startswith_op_binary(self, binary, operator, **kw):
         binary = binary._clone()
         percent = self._like_percent_literal
@@ -2712,6 +2774,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 = ilike_case_insensitive(binary.left)
+        binary.right = percent._rconcat(ilike_case_insensitive(binary.right))
+        return self.visit_ilike_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 = ilike_case_insensitive(binary.left)
+        binary.right = percent._rconcat(ilike_case_insensitive(binary.right))
+        return self.visit_not_ilike_op_binary(binary, operator, **kw)
+
     def visit_endswith_op_binary(self, binary, operator, **kw):
         binary = binary._clone()
         percent = self._like_percent_literal
@@ -2724,10 +2800,23 @@ 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 = ilike_case_insensitive(binary.left)
+        binary.right = percent.concat(ilike_case_insensitive(binary.right))
+        return self.visit_ilike_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 = ilike_case_insensitive(binary.left)
+        binary.right = percent.concat(ilike_case_insensitive(binary.right))
+        return self.visit_not_ilike_op_binary(binary, operator, **kw)
+
     def visit_like_op_binary(self, binary, operator, **kw):
         escape = binary.modifiers.get("escape", None)
 
-        # TODO: use ternary here, not "and"/ "or"
         return "%s LIKE %s" % (
             binary.left._compiler_dispatch(self, **kw),
             binary.right._compiler_dispatch(self, **kw),
@@ -2749,26 +2838,22 @@ class SQLCompiler(Compiled):
         )
 
     def visit_ilike_op_binary(self, binary, operator, **kw):
-        escape = binary.modifiers.get("escape", None)
-        return "lower(%s) LIKE lower(%s)" % (
-            binary.left._compiler_dispatch(self, **kw),
-            binary.right._compiler_dispatch(self, **kw),
-        ) + (
-            " ESCAPE " + self.render_literal_value(escape, sqltypes.STRINGTYPE)
-            if escape
-            else ""
-        )
+        if operator is operators.ilike_op:
+            binary = binary._clone()
+            binary.left = ilike_case_insensitive(binary.left)
+            binary.right = ilike_case_insensitive(binary.right)
+        # else we assume ilower() has been applied
+
+        return self.visit_like_op_binary(binary, operator, **kw)
 
     def visit_not_ilike_op_binary(self, binary, operator, **kw):
-        escape = binary.modifiers.get("escape", None)
-        return "lower(%s) NOT LIKE lower(%s)" % (
-            binary.left._compiler_dispatch(self, **kw),
-            binary.right._compiler_dispatch(self, **kw),
-        ) + (
-            " ESCAPE " + self.render_literal_value(escape, sqltypes.STRINGTYPE)
-            if escape
-            else ""
-        )
+        if operator is operators.not_ilike_op:
+            binary = binary._clone()
+            binary.left = ilike_case_insensitive(binary.left)
+            binary.right = ilike_case_insensitive(binary.right)
+        # else we assume ilower() has been applied
+
+        return self.visit_not_like_op_binary(binary, operator, **kw)
 
     def visit_between_op_binary(self, binary, operator, **kw):
         symmetric = binary.modifiers.get("symmetric", False)
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 29dfdc20c4ec289fc9f5123dc7a3673ad6e4ddc8..cfbf24f3c4c2cb5f3eb162c6470aeea1c26d76f2 100644 (file)
@@ -747,6 +747,8 @@ class CompilerColumnElement(
 
     __slots__ = ()
 
+    _propagate_attrs = util.EMPTY_DICT
+
 
 # SQLCoreOperations should be suiting the ExpressionElementRole
 # and ColumnsClauseRole.   however the MRO issues become too elaborate
index 44d63b3987556aa01c27c3edf2cb3b441076f1ec..49cf05f8defc3c71568ec34fc4a39fcda82b9e26 100644 (file)
@@ -966,6 +966,88 @@ 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,
@@ -1052,6 +1134,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 +1296,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.
 
@@ -1687,6 +1927,22 @@ 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(
@@ -1707,6 +1963,22 @@ 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(
@@ -1727,6 +1999,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:
index 830a5eb0f1203171865037a9016ceff4ca61b1bc..79ca00e1436f8a189e7786e7fbcb65bad3fb079f 100644 (file)
@@ -3087,6 +3087,14 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             checkparams={"x_1": "y"},
         )
 
+    def test_contains_pg(self):
+        self.assert_compile(
+            column("x").contains("y"),
+            "x LIKE '%%' || %(x_1)s || '%%'",
+            checkparams={"x_1": "y"},
+            dialect="postgresql",
+        )
+
     def test_contains_encoded(self):
         self.assert_compile(
             column("x").contains(b"y"),
@@ -3117,6 +3125,14 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             dialect="mysql",
         )
 
+    def test_not_contains_pg(self):
+        self.assert_compile(
+            ~column("x").contains(b"y"),
+            "x NOT LIKE '%%' || %(x_1)s || '%%'",
+            checkparams={"x_1": b"y"},
+            dialect="postgresql",
+        )
+
     def test_contains_escape(self):
         self.assert_compile(
             column("x").contains("a%b_c", escape="\\"),
@@ -3198,6 +3214,177 @@ 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_pg(self):
+        """
+        case insensitive contains method
+        """
+        self.assert_compile(
+            column("x").icontains("y"),
+            "x ILIKE '%%' || %(x_1)s || '%%'",
+            checkparams={"x_1": "y"},
+            dialect="postgresql",
+        )
+
+    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_not_icontains_pg(self):
+        """
+        same as test_icontains but negate the statement
+        """
+        self.assert_compile(
+            ~column("x").icontains("y"),
+            "x NOT ILIKE '%%' || %(x_1)s || '%%'",
+            checkparams={"x_1": "y"},
+            dialect="postgresql",
+        )
+
+    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 +3564,171 @@ 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_istartswith_pg(self):
+        """
+        case insensitive startswith method
+        """
+        self.assert_compile(
+            column("x").istartswith("y"),
+            "x ILIKE %(x_1)s || '%%'",
+            checkparams={"x_1": "y"},
+            dialect="postgresql",
+        )
+
+    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_not_istartswith_pg(self):
+        """
+        same as test_istartswith but negate the statement
+        """
+        self.assert_compile(
+            ~column("x").istartswith("y"),
+            "x NOT ILIKE %(x_1)s || '%%'",
+            checkparams={"x_1": "y"},
+            dialect="postgresql",
+        )
+
+    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"),
@@ -3451,6 +3803,14 @@ class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
             checkparams={"x_1": "y"},
         )
 
+    def test_not_endswith_pg(self):
+        self.assert_compile(
+            ~column("x").endswith("y"),
+            "x NOT LIKE '%%' || %(x_1)s",
+            checkparams={"x_1": "y"},
+            dialect="postgresql",
+        )
+
     def test_not_endswith_escape(self):
         self.assert_compile(
             ~column("x").endswith("a%b_c", escape="\\"),
@@ -3509,6 +3869,180 @@ 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_iendswith_pg(self):
+        """
+        case insensitive endswith method
+        """
+        self.assert_compile(
+            column("x").iendswith("y"),
+            "x ILIKE '%%' || %(x_1)s",
+            checkparams={"x_1": "y"},
+            dialect="postgresql",
+        )
+
+    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_not_iendswith_pg(self):
+        """
+        same as test_iendswith but negate the statement
+        """
+        self.assert_compile(
+            ~column("x").iendswith("y"),
+            "x NOT ILIKE '%%' || %(x_1)s",
+            checkparams={"x_1": "y"},
+            dialect="postgresql",
+        )
+
+    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):