From fb7a5ed7f74de3571a1c22df3c0f551850d27a42 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 13 Jun 2024 08:46:55 +0100 Subject: [PATCH] add basic math functions (#1016) --- docs/src/piccolo/functions/index.rst | 1 + docs/src/piccolo/functions/math.rst | 28 +++ piccolo/query/functions/__init__.py | 5 + piccolo/query/functions/math.py | 48 ++++ tests/query/functions/__init__.py | 0 tests/query/functions/base.py | 34 +++ tests/query/functions/test_functions.py | 64 +++++ tests/query/functions/test_math.py | 39 +++ tests/query/functions/test_string.py | 25 ++ tests/query/functions/test_type_conversion.py | 134 ++++++++++ tests/query/test_functions.py | 238 ------------------ 11 files changed, 378 insertions(+), 238 deletions(-) create mode 100644 docs/src/piccolo/functions/math.rst create mode 100644 piccolo/query/functions/math.py create mode 100644 tests/query/functions/__init__.py create mode 100644 tests/query/functions/base.py create mode 100644 tests/query/functions/test_functions.py create mode 100644 tests/query/functions/test_math.py create mode 100644 tests/query/functions/test_string.py create mode 100644 tests/query/functions/test_type_conversion.py delete mode 100644 tests/query/test_functions.py diff --git a/docs/src/piccolo/functions/index.rst b/docs/src/piccolo/functions/index.rst index d9a412bbc..93b3fe4f7 100644 --- a/docs/src/piccolo/functions/index.rst +++ b/docs/src/piccolo/functions/index.rst @@ -8,5 +8,6 @@ Functions can be used to modify how queries are run, and what is returned. ./basic_usage ./string + ./math ./type_conversion ./aggregate diff --git a/docs/src/piccolo/functions/math.rst b/docs/src/piccolo/functions/math.rst new file mode 100644 index 000000000..6b9472764 --- /dev/null +++ b/docs/src/piccolo/functions/math.rst @@ -0,0 +1,28 @@ +Math functions +============== + +.. currentmodule:: piccolo.query.functions.math + +Abs +--- + +.. autoclass:: Abs + :class-doc-from: class + +Ceil +---- + +.. autoclass:: Ceil + :class-doc-from: class + +Floor +----- + +.. autoclass:: Floor + :class-doc-from: class + +Round +----- + +.. autoclass:: Round + :class-doc-from: class diff --git a/piccolo/query/functions/__init__.py b/piccolo/query/functions/__init__.py index f7c841d0b..9b83eca7b 100644 --- a/piccolo/query/functions/__init__.py +++ b/piccolo/query/functions/__init__.py @@ -1,17 +1,22 @@ from .aggregate import Avg, Count, Max, Min, Sum +from .math import Abs, Ceil, Floor, Round from .string import Length, Lower, Ltrim, Reverse, Rtrim, Upper from .type_conversion import Cast __all__ = ( + "Abs", "Avg", "Cast", + "Ceil", "Count", + "Floor", "Length", "Lower", "Ltrim", "Max", "Min", "Reverse", + "Round", "Rtrim", "Sum", "Upper", diff --git a/piccolo/query/functions/math.py b/piccolo/query/functions/math.py new file mode 100644 index 000000000..e0ebaf70f --- /dev/null +++ b/piccolo/query/functions/math.py @@ -0,0 +1,48 @@ +""" +These functions mirror their counterparts in the Postgresql docs: + +https://www.postgresql.org/docs/current/functions-math.html + +""" + +from .base import Function + + +class Abs(Function): + """ + Absolute value. + """ + + function_name = "ABS" + + +class Ceil(Function): + """ + Nearest integer greater than or equal to argument. + """ + + function_name = "CEIL" + + +class Floor(Function): + """ + Nearest integer less than or equal to argument. + """ + + function_name = "FLOOR" + + +class Round(Function): + """ + Rounds to nearest integer. + """ + + function_name = "ROUND" + + +__all__ = ( + "Abs", + "Ceil", + "Floor", + "Round", +) diff --git a/tests/query/functions/__init__.py b/tests/query/functions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/query/functions/base.py b/tests/query/functions/base.py new file mode 100644 index 000000000..168f5528b --- /dev/null +++ b/tests/query/functions/base.py @@ -0,0 +1,34 @@ +import typing as t +from unittest import TestCase + +from piccolo.table import Table, create_db_tables_sync, drop_db_tables_sync +from tests.example_apps.music.tables import Band, Manager + + +class FunctionTest(TestCase): + tables: t.List[t.Type[Table]] + + def setUp(self) -> None: + create_db_tables_sync(*self.tables) + + def tearDown(self) -> None: + drop_db_tables_sync(*self.tables) + + +class BandTest(FunctionTest): + tables = [Band, Manager] + + def setUp(self) -> None: + super().setUp() + + manager = Manager({Manager.name: "Guido"}) + manager.save().run_sync() + + band = Band( + { + Band.name: "Pythonistas", + Band.manager: manager, + Band.popularity: 1000, + } + ) + band.save().run_sync() diff --git a/tests/query/functions/test_functions.py b/tests/query/functions/test_functions.py new file mode 100644 index 000000000..cb306dcc4 --- /dev/null +++ b/tests/query/functions/test_functions.py @@ -0,0 +1,64 @@ +from piccolo.query.functions import Reverse, Upper +from piccolo.querystring import QueryString +from tests.base import engines_skip +from tests.example_apps.music.tables import Band + +from .base import BandTest + + +@engines_skip("sqlite") +class TestNested(BandTest): + """ + Skip the the test for SQLite, as it doesn't support ``Reverse``. + """ + + def test_nested(self): + """ + Make sure we can nest functions. + """ + response = Band.select(Upper(Reverse(Band.name))).run_sync() + self.assertListEqual(response, [{"upper": "SATSINOHTYP"}]) + + def test_nested_with_joined_column(self): + """ + Make sure nested functions can be used on a column from a joined table. + """ + response = Band.select(Upper(Reverse(Band.manager._.name))).run_sync() + self.assertListEqual(response, [{"upper": "ODIUG"}]) + + def test_nested_within_querystring(self): + """ + If we wrap a function in a custom QueryString - make sure the columns + are still accessible, so joins are successful. + """ + response = Band.select( + QueryString("CONCAT({}, '!')", Upper(Band.manager._.name)), + ).run_sync() + + self.assertListEqual(response, [{"concat": "GUIDO!"}]) + + +class TestWhereClause(BandTest): + + def test_where(self): + """ + Make sure where clauses work with functions. + """ + response = ( + Band.select(Band.name) + .where(Upper(Band.name) == "PYTHONISTAS") + .run_sync() + ) + self.assertListEqual(response, [{"name": "Pythonistas"}]) + + def test_where_with_joined_column(self): + """ + Make sure where clauses work with functions, when a joined column is + used. + """ + response = ( + Band.select(Band.name) + .where(Upper(Band.manager._.name) == "GUIDO") + .run_sync() + ) + self.assertListEqual(response, [{"name": "Pythonistas"}]) diff --git a/tests/query/functions/test_math.py b/tests/query/functions/test_math.py new file mode 100644 index 000000000..1c82f9426 --- /dev/null +++ b/tests/query/functions/test_math.py @@ -0,0 +1,39 @@ +import decimal + +from piccolo.columns import Numeric +from piccolo.query.functions.math import Abs, Ceil, Floor, Round +from piccolo.table import Table + +from .base import FunctionTest + + +class Ticket(Table): + price = Numeric(digits=(5, 2)) + + +class TestMath(FunctionTest): + + tables = [Ticket] + + def setUp(self): + super().setUp() + self.ticket = Ticket({Ticket.price: decimal.Decimal("36.50")}) + self.ticket.save().run_sync() + + def test_floor(self): + response = Ticket.select(Floor(Ticket.price, alias="price")).run_sync() + self.assertListEqual(response, [{"price": decimal.Decimal("36.00")}]) + + def test_ceil(self): + response = Ticket.select(Ceil(Ticket.price, alias="price")).run_sync() + self.assertListEqual(response, [{"price": decimal.Decimal("37.00")}]) + + def test_abs(self): + self.ticket.price = decimal.Decimal("-1.50") + self.ticket.save().run_sync() + response = Ticket.select(Abs(Ticket.price, alias="price")).run_sync() + self.assertListEqual(response, [{"price": decimal.Decimal("1.50")}]) + + def test_round(self): + response = Ticket.select(Round(Ticket.price, alias="price")).run_sync() + self.assertListEqual(response, [{"price": decimal.Decimal("37.00")}]) diff --git a/tests/query/functions/test_string.py b/tests/query/functions/test_string.py new file mode 100644 index 000000000..b87952634 --- /dev/null +++ b/tests/query/functions/test_string.py @@ -0,0 +1,25 @@ +from piccolo.query.functions.string import Upper +from tests.example_apps.music.tables import Band + +from .base import BandTest + + +class TestUpperFunction(BandTest): + + def test_column(self): + """ + Make sure we can uppercase a column's value. + """ + response = Band.select(Upper(Band.name)).run_sync() + self.assertListEqual(response, [{"upper": "PYTHONISTAS"}]) + + def test_alias(self): + response = Band.select(Upper(Band.name, alias="name")).run_sync() + self.assertListEqual(response, [{"name": "PYTHONISTAS"}]) + + def test_joined_column(self): + """ + Make sure we can uppercase a column's value from a joined table. + """ + response = Band.select(Upper(Band.manager._.name)).run_sync() + self.assertListEqual(response, [{"upper": "GUIDO"}]) diff --git a/tests/query/functions/test_type_conversion.py b/tests/query/functions/test_type_conversion.py new file mode 100644 index 000000000..598d9d37c --- /dev/null +++ b/tests/query/functions/test_type_conversion.py @@ -0,0 +1,134 @@ +from piccolo.columns import Integer, Text, Varchar +from piccolo.query.functions import Cast, Length +from tests.example_apps.music.tables import Band, Manager + +from .base import BandTest + + +class TestCast(BandTest): + def test_varchar(self): + """ + Make sure that casting to ``Varchar`` works. + """ + response = Band.select( + Cast( + Band.popularity, + as_type=Varchar(), + ) + ).run_sync() + + self.assertListEqual( + response, + [{"popularity": "1000"}], + ) + + def test_text(self): + """ + Make sure that casting to ``Text`` works. + """ + response = Band.select( + Cast( + Band.popularity, + as_type=Text(), + ) + ).run_sync() + + self.assertListEqual( + response, + [{"popularity": "1000"}], + ) + + def test_integer(self): + """ + Make sure that casting to ``Integer`` works. + """ + Band.update({Band.name: "1111"}, force=True).run_sync() + + response = Band.select( + Cast( + Band.name, + as_type=Integer(), + ) + ).run_sync() + + self.assertListEqual( + response, + [{"name": 1111}], + ) + + def test_join(self): + """ + Make sure that casting works with joins. + """ + Manager.update({Manager.name: "1111"}, force=True).run_sync() + + response = Band.select( + Band.name, + Cast( + Band.manager.name, + as_type=Integer(), + ), + ).run_sync() + + self.assertListEqual( + response, + [ + { + "name": "Pythonistas", + "manager.name": 1111, + } + ], + ) + + def test_nested_inner(self): + """ + Make sure ``Cast`` can be passed into other functions. + """ + Band.update({Band.name: "1111"}, force=True).run_sync() + + response = Band.select( + Length( + Cast( + Band.popularity, + as_type=Varchar(), + ) + ) + ).run_sync() + + self.assertListEqual( + response, + [{"length": 4}], + ) + + def test_nested_outer(self): + """ + Make sure a querystring can be passed into ``Cast`` (meaning it can be + nested). + """ + response = Band.select( + Cast( + Length(Band.name), + as_type=Varchar(), + alias="length", + ) + ).run_sync() + + self.assertListEqual( + response, + [{"length": str(len("Pythonistas"))}], + ) + + def test_where_clause(self): + """ + Make sure ``Cast`` works in a where clause. + """ + response = ( + Band.select(Band.name, Band.popularity) + .where(Cast(Band.popularity, Varchar()) == "1000") + .run_sync() + ) + + self.assertListEqual( + response, + [{"name": "Pythonistas", "popularity": 1000}], + ) diff --git a/tests/query/test_functions.py b/tests/query/test_functions.py deleted file mode 100644 index a970d9fa1..000000000 --- a/tests/query/test_functions.py +++ /dev/null @@ -1,238 +0,0 @@ -from unittest import TestCase - -from piccolo.columns import Integer, Text, Varchar -from piccolo.query.functions import Cast, Length, Reverse, Upper -from piccolo.querystring import QueryString -from piccolo.table import create_db_tables_sync, drop_db_tables_sync -from tests.base import engines_skip -from tests.example_apps.music.tables import Band, Manager - - -class FunctionTest(TestCase): - tables = (Band, Manager) - - def setUp(self) -> None: - create_db_tables_sync(*self.tables) - - manager = Manager({Manager.name: "Guido"}) - manager.save().run_sync() - - band = Band( - { - Band.name: "Pythonistas", - Band.manager: manager, - Band.popularity: 1000, - } - ) - band.save().run_sync() - - def tearDown(self) -> None: - drop_db_tables_sync(*self.tables) - - -class TestUpperFunction(FunctionTest): - - def test_column(self): - """ - Make sure we can uppercase a column's value. - """ - response = Band.select(Upper(Band.name)).run_sync() - self.assertListEqual(response, [{"upper": "PYTHONISTAS"}]) - - def test_alias(self): - response = Band.select(Upper(Band.name, alias="name")).run_sync() - self.assertListEqual(response, [{"name": "PYTHONISTAS"}]) - - def test_joined_column(self): - """ - Make sure we can uppercase a column's value from a joined table. - """ - response = Band.select(Upper(Band.manager._.name)).run_sync() - self.assertListEqual(response, [{"upper": "GUIDO"}]) - - -@engines_skip("sqlite") -class TestNested(FunctionTest): - """ - Skip the the test for SQLite, as it doesn't support ``Reverse``. - """ - - def test_nested(self): - """ - Make sure we can nest functions. - """ - response = Band.select(Upper(Reverse(Band.name))).run_sync() - self.assertListEqual(response, [{"upper": "SATSINOHTYP"}]) - - def test_nested_with_joined_column(self): - """ - Make sure nested functions can be used on a column from a joined table. - """ - response = Band.select(Upper(Reverse(Band.manager._.name))).run_sync() - self.assertListEqual(response, [{"upper": "ODIUG"}]) - - def test_nested_within_querystring(self): - """ - If we wrap a function in a custom QueryString - make sure the columns - are still accessible, so joins are successful. - """ - response = Band.select( - QueryString("CONCAT({}, '!')", Upper(Band.manager._.name)), - ).run_sync() - - self.assertListEqual(response, [{"concat": "GUIDO!"}]) - - -class TestWhereClause(FunctionTest): - - def test_where(self): - """ - Make sure where clauses work with functions. - """ - response = ( - Band.select(Band.name) - .where(Upper(Band.name) == "PYTHONISTAS") - .run_sync() - ) - self.assertListEqual(response, [{"name": "Pythonistas"}]) - - def test_where_with_joined_column(self): - """ - Make sure where clauses work with functions, when a joined column is - used. - """ - response = ( - Band.select(Band.name) - .where(Upper(Band.manager._.name) == "GUIDO") - .run_sync() - ) - self.assertListEqual(response, [{"name": "Pythonistas"}]) - - -class TestCast(FunctionTest): - def test_varchar(self): - """ - Make sure that casting to ``Varchar`` works. - """ - response = Band.select( - Cast( - Band.popularity, - as_type=Varchar(), - ) - ).run_sync() - - self.assertListEqual( - response, - [{"popularity": "1000"}], - ) - - def test_text(self): - """ - Make sure that casting to ``Text`` works. - """ - response = Band.select( - Cast( - Band.popularity, - as_type=Text(), - ) - ).run_sync() - - self.assertListEqual( - response, - [{"popularity": "1000"}], - ) - - def test_integer(self): - """ - Make sure that casting to ``Integer`` works. - """ - Band.update({Band.name: "1111"}, force=True).run_sync() - - response = Band.select( - Cast( - Band.name, - as_type=Integer(), - ) - ).run_sync() - - self.assertListEqual( - response, - [{"name": 1111}], - ) - - def test_join(self): - """ - Make sure that casting works with joins. - """ - Manager.update({Manager.name: "1111"}, force=True).run_sync() - - response = Band.select( - Band.name, - Cast( - Band.manager.name, - as_type=Integer(), - ), - ).run_sync() - - self.assertListEqual( - response, - [ - { - "name": "Pythonistas", - "manager.name": 1111, - } - ], - ) - - def test_nested_inner(self): - """ - Make sure ``Cast`` can be passed into other functions. - """ - Band.update({Band.name: "1111"}, force=True).run_sync() - - response = Band.select( - Length( - Cast( - Band.popularity, - as_type=Varchar(), - ) - ) - ).run_sync() - - self.assertListEqual( - response, - [{"length": 4}], - ) - - def test_nested_outer(self): - """ - Make sure a querystring can be passed into ``Cast`` (meaning it can be - nested). - """ - response = Band.select( - Cast( - Length(Band.name), - as_type=Varchar(), - alias="length", - ) - ).run_sync() - - self.assertListEqual( - response, - [{"length": str(len("Pythonistas"))}], - ) - - def test_where_clause(self): - """ - Make sure ``Cast`` works in a where clause. - """ - response = ( - Band.select(Band.name, Band.popularity) - .where(Cast(Band.popularity, Varchar()) == "1000") - .run_sync() - ) - - self.assertListEqual( - response, - [{"name": "Pythonistas", "popularity": 1000}], - )