From: Matias Martinez Rebori Date: Wed, 7 Sep 2022 12:42:57 +0000 (-0400) Subject: icontains,istartswith,iendswith operators X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=37ef2ea1ffe85810195e6f1b94ec5005478f6fe7;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git icontains,istartswith,iendswith operators --- diff --git a/lib/sqlalchemy/sql/compiler.py b/lib/sqlalchemy/sql/compiler.py index 6d9d47388d..13774b512d 100644 --- a/lib/sqlalchemy/sql/compiler.py +++ b/lib/sqlalchemy/sql/compiler.py @@ -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) diff --git a/lib/sqlalchemy/sql/default_comparator.py b/lib/sqlalchemy/sql/default_comparator.py index 619be2cd1e..49ca05dad6 100644 --- a/lib/sqlalchemy/sql/default_comparator.py +++ b/lib/sqlalchemy/sql/default_comparator.py @@ -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}), diff --git a/lib/sqlalchemy/sql/operators.py b/lib/sqlalchemy/sql/operators.py index 44d63b3987..f9e4a995f0 100644 --- a/lib/sqlalchemy/sql/operators.py +++ b/lib/sqlalchemy/sql/operators.py @@ -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() || '%' + + E.g.:: + + stmt = select(sometable).\ + where(sometable.c.column.istartswith("foobar")) + + Since the operator uses ``LIKE``, wildcard characters + ``"%"`` and ``"_"`` that are present inside the 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() + + E.g.:: + + stmt = select(sometable).\ + where(sometable.c.column.iendswith("foobar")) + + Since the operator uses ``LIKE``, wildcard characters + ``"%"`` and ``"_"`` that are present inside the 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() || '%' + + E.g.:: + + stmt = select(sometable).\ + where(sometable.c.column.icontains("foobar")) + + Since the operator uses ``LIKE``, wildcard characters + ``"%"`` and ``"_"`` that are present inside the 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 ) diff --git a/test/sql/test_operators.py b/test/sql/test_operators.py index 830a5eb0f1..374d144baf 100644 --- a/test/sql/test_operators.py +++ b/test/sql/test_operators.py @@ -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):