From efc4f5f08342991efc0da6c8f78045e6e956a272 Mon Sep 17 00:00:00 2001 From: seladb Date: Sat, 23 May 2026 20:00:24 -0700 Subject: [PATCH 1/2] Add new string functions --- pyproject.toml | 3 ++ tests/test_filtering.py | 100 +++++++++++++++++++++++++++++++++++++++- tortoise/functions.py | 92 +++++++++++++++++++++++++++++++++++- uv.lock | 8 +--- 4 files changed, 194 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 69b8ba3b8..21e318a33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,6 +129,9 @@ version = { source = "file", path = "tortoise/__init__.py" } excludes = ["./**/.git", "./**/.*_cache", "examples"] include = ["CHANGELOG.rst", "LICENSE", "README.rst"] +[tool.uv.sources] +pypika-tortoise = { git = "https://github.com/seladb/pypika-tortoise", branch = "add-functions" } + [tool.mypy] pretty = true exclude = ["docs"] diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 08a0da02d..6f52cbc6f 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -14,9 +14,23 @@ Tournament, ) from tortoise.contrib import test -from tortoise.contrib.test.condition import In, NotEQ +from tortoise.contrib.test import requireCapability +from tortoise.contrib.test.condition import In, NotEQ, NotIn from tortoise.expressions import Case, F, Q, When -from tortoise.functions import Coalesce, Count, Length, Lower, Max, Trim, Upper +from tortoise.functions import ( + Coalesce, + Count, + Length, + Lower, + LPad, + LTrim, + Max, + Replace, + RPad, + RTrim, + Trim, + Upper, +) @pytest.mark.asyncio @@ -379,6 +393,88 @@ async def test_filter_by_aggregation_field_trim(db): assert {(t.name, t.trimmed_name) for t in tournaments} == {(" 1 ", "1")} +@pytest.mark.asyncio +@pytest.mark.parametrize( + ["name", "trim_chars", "trimmed_name"], + [ + ("xxxhellox", "x", "hello"), + ("ababhelloab", "ab", "hello"), + ], +) +async def test_filter_by_trim_with_chars(db, name, trim_chars, trimmed_name): + await Tournament.create(name=name) + tournaments = await Tournament.annotate(trimmed_name=Trim("name", trim_chars)).filter( + trimmed_name=trimmed_name + ) + + assert len(tournaments) == 1 + assert {(t.name, t.trimmed_name) for t in tournaments} == {(name, trimmed_name)} + + +@pytest.mark.asyncio +async def test_filter_by_ltrim(db): + await Tournament.create(name=" hello ") + tournaments = await Tournament.annotate(trimmed_name=LTrim("name")).filter( + trimmed_name="hello " + ) + + assert len(tournaments) == 1 + assert {(t.name, t.trimmed_name) for t in tournaments} == {(" hello ", "hello ")} + + +@pytest.mark.asyncio +async def test_filter_by_rtrim(db): + await Tournament.create(name=" hello ") + tournaments = await Tournament.annotate(trimmed_name=RTrim("name")).filter( + trimmed_name=" hello" + ) + + assert len(tournaments) == 1 + assert {(t.name, t.trimmed_name) for t in tournaments} == {(" hello ", " hello")} + + +@requireCapability(dialect=NotIn("sqlite")) +@pytest.mark.asyncio +async def test_lpad(db): + await Tournament.create(name="hello") + await Tournament.create(name="my world") + tournaments = await Tournament.annotate(pad_name=LPad("name", 12, "x")) + result = set(tournament.pad_name for tournament in tournaments) + assert result == {"xxxxmy world", "xxxxxxxhello"} + + +@requireCapability(dialect=NotIn("sqlite")) +@pytest.mark.asyncio +async def test_rpad(db): + await Tournament.create(name="hello") + await Tournament.create(name="my world") + tournaments = await Tournament.annotate(pad_name=RPad("name", 12, "x")) + result = set(tournament.pad_name for tournament in tournaments) + assert result == {"my worldxxxx", "helloxxxxxxx"} + + +@pytest.mark.asyncio +async def test_replace(db): + await Tournament.create(name="Tournament A") + await Tournament.create(name="Tournament B") + tournaments = await Tournament.annotate(replaced_name=Replace("name", "Tournament", "Contest")) + result = {t.replaced_name for t in tournaments} + assert result == {"Contest A", "Contest B"} + + +@pytest.mark.asyncio +async def test_filter_by_replace(db): + await Tournament.create(name="1st Tournament") + await Tournament.create(name="2nd Tournament") + await Tournament.create(name="3rd Place") + + tournaments = await Tournament.annotate( + replaced_name=Replace("name", "Tournament", "Contest") + ).filter(replaced_name="1st Contest") + assert len(tournaments) == 1 + assert {(t.name, t.replaced_name) for t in tournaments} == {("1st Tournament", "1st Contest")} + + @test.requireCapability(dialect=NotEQ("mssql")) @pytest.mark.asyncio async def test_filter_by_aggregation_field_length(db): diff --git a/tortoise/functions.py b/tortoise/functions.py index f5050df10..cd9e72948 100644 --- a/tortoise/functions.py +++ b/tortoise/functions.py @@ -1,6 +1,9 @@ +from typing import Any + from pypika_tortoise import SqlContext, functions +from pypika_tortoise.terms import Term -from tortoise.expressions import Aggregate, Function +from tortoise.expressions import Aggregate, CombinedExpression, F, Function ############################################################################## # Standard functions @@ -16,6 +19,93 @@ class Trim(Function): database_func = functions.Trim + def __init__( + self, + field: str | F | CombinedExpression | Function | Term, + trim_chars: str = " ", + *default_values: Any, + ) -> None: + super().__init__(field, trim_chars, *default_values) + + database_func = functions.Trim + + +class LTrim(Function): + """ + Trims whitespace from the left side of text. + + :samp:`LTrim("{FIELD_NAME}")` + """ + + database_func = functions.LTrim + + +class RTrim(Function): + """ + Trims whitespace from the right side of text. + + :samp:`RTrim("{FIELD_NAME}")` + """ + + database_func = functions.RTrim + + +class LPad(Function): + """ + Pads the left side of a string with a specified character to reach a certain length. + + :samp:`LPad("{FIELD_NAME}", length, fill_text)` + """ + + def __init__( + self, + field: str | F | CombinedExpression | Function | Term, + length: int, + fill_text: str = " ", + *default_values: Any, + ) -> None: + super().__init__(field, length, fill_text, *default_values) + + database_func = functions.LPad + + +class RPad(Function): + """ + Pads the right side of a string with a specified character to reach a certain length. + + :samp:`RPad("{FIELD_NAME}", length, fill_text)` + """ + + def __init__( + self, + field: str | F | CombinedExpression | Function | Term, + length: int, + fill_text: str = " ", + *default_values: Any, + ) -> None: + super().__init__(field, length, fill_text, *default_values) + + database_func = functions.RPad + + +class Replace(Function): + """ + Replaces all occurrences of a search string with a replacement string. + + :samp:`Replace("{FIELD_NAME}", "search", "replacement")` + """ + + def __init__( + self, + field: str | F | CombinedExpression | Function | Term, + search: str, + replacement: str, + *default_values: Any, + ) -> None: + super().__init__(field, search, replacement, *default_values) + + database_func = functions.Replace + class Length(Function): """ diff --git a/uv.lock b/uv.lock index e76fcbb0e..c7f6bf82b 100644 --- a/uv.lock +++ b/uv.lock @@ -2677,11 +2677,7 @@ wheels = [ [[package]] name = "pypika-tortoise" version = "0.6.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/06/89fe5fff93c5a01dbdeb9f3d843a7e997dc6e3a87222a260a164ff91fb81/pypika_tortoise-0.6.5.tar.gz", hash = "sha256:64d96c9b88450f6360ad22a7063933b6a90961a7317f04b2b63c98fd5d705506", size = 81468, upload-time = "2026-03-13T20:44:54.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/b8/502910eb8b315f719d8f6a6509f13a38b6c4c05378f14ac151ff347bff0a/pypika_tortoise-0.6.5-py3-none-any.whl", hash = "sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1", size = 47936, upload-time = "2026-03-13T20:44:53.541Z" }, -] +source = { git = "https://github.com/seladb/pypika-tortoise?branch=add-functions#c4c65e2e0c5067834ac7a18e86593eac3a24ec5f" } [[package]] name = "pytest" @@ -3452,7 +3448,7 @@ requires-dist = [ { name = "orjson", marker = "extra == 'accel'" }, { name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'psycopg'", specifier = ">=3.0.12,<4.0.0" }, { name = "ptpython", marker = "extra == 'ptpython'", specifier = ">=3.0.0" }, - { name = "pypika-tortoise", specifier = ">=0.6.5,<1.0.0" }, + { name = "pypika-tortoise", git = "https://github.com/seladb/pypika-tortoise?branch=add-functions" }, { name = "tomlkit", marker = "python_full_version < '3.11'", specifier = ">=0.11.4,<1.0.0" }, { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.1.0" }, { name = "uvloop", marker = "implementation_name == 'cpython' and sys_platform != 'win32' and extra == 'accel'" }, From 8d0ae7fb802931f3e6c7e69ef12326f7cce95f8f Mon Sep 17 00:00:00 2001 From: seladb Date: Mon, 25 May 2026 01:38:38 -0700 Subject: [PATCH 2/2] Move LPad and RPad to contrib --- tests/contrib/test_functions.py | 54 +++++++++++++++++++++++++- tests/test_filtering.py | 25 +----------- tortoise/contrib/mysql/functions.py | 42 +++++++++++++++++++- tortoise/contrib/postgres/functions.py | 40 +++++++++++++++++++ tortoise/functions.py | 38 ------------------ 5 files changed, 134 insertions(+), 65 deletions(-) diff --git a/tests/contrib/test_functions.py b/tests/contrib/test_functions.py index 9bf6a538b..fa6ef2010 100644 --- a/tests/contrib/test_functions.py +++ b/tests/contrib/test_functions.py @@ -1,10 +1,20 @@ import pytest import pytest_asyncio -from tests.testmodels import IntFields +from tests.testmodels import IntFields, Tournament from tortoise.contrib import test +from tortoise.contrib.mysql.functions import LPad as MySqlLPad from tortoise.contrib.mysql.functions import Rand -from tortoise.contrib.postgres.functions import Random as PostgresRandom +from tortoise.contrib.mysql.functions import RPad as MySqlRPad +from tortoise.contrib.postgres.functions import ( + LPad as PostgresLPad, +) +from tortoise.contrib.postgres.functions import ( + Random as PostgresRandom, +) +from tortoise.contrib.postgres.functions import ( + RPad as PostgresRPad, +) from tortoise.contrib.sqlite.functions import Random as SqliteRandom @@ -43,3 +53,43 @@ async def test_sqlite_func_rand(db, intfields): sql = IntFields.all().annotate(randnum=SqliteRandom()).values("intnum", "randnum").sql() expected_sql = 'SELECT "intnum" "intnum",RANDOM() "randnum" FROM "intfields"' assert sql == expected_sql + + +@test.requireCapability(dialect="postgres") +@pytest.mark.asyncio +async def test_postgres_func_lpad(db): + await Tournament.create(name="hello") + await Tournament.create(name="my world") + tournaments = await Tournament.annotate(pad_name=PostgresLPad("name", 12, "x")) + result = set(tournament.pad_name for tournament in tournaments) + assert result == {"xxxxmy world", "xxxxxxxhello"} + + +@test.requireCapability(dialect="mysql") +@pytest.mark.asyncio +async def test_mysql_func_lpad(db): + await Tournament.create(name="hello") + await Tournament.create(name="my world") + tournaments = await Tournament.annotate(pad_name=MySqlLPad("name", 12, "x")) + result = set(tournament.pad_name for tournament in tournaments) + assert result == {"xxxxmy world", "xxxxxxxhello"} + + +@test.requireCapability(dialect="postgres") +@pytest.mark.asyncio +async def test_postgres_func_rpad(db): + await Tournament.create(name="hello") + await Tournament.create(name="my world") + tournaments = await Tournament.annotate(pad_name=PostgresRPad("name", 12, "x")) + result = set(tournament.pad_name for tournament in tournaments) + assert result == {"my worldxxxx", "helloxxxxxxx"} + + +@test.requireCapability(dialect="mysql") +@pytest.mark.asyncio +async def test_mysql_func_rpad(db): + await Tournament.create(name="hello") + await Tournament.create(name="my world") + tournaments = await Tournament.annotate(pad_name=MySqlRPad("name", 12, "x")) + result = set(tournament.pad_name for tournament in tournaments) + assert result == {"my worldxxxx", "helloxxxxxxx"} diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 6f52cbc6f..5d74e06b6 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -14,19 +14,16 @@ Tournament, ) from tortoise.contrib import test -from tortoise.contrib.test import requireCapability -from tortoise.contrib.test.condition import In, NotEQ, NotIn +from tortoise.contrib.test.condition import In, NotEQ from tortoise.expressions import Case, F, Q, When from tortoise.functions import ( Coalesce, Count, Length, Lower, - LPad, LTrim, Max, Replace, - RPad, RTrim, Trim, Upper, @@ -433,26 +430,6 @@ async def test_filter_by_rtrim(db): assert {(t.name, t.trimmed_name) for t in tournaments} == {(" hello ", " hello")} -@requireCapability(dialect=NotIn("sqlite")) -@pytest.mark.asyncio -async def test_lpad(db): - await Tournament.create(name="hello") - await Tournament.create(name="my world") - tournaments = await Tournament.annotate(pad_name=LPad("name", 12, "x")) - result = set(tournament.pad_name for tournament in tournaments) - assert result == {"xxxxmy world", "xxxxxxxhello"} - - -@requireCapability(dialect=NotIn("sqlite")) -@pytest.mark.asyncio -async def test_rpad(db): - await Tournament.create(name="hello") - await Tournament.create(name="my world") - tournaments = await Tournament.annotate(pad_name=RPad("name", 12, "x")) - result = set(tournament.pad_name for tournament in tournaments) - assert result == {"my worldxxxx", "helloxxxxxxx"} - - @pytest.mark.asyncio async def test_replace(db): await Tournament.create(name="Tournament A") diff --git a/tortoise/contrib/mysql/functions.py b/tortoise/contrib/mysql/functions.py index e26e74061..8cb580192 100644 --- a/tortoise/contrib/mysql/functions.py +++ b/tortoise/contrib/mysql/functions.py @@ -1,6 +1,10 @@ from __future__ import annotations -from pypika_tortoise.terms import Function +from pypika_tortoise import functions +from pypika_tortoise.terms import Function, Term + +from tortoise.expressions import CombinedExpression, F +from tortoise.functions import Function as TortoiseFunction class Rand(Function): @@ -13,3 +17,39 @@ class Rand(Function): def __init__(self, seed: int | None = None, alias=None) -> None: super().__init__("RAND", seed, alias=alias) self.args = [self.wrap_constant(seed)] if seed is not None else [] + + +class LPad(TortoiseFunction): + """ + Pads the left side of a string with a specified character to reach a certain length. + + :samp:`LPad("{FIELD_NAME}", length, fill_text)` + """ + + def __init__( + self, + field: str | F | CombinedExpression | TortoiseFunction | Term, + length: int, + fill_text: str = " ", + ) -> None: + super().__init__(field, length, fill_text) + + database_func = functions.LPad + + +class RPad(TortoiseFunction): + """ + Pads the right side of a string with a specified character to reach a certain length. + + :samp:`RPad("{FIELD_NAME}", length, fill_text)` + """ + + def __init__( + self, + field: str | F | CombinedExpression | TortoiseFunction | Term, + length: int, + fill_text: str = " ", + ) -> None: + super().__init__(field, length, fill_text) + + database_func = functions.RPad diff --git a/tortoise/contrib/postgres/functions.py b/tortoise/contrib/postgres/functions.py index 823b4aa4c..b3ddb0aa0 100644 --- a/tortoise/contrib/postgres/functions.py +++ b/tortoise/contrib/postgres/functions.py @@ -1,5 +1,9 @@ +from pypika_tortoise import functions from pypika_tortoise.terms import Function, Term +from tortoise.expressions import CombinedExpression, F +from tortoise.functions import Function as TortoiseFunction + class ToTsVector(Function): """ @@ -37,3 +41,39 @@ class Random(Function): def __init__(self, alias=None) -> None: super().__init__("RANDOM", alias=alias) + + +class LPad(TortoiseFunction): + """ + Pads the left side of a string with a specified character to reach a certain length. + + :samp:`LPad("{FIELD_NAME}", length, fill_text)` + """ + + def __init__( + self, + field: str | F | CombinedExpression | TortoiseFunction | Term, + length: int, + fill_text: str = " ", + ) -> None: + super().__init__(field, length, fill_text) + + database_func = functions.LPad + + +class RPad(TortoiseFunction): + """ + Pads the right side of a string with a specified character to reach a certain length. + + :samp:`RPad("{FIELD_NAME}", length, fill_text)` + """ + + def __init__( + self, + field: str | F | CombinedExpression | TortoiseFunction | Term, + length: int, + fill_text: str = " ", + ) -> None: + super().__init__(field, length, fill_text) + + database_func = functions.RPad diff --git a/tortoise/functions.py b/tortoise/functions.py index cd9e72948..f97c5ffb2 100644 --- a/tortoise/functions.py +++ b/tortoise/functions.py @@ -50,44 +50,6 @@ class RTrim(Function): database_func = functions.RTrim -class LPad(Function): - """ - Pads the left side of a string with a specified character to reach a certain length. - - :samp:`LPad("{FIELD_NAME}", length, fill_text)` - """ - - def __init__( - self, - field: str | F | CombinedExpression | Function | Term, - length: int, - fill_text: str = " ", - *default_values: Any, - ) -> None: - super().__init__(field, length, fill_text, *default_values) - - database_func = functions.LPad - - -class RPad(Function): - """ - Pads the right side of a string with a specified character to reach a certain length. - - :samp:`RPad("{FIELD_NAME}", length, fill_text)` - """ - - def __init__( - self, - field: str | F | CombinedExpression | Function | Term, - length: int, - fill_text: str = " ", - *default_values: Any, - ) -> None: - super().__init__(field, length, fill_text, *default_values) - - database_func = functions.RPad - - class Replace(Function): """ Replaces all occurrences of a search string with a replacement string.