From 2480c043237bb12ec7cc5bd5ab5aa7c3c071cc02 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Fri, 14 Feb 2025 17:15:07 +0100 Subject: [PATCH 01/15] feat(chart): add quarter aggregation --- .../resources/collections/stats.py | 16 ++++-- .../datasource_django/utils/query_factory.py | 13 +++++ src/datasource_django/pyproject.toml | 7 +++ .../datasource_sqlalchemy/datasource.py | 2 +- .../utils/aggregation.py | 55 +++++++++++++++++-- .../decorators/chart/result_builder.py | 46 ++++++++++------ .../interfaces/query/aggregation.py | 3 +- 7 files changed, 115 insertions(+), 27 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py index e4c1b9ee2..72a2efb41 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py @@ -1,5 +1,5 @@ from datetime import date, datetime -from typing import Any, Dict, List, Literal, Optional, Union, cast +from typing import Any, Callable, Dict, List, Literal, Optional, Union, cast from uuid import uuid1 import pandas as pd @@ -26,9 +26,15 @@ class StatsResource(BaseCollectionResource, ContextVariableInjectorResourceMixin): - FREQUENCIES = {"Day": "d", "Week": "W-MON", "Month": "BMS", "Year": "BYS"} + FREQUENCIES = {"Day": "d", "Week": "W-MON", "Month": "BMS", "Year": "BYS", "Quarter": "QS"} - FORMAT = {"Day": "%d/%m/%Y", "Week": "W%V-%G", "Month": "%b %Y", "Year": "%Y"} + FORMAT: Dict[str, Callable[[Union[date, datetime]], str]] = { + "Day": lambda d: d.strftime("%d/%m/%Y"), + "Week": lambda d: d.strftime("W%V-%Y"), + "Month": lambda d: d.strftime("%b %Y"), + "Year": lambda d: d.strftime("%Y"), + "Quarter": lambda d: f"{d.year}-Q{pd.Timestamp(d).quarter}", + } def stats_method(self, type: str): return { @@ -161,7 +167,7 @@ async def line(self, request: RequestCollection) -> Response: f"The time chart label type must be 'str' or 'date', not {type(label)}. Skipping this record.", ) dates.append(label) - values_label[label.strftime(self.FORMAT[request.body["timeRange"]])] = row["value"] + values_label[self.FORMAT[request.body["timeRange"]](label)] = row["value"] dates.sort() end = dates[-1] @@ -170,7 +176,7 @@ async def line(self, request: RequestCollection) -> Response: for dt in pd.date_range( # type: ignore start=start, end=end, freq=self.FREQUENCIES[request.body["timeRange"]] ).to_pydatetime(): - label = dt.strftime(self.FORMAT[request.body["timeRange"]]) + label = self.FORMAT[request.body["timeRange"]](dt) data_points.append( { "label": label, diff --git a/src/datasource_django/forestadmin/datasource_django/utils/query_factory.py b/src/datasource_django/forestadmin/datasource_django/utils/query_factory.py index 6a6e3f2bf..0489c1aaa 100644 --- a/src/datasource_django/forestadmin/datasource_django/utils/query_factory.py +++ b/src/datasource_django/forestadmin/datasource_django/utils/query_factory.py @@ -2,6 +2,7 @@ from datetime import date, datetime from typing import Any, Dict, List, Optional, Set, Tuple +import pandas as pd from django.db import models from forestadmin.datasource_django.exception import DjangoDatasourceException from forestadmin.datasource_django.interface import BaseDjangoCollection @@ -311,6 +312,7 @@ class DjangoQueryGroupByHelper: DateOperation.DAY: "__day", DateOperation.WEEK: "__week", DateOperation.MONTH: "__month", + DateOperation.QUARTER: "__quarter", DateOperation.YEAR: "__year", } @@ -331,6 +333,11 @@ def get_operation_suffixes(cls, group: PlainAggregationGroup) -> List[str]: cls.DATE_OPERATION_SUFFIX_MAPPING[DateOperation.YEAR], cls.DATE_OPERATION_SUFFIX_MAPPING[DateOperation.WEEK], ] + if group["operation"] == DateOperation.QUARTER: + return [ + cls.DATE_OPERATION_SUFFIX_MAPPING[DateOperation.YEAR], + cls.DATE_OPERATION_SUFFIX_MAPPING[DateOperation.QUARTER], + ] if group["operation"] == DateOperation.DAY: return [ cls.DATE_OPERATION_SUFFIX_MAPPING[DateOperation.YEAR], @@ -380,5 +387,11 @@ def _make_date_from_record(cls, row: AggregateResult, date_field: str, date_oper row_date = datetime.strptime(str_year_week + "-1", "%Y-W%W-%w") return row_date.date() + if date_operation == DateOperation.QUARTER: + end_of_quarter_date = ( + pd.Timestamp(row[f"{date_field}__year"], (row[f"{date_field}__quarter"] * 3), 1) + pd.offsets.MonthEnd() + ) + return end_of_quarter_date.date() + if date_operation == DateOperation.DAY: return date(row[f"{date_field}__year"], row[f"{date_field}__month"], row[f"{date_field}__day"]) diff --git a/src/datasource_django/pyproject.toml b/src/datasource_django/pyproject.toml index b74d7859e..0091a8c2e 100644 --- a/src/datasource_django/pyproject.toml +++ b/src/datasource_django/pyproject.toml @@ -20,6 +20,13 @@ typing-extensions = "~=4.2" django = ">=3.2,<5.2" forestadmin-datasource-toolkit = "1.22.11" forestadmin-agent-toolkit = "1.22.11" +[[tool.poetry.dependencies.pandas]] +version = ">=1.4.0" +python = "<3.13.0" + +[[tool.poetry.dependencies.pandas]] +version = ">=2.2.3" +python = ">=3.13.0" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "test_project_datasource.settings" diff --git a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/datasource.py b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/datasource.py index 82284b1ef..b61a7e4c8 100644 --- a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/datasource.py +++ b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/datasource.py @@ -15,7 +15,7 @@ def __init__(self, Base: Any, db_uri: Optional[str] = None, live_query_connectio self._base = Base self.__is_using_flask_sqlalchemy = hasattr(Base, "Model") - bind = create_engine(db_uri, echo=False) if db_uri is not None else self._find_db_uri(Base) + bind = create_engine(db_uri, echo=True) if db_uri is not None else self._find_db_uri(Base) if bind is None: raise SqlAlchemyDatasourceException( "Cannot find database uri in your SQLAlchemy Base class. " diff --git a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py index be77891af..e228f40dc 100644 --- a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py +++ b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py @@ -6,9 +6,10 @@ from forestadmin.datasource_toolkit.exceptions import DatasourceToolkitException from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation, Aggregator, DateOperation from forestadmin.datasource_toolkit.interfaces.query.projections import Projection -from sqlalchemy import DATE, cast +from sqlalchemy import DATE, Integer, cast from sqlalchemy import column as SqlAlchemyColumn -from sqlalchemy import func, text +from sqlalchemy import extract, func, text +from sqlalchemy.dialects.postgresql import INTERVAL from sqlalchemy.engine import Dialect @@ -82,12 +83,39 @@ def build_group( class DateAggregation: @staticmethod def build_postgres(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchemyColumn: + # if operation == DateOperation.QUARTER: + # return func.date_trunc(text("'quarter'"), column) + INTERVAL("3 month") - INTERVAL("1 day"). + return func.date_trunc(operation.value.lower(), column) @staticmethod - def build_sqllite(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchemyColumn: + def build_sqlite(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchemyColumn: if operation == DateOperation.WEEK: return func.DATE(column, "weekday 1", "-7 days") + elif operation == DateOperation.QUARTER: + # floor( + # ( + # CAST( + # strftime('%m',column) AS int + # ) + 2 + # ) / 3 + # ) + # SELECT + # date(strftime('%Y', ordered_at) || '-' || + # printf('%02d', (floor((CAST(strftime('%m', ordered_at) AS INTEGER) - 1) / 3) + 1) * 3) || '-01', + # 'start of month', "+1 month", '-1 day') AS quarter_end, + # ordered_at + # FROM app_order; + + return func.date( + func.strftime("%Y", column) + + "-" + + func.printf("%02d", (func.floor((func.cast(func.strftime("%m", column), Integer) - 1) / 3) + 1) * 3) + + "-01", + "start of month", + "+1 month", + "-1 day", + ) elif operation == DateOperation.YEAR: format = "%Y-01-01" elif operation == DateOperation.MONTH: @@ -107,6 +135,15 @@ def build_mysql(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchem format = "%Y-%m-01" elif operation == DateOperation.WEEK: return cast(func.date_sub(column, text(f"INTERVAL(WEEKDAY({column})) DAY")), DATE) + elif operation == DateOperation.QUARTER: + return func.last_day( + func.str_to_date( + func.concat( + func.year(column), "-", func.lpad(func.ceiling(extract("month", column) / 3) * 3, 2, "0"), "-01" + ), + "%Y-%m-%d", + ) + ) elif operation == DateOperation.DAY: format = "%Y-%m-%d" else: @@ -121,6 +158,15 @@ def build_mssql(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchem return func.datefromparts(func.extract("year", column), func.extract("month", column), "01") elif operation == DateOperation.WEEK: return cast(func.dateadd(text("day"), -func.extract("dw", column) + 2, column), DATE) + elif operation == DateOperation.QUARTER: + return func.eomonth( + func.datefromparts( + func.extract("YEAR", column), + func.datepart(text("QUARTER"), column) * text("3"), + text("1"), + ) + ) + # docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Forestadmin1*" -p 1433:1433 --name mssql --hostname mssql -d mcr.microsoft.com/mssql/server:2022-latest elif operation == DateOperation.DAY: return func.datefromparts( func.extract("year", column), @@ -131,12 +177,13 @@ def build_mssql(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchem @classmethod def build(cls, dialect: Dialect, column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchemyColumn: if dialect.name == "sqlite": - return cls.build_sqllite(column, operation) + return cls.build_sqlite(column, operation) elif dialect.name in ["mysql", "mariadb"]: return cls.build_mysql(column, operation) elif dialect.name == "postgresql": return cls.build_postgres(column, operation) elif dialect.name == "mssql": return cls.build_mssql(column, operation) + # TODO: oracle ??? else: raise AggregationFactoryException(f"The dialect {dialect.name} is not handled") diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py index 1d3545e28..3fd6d98ff 100644 --- a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py @@ -1,6 +1,6 @@ import enum from datetime import date, datetime -from typing import Dict, List, Optional, TypedDict, Union +from typing import Callable, Dict, List, Optional, TypedDict, Union import pandas as pd from forestadmin.datasource_toolkit.interfaces.chart import ( @@ -21,6 +21,7 @@ class _DateRangeFrequency(enum.Enum): Day: str = "days" Week: str = "weeks" Month: str = "months" + Quarter: str = "quarters" Year: str = "years" @@ -37,27 +38,40 @@ def _parse_date(date_input: Union[str, date, datetime]) -> date: def _make_formatted_date_range( - first: Union[date, datetime], last: Union[date, datetime], frequency: _DateRangeFrequency, format_: str + first: Union[date, datetime], + last: Union[date, datetime], + frequency: _DateRangeFrequency, + format_fn: Callable[[Union[date, datetime]], str], ): current = first used = set() while current <= last: - yield current.strftime(format_) - used.add(current.strftime(format_)) - current = (current + pd.DateOffset(**{frequency.value: 1})).date() + yield format_fn(current) + used.add(format_fn(current)) + if frequency == _DateRangeFrequency.Quarter: + current = (current + pd.DateOffset(months=3)).date() + else: + current = (current + pd.DateOffset(**{frequency.value: 1})).date() - if last.strftime(format_) not in used: - yield last.strftime(format_) + if format_fn(last) not in used: + yield format_fn(last) class ResultBuilder: - FREQUENCIES = {"Day": Frequency.DAY, "Week": Frequency.WEEK, "Month": Frequency.MONTH, "Year": Frequency.YEAR} + FREQUENCIES = { + "Day": Frequency.DAY, + "Week": Frequency.WEEK, + "Month": Frequency.MONTH, + "Year": Frequency.YEAR, + "Quarter": Frequency.QUARTER, + } - FORMATS: Dict[DateOperation, str] = { - DateOperation.DAY: "%d/%m/%Y", - DateOperation.WEEK: "W%V-%G", - DateOperation.MONTH: "%b %Y", - DateOperation.YEAR: "%Y", + FORMATS: Dict[DateOperation, Callable[[date], str]] = { + DateOperation.DAY: lambda d: d.strftime("%d/%m/%Y"), + DateOperation.WEEK: lambda d: d.strftime("W%V-%G"), + DateOperation.MONTH: lambda d: d.strftime("%b %Y"), + DateOperation.QUARTER: lambda d: f"{d.year}-Q{pd.Timestamp(d).quarter}", + DateOperation.YEAR: lambda d: d.strftime("%Y"), } @staticmethod @@ -182,11 +196,11 @@ def _build_time_base_chart_result( if len(points) == 0: return [] points_in_date_time = [{"date": _parse_date(point["date"]), "value": point["value"]} for point in points] - format_ = ResultBuilder.FORMATS[DateOperation(time_range)] + format_fn = ResultBuilder.FORMATS[DateOperation(time_range)] formatted = {} for point in points_in_date_time: - label = point["date"].strftime(format_) + label = format_fn(point["date"]) if point["value"] is not None: formatted[label] = formatted.get(label, 0) + point["value"] @@ -195,7 +209,7 @@ def _build_time_base_chart_result( first = dates[0] last = dates[-1] for label in _make_formatted_date_range( - first, last, _DateRangeFrequency[DateOperation(time_range).value], format_ + first, last, _DateRangeFrequency[DateOperation(time_range).value], format_fn ): data_points.append({"label": label, "values": {"value": formatted.get(label, 0)}}) return data_points diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/interfaces/query/aggregation.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/interfaces/query/aggregation.py index 9d6e7d632..80424a279 100644 --- a/src/datasource_toolkit/forestadmin/datasource_toolkit/interfaces/query/aggregation.py +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/interfaces/query/aggregation.py @@ -30,12 +30,13 @@ class Aggregator(enum.Enum): class DateOperation(enum.Enum): YEAR = "Year" + QUARTER = "Quarter" MONTH = "Month" WEEK = "Week" DAY = "Day" -DateOperationLiteral = Literal["Year", "Month", "Week", "Day"] +DateOperationLiteral = Literal["Year", "Quarter", "Month", "Week", "Day"] class AggregateResult(TypedDict): From aada133bccee55a7c71fd105a438f6925032b660 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Mon, 17 Feb 2025 11:04:45 +0100 Subject: [PATCH 02/15] chore: make label be the same --- .../datasource_toolkit/decorators/chart/result_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py index 3fd6d98ff..fb274e235 100644 --- a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py @@ -70,7 +70,7 @@ class ResultBuilder: DateOperation.DAY: lambda d: d.strftime("%d/%m/%Y"), DateOperation.WEEK: lambda d: d.strftime("W%V-%G"), DateOperation.MONTH: lambda d: d.strftime("%b %Y"), - DateOperation.QUARTER: lambda d: f"{d.year}-Q{pd.Timestamp(d).quarter}", + DateOperation.QUARTER: lambda d: f"Q{pd.Timestamp(d).quarter}-{d.year}", DateOperation.YEAR: lambda d: d.strftime("%Y"), } From 55b926cfea6657be689d0515f6f23308dc9b5dac Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Mon, 17 Feb 2025 11:04:59 +0100 Subject: [PATCH 03/15] chore: remove comments --- .../utils/aggregation.py | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py index e228f40dc..19695f687 100644 --- a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py +++ b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py @@ -84,6 +84,8 @@ class DateAggregation: @staticmethod def build_postgres(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchemyColumn: # if operation == DateOperation.QUARTER: + # # TODO: adapt to the end of quarter like other SGBD ? + # # is it necessary, or the aggregation result is the same ? # return func.date_trunc(text("'quarter'"), column) + INTERVAL("3 month") - INTERVAL("1 day"). return func.date_trunc(operation.value.lower(), column) @@ -93,26 +95,12 @@ def build_sqlite(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlche if operation == DateOperation.WEEK: return func.DATE(column, "weekday 1", "-7 days") elif operation == DateOperation.QUARTER: - # floor( - # ( - # CAST( - # strftime('%m',column) AS int - # ) + 2 - # ) / 3 - # ) - # SELECT - # date(strftime('%Y', ordered_at) || '-' || - # printf('%02d', (floor((CAST(strftime('%m', ordered_at) AS INTEGER) - 1) / 3) + 1) * 3) || '-01', - # 'start of month', "+1 month", '-1 day') AS quarter_end, - # ordered_at - # FROM app_order; - return func.date( func.strftime("%Y", column) + "-" + func.printf("%02d", (func.floor((func.cast(func.strftime("%m", column), Integer) - 1) / 3) + 1) * 3) + "-01", - "start of month", + "start of month", # TODO: is this one necessary ? we just said the day of the date is "01" "+1 month", "-1 day", ) @@ -166,7 +154,6 @@ def build_mssql(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchem text("1"), ) ) - # docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Forestadmin1*" -p 1433:1433 --name mssql --hostname mssql -d mcr.microsoft.com/mssql/server:2022-latest elif operation == DateOperation.DAY: return func.datefromparts( func.extract("year", column), @@ -184,6 +171,5 @@ def build(cls, dialect: Dialect, column: SqlAlchemyColumn, operation: DateOperat return cls.build_postgres(column, operation) elif dialect.name == "mssql": return cls.build_mssql(column, operation) - # TODO: oracle ??? else: raise AggregationFactoryException(f"The dialect {dialect.name} is not handled") From 7baec9fc6a0cc87633c614f02b04a36dc115e774 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Mon, 17 Feb 2025 11:06:51 +0100 Subject: [PATCH 04/15] chore: fix linting --- .../forestadmin/datasource_sqlalchemy/utils/aggregation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py index 19695f687..ab99a23ba 100644 --- a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py +++ b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py @@ -9,7 +9,6 @@ from sqlalchemy import DATE, Integer, cast from sqlalchemy import column as SqlAlchemyColumn from sqlalchemy import extract, func, text -from sqlalchemy.dialects.postgresql import INTERVAL from sqlalchemy.engine import Dialect From e2486f24753895cf7ac1856f71db3bb446df4312 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Mon, 17 Feb 2025 11:18:21 +0100 Subject: [PATCH 05/15] fix: error while rebasing --- .../forestadmin/agent_toolkit/resources/collections/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py index 72a2efb41..30056c36c 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py @@ -30,7 +30,7 @@ class StatsResource(BaseCollectionResource, ContextVariableInjectorResourceMixin FORMAT: Dict[str, Callable[[Union[date, datetime]], str]] = { "Day": lambda d: d.strftime("%d/%m/%Y"), - "Week": lambda d: d.strftime("W%V-%Y"), + "Week": lambda d: d.strftime("W%V-%G"), "Month": lambda d: d.strftime("%b %Y"), "Year": lambda d: d.strftime("%Y"), "Quarter": lambda d: f"{d.year}-Q{pd.Timestamp(d).quarter}", From fc735bd69962510583a1fbd7f2ea425a6a76404f Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Tue, 18 Feb 2025 17:52:14 +0100 Subject: [PATCH 06/15] chore: add test & few fixes --- .../resources/collections/stats.py | 17 +- .../collections/test_stats_resources.py | 48 ++ .../tests/test_django_collection.py | 15 + .../datasource_sqlalchemy/datasource.py | 2 +- .../utils/aggregation.py | 5 - .../tests/test_sqlalchemy_collections.py | 806 +++++++++++++++++- .../chart/test_chart_result_builder.py | 17 + 7 files changed, 897 insertions(+), 13 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py index 30056c36c..4dd6b4071 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py @@ -26,14 +26,14 @@ class StatsResource(BaseCollectionResource, ContextVariableInjectorResourceMixin): - FREQUENCIES = {"Day": "d", "Week": "W-MON", "Month": "BMS", "Year": "BYS", "Quarter": "QS"} + FREQUENCIES = {"Day": "d", "Week": "W-MON", "Month": "BMS", "Year": "BYS", "Quarter": "Q"} FORMAT: Dict[str, Callable[[Union[date, datetime]], str]] = { "Day": lambda d: d.strftime("%d/%m/%Y"), "Week": lambda d: d.strftime("W%V-%G"), "Month": lambda d: d.strftime("%b %Y"), "Year": lambda d: d.strftime("%Y"), - "Quarter": lambda d: f"{d.year}-Q{pd.Timestamp(d).quarter}", + "Quarter": lambda d: f"Q{pd.Timestamp(d).quarter}-{d.year}", } def stats_method(self, type: str): @@ -173,16 +173,21 @@ async def line(self, request: RequestCollection) -> Response: end = dates[-1] start = dates[0] data_points: List[Dict[str, Union[date, Dict[str, int]]]] = [] - for dt in pd.date_range( # type: ignore - start=start, end=end, freq=self.FREQUENCIES[request.body["timeRange"]] - ).to_pydatetime(): - label = self.FORMAT[request.body["timeRange"]](dt) + + current = start + while current <= end: + label = self.FORMAT[request.body["timeRange"]](current) data_points.append( { "label": label, "values": {"value": values_label.get(label, 0)}, } ) + if request.body["timeRange"] == "Quarter": + current = (current + pd.DateOffset(months=3)).date() + else: + current = (current + pd.DateOffset(**{f'{request.body["timeRange"].lower()}s': 1})).date() + return self._build_success_response(data_points) @check_method(RequestMethod.POST) diff --git a/src/agent_toolkit/tests/resources/collections/test_stats_resources.py b/src/agent_toolkit/tests/resources/collections/test_stats_resources.py index d2e131bdb..190cdca3e 100644 --- a/src/agent_toolkit/tests/resources/collections/test_stats_resources.py +++ b/src/agent_toolkit/tests/resources/collections/test_stats_resources.py @@ -686,6 +686,54 @@ def test_line_should_return_chart_with_month_filter(self): {"label": "Feb 2022", "values": {"value": 15}}, ) + def test_line_should_return_chart_with_quarter_filter(self): + request = self.mk_request("Quarter") + with patch.object( + self.book_collection, + "aggregate", + new_callable=AsyncMock, + return_value=[ + {"value": 10, "group": {"date": "2022-03-31 00:00:00"}}, + {"value": 20, "group": {"date": "2022-06-30 00:00:00"}}, + {"value": 30, "group": {"date": "2022-09-30 00:00:00"}}, + {"value": 40, "group": {"date": "2022-12-31 00:00:00"}}, + ], + ): + response = self.loop.run_until_complete(self.stat_resource.line(request)) + + content_body = json.loads(response.body) + self.assertEqual(response.status, 200) + self.assertEqual(content_body["data"]["type"], "stats") + self.assertEqual(len(content_body["data"]["attributes"]["value"]), 4) + self.assertEqual(content_body["data"]["attributes"]["value"][0], {"label": "Q1-2022", "values": {"value": 10}}) + self.assertEqual(content_body["data"]["attributes"]["value"][1], {"label": "Q2-2022", "values": {"value": 20}}) + self.assertEqual(content_body["data"]["attributes"]["value"][2], {"label": "Q3-2022", "values": {"value": 30}}) + self.assertEqual(content_body["data"]["attributes"]["value"][3], {"label": "Q4-2022", "values": {"value": 40}}) + + def test_line_should_return_chart_with_quarter_filter_should_also_work_with_date_as_quarter_start(self): + request = self.mk_request("Quarter") + with patch.object( + self.book_collection, + "aggregate", + new_callable=AsyncMock, + return_value=[ + {"value": 10, "group": {"date": "2022-01-01 00:00:00"}}, + {"value": 20, "group": {"date": "2022-04-01 00:00:00"}}, + {"value": 30, "group": {"date": "2022-07-01 00:00:00"}}, + {"value": 40, "group": {"date": "2022-10-01 00:00:00"}}, + ], + ): + response = self.loop.run_until_complete(self.stat_resource.line(request)) + + content_body = json.loads(response.body) + self.assertEqual(response.status, 200) + self.assertEqual(content_body["data"]["type"], "stats") + self.assertEqual(len(content_body["data"]["attributes"]["value"]), 4) + self.assertEqual(content_body["data"]["attributes"]["value"][0], {"label": "Q1-2022", "values": {"value": 10}}) + self.assertEqual(content_body["data"]["attributes"]["value"][1], {"label": "Q2-2022", "values": {"value": 20}}) + self.assertEqual(content_body["data"]["attributes"]["value"][2], {"label": "Q3-2022", "values": {"value": 30}}) + self.assertEqual(content_body["data"]["attributes"]["value"][3], {"label": "Q4-2022", "values": {"value": 40}}) + def test_line_should_return_chart_with_year_filter(self): request = self.mk_request("Year") with patch.object( diff --git a/src/datasource_django/tests/test_django_collection.py b/src/datasource_django/tests/test_django_collection.py index 26c979494..ed8298843 100644 --- a/src/datasource_django/tests/test_django_collection.py +++ b/src/datasource_django/tests/test_django_collection.py @@ -512,6 +512,21 @@ async def test_should_work_by_year(self): ], ) + async def test_should_work_by_quarter(self): + ret = await self.rating_collection.aggregate( + self.mocked_caller, + Filter({}), + Aggregation( + { + "operation": "Sum", + "field": "rating", + "groups": [{"field": "rated_at", "operation": DateOperation.QUARTER}], + } + ), + ) + self.assertIn({"value": 16, "group": {"rated_at": datetime.date(2023, 3, 31)}}, ret) + self.assertIn({"value": 1, "group": {"rated_at": datetime.date(2022, 12, 31)}}, ret) + async def test_should_work_by_month(self): ret = await self.rating_collection.aggregate( self.mocked_caller, diff --git a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/datasource.py b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/datasource.py index b61a7e4c8..82284b1ef 100644 --- a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/datasource.py +++ b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/datasource.py @@ -15,7 +15,7 @@ def __init__(self, Base: Any, db_uri: Optional[str] = None, live_query_connectio self._base = Base self.__is_using_flask_sqlalchemy = hasattr(Base, "Model") - bind = create_engine(db_uri, echo=True) if db_uri is not None else self._find_db_uri(Base) + bind = create_engine(db_uri, echo=False) if db_uri is not None else self._find_db_uri(Base) if bind is None: raise SqlAlchemyDatasourceException( "Cannot find database uri in your SQLAlchemy Base class. " diff --git a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py index ab99a23ba..da1fd7969 100644 --- a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py +++ b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/aggregation.py @@ -82,10 +82,6 @@ def build_group( class DateAggregation: @staticmethod def build_postgres(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlchemyColumn: - # if operation == DateOperation.QUARTER: - # # TODO: adapt to the end of quarter like other SGBD ? - # # is it necessary, or the aggregation result is the same ? - # return func.date_trunc(text("'quarter'"), column) + INTERVAL("3 month") - INTERVAL("1 day"). return func.date_trunc(operation.value.lower(), column) @@ -99,7 +95,6 @@ def build_sqlite(column: SqlAlchemyColumn, operation: DateOperation) -> SqlAlche + "-" + func.printf("%02d", (func.floor((func.cast(func.strftime("%m", column), Integer) - 1) / 3) + 1) * 3) + "-01", - "start of month", # TODO: is this one necessary ? we just said the day of the date is "01" "+1 month", "-1 day", ) diff --git a/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py b/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py index 7397a5455..39ba6fa0d 100644 --- a/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py +++ b/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py @@ -141,7 +141,7 @@ def test__normalize_projection(self, mocked_sqlalchemy_collection_factory, mocke assert projection == Projection("city", "customers:first_name") -class TestSqlAlchemyCollectionWithModels(TestCase): +class BaseTestSqlAlchemyCollectionWithModels(TestCase): @classmethod def setUpClass(cls): cls.loop = asyncio.new_event_loop() @@ -168,6 +168,8 @@ def tearDownClass(cls): os.remove(cls.sql_alchemy_base.metadata.file_path) cls.loop.close() + +class TestSqlAlchemyCollectionWithModels(BaseTestSqlAlchemyCollectionWithModels): def test_get_columns(self): collection = self.datasource.get_collection("order") columns, relationships = collection.get_columns(Projection("amount", "status", "customer:first_name")) @@ -345,6 +347,33 @@ def test_aggregate(self): assert [*filter(lambda item: item["group"]["customer_id"] == 9, results)][0]["value"] == 4984.5 assert [*filter(lambda item: item["group"]["customer_id"] == 10, results)][0]["value"] == 3408.5 + def test_aggregate_by_date(self): + filter_ = PaginatedFilter({"condition_tree": ConditionTreeLeaf("id", Operator.LESS_THAN, 11)}) + collection = self.datasource.get_collection("order") + + results = self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Quarter"}], + } + ), + ) + ) + + self.assertEqual(len(results), 7) + self.assertIn({"value": 9744.0, "group": {"created_at": "2021-09-30"}}, results) + self.assertIn({"value": 7676.0, "group": {"created_at": "2022-09-30"}}, results) + self.assertIn({"value": 5285.0, "group": {"created_at": "2022-06-30"}}, results) + self.assertIn({"value": 5278.5, "group": {"created_at": "2023-03-31"}}, results) + self.assertIn({"value": 4753.5, "group": {"created_at": "2021-03-31"}}, results) + self.assertIn({"value": 4684.0, "group": {"created_at": "2022-12-31"}}, results) + self.assertIn({"value": 1459.0, "group": {"created_at": "2021-06-30"}}, results) + def test_get_native_driver_should_return_connection(self): with self.datasource.get_collection("order").get_native_driver() as connection: self.assertIsInstance(connection, Session) @@ -362,6 +391,781 @@ def test_get_native_driver_should_work_without_declaring_request_as_text(self): self.assertEqual(rows, [(3, 5285)]) +class TestSQLAlchemyOnSQLite(BaseTestSqlAlchemyCollectionWithModels): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.dialect = "sqlite" + + def test_can_aggregate_date_by_year(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Year"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, ' + 'strftime(:strftime_1, "order".created_at) AS created_at__grouped__ \n' + 'FROM "order" GROUP BY strftime(:strftime_1, "order".created_at) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["%Y-01-01"], + ) + + def test_can_aggregate_date_by_quarter(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Quarter"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date(strftime(:strftime_1, "order".created_at) || ' + ':strftime_2 || printf(:printf_1, (floor((CAST(strftime(:strftime_3, "order".created_at) AS INTEGER) ' + "- :param_1) / CAST(:param_2 AS NUMERIC)) + :floor_1) * :param_3) || :param_4, :date_1, :date_2) " + "AS created_at__grouped__ \n" + 'FROM "order" GROUP BY date(strftime(:strftime_1, "order".created_at) || :strftime_2 || ' + 'printf(:printf_1, (floor((CAST(strftime(:strftime_3, "order".created_at) AS INTEGER) - :param_1) / ' + "CAST(:param_2 AS NUMERIC)) + :floor_1) * :param_3) || :param_4, :date_1, :date_2) " + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["%Y", "-", "%02d", "%m", 1, 3, 1, 3, "-01", "+1 month", "-1 day"], + ) + + def test_can_aggregate_date_by_month(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Month"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, strftime(:strftime_1, "order".created_at) ' + "AS created_at__grouped__ \n" + 'FROM "order" GROUP BY strftime(:strftime_1, "order".created_at) ORDER BY __aggregate__ DESC', + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["%Y-%m-01"], + ) + + def test_can_aggregate_date_by_week(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Week"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, DATE("order".created_at, :DATE_1, :DATE_2) AS ' + 'created_at__grouped__ \nFROM "order" ' + 'GROUP BY DATE("order".created_at, :DATE_1, :DATE_2) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["weekday 1", "-7 days"], + ) + + def test_can_aggregate_date_by_day(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Day"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, strftime(:strftime_1, "order".created_at) ' + "AS created_at__grouped__ \n" + 'FROM "order" GROUP BY strftime(:strftime_1, "order".created_at) ORDER BY __aggregate__ DESC', + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["%Y-%m-%d"], + ) + + +class TestSQLAlchemyOnPostgres(BaseTestSqlAlchemyCollectionWithModels): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.dialect = "postgresql" + + def test_can_aggregate_date_by_year(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Year"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date_trunc(:date_trunc_1, "order".created_at) ' + "AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY date_trunc(:date_trunc_1, "order".created_at) ORDER BY __aggregate__ DESC NULLS LAST', + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["year"], + ) + + def test_can_aggregate_date_by_quarter(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Quarter"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date_trunc(:date_trunc_1, "order".created_at) ' + "AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY date_trunc(:date_trunc_1, "order".created_at) ORDER BY __aggregate__ DESC NULLS LAST', + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["quarter"], + ) + + def test_can_aggregate_date_by_month(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Month"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date_trunc(:date_trunc_1, "order".created_at) ' + "AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY date_trunc(:date_trunc_1, "order".created_at) ORDER BY __aggregate__ DESC NULLS LAST', + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["month"], + ) + + def test_can_aggregate_date_by_week(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Week"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date_trunc(:date_trunc_1, "order".created_at) AS ' + "created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY date_trunc(:date_trunc_1, "order".created_at) ' + "ORDER BY __aggregate__ DESC NULLS LAST", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["week"], + ) + + def test_can_aggregate_date_by_day(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Day"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date_trunc(:date_trunc_1, "order".created_at) AS ' + "created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY date_trunc(:date_trunc_1, "order".created_at) ' + "ORDER BY __aggregate__ DESC NULLS LAST", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["day"], + ) + + +class TestSQLAlchemyOnMySQL(BaseTestSqlAlchemyCollectionWithModels): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.dialect = "mysql" # same as 'mariadb' + + def test_can_aggregate_date_by_year(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Year"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date_format("order".created_at, :date_format_1) ' + "AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY date_format("order".created_at, :date_format_1) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["%Y-01-01"], + ) + + def test_can_aggregate_date_by_quarter(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Quarter"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, last_day(str_to_date(concat(year("order".created_at), ' + ':concat_1, lpad(ceiling(EXTRACT(month FROM "order".created_at) / CAST(:param_1 AS NUMERIC)' + ") * :ceiling_1, :lpad_1, :lpad_2), :concat_2), :str_to_date_1)) AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY last_day(str_to_date(concat(year("order".created_at), :concat_1, ' + 'lpad(ceiling(EXTRACT(month FROM "order".created_at) / CAST(:param_1 AS NUMERIC)' + ") * :ceiling_1, :lpad_1, :lpad_2), :concat_2), :str_to_date_1)) " + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["-", 3, 3, 2, "0", "-01", "%Y-%m-%d"], + ) + + def test_can_aggregate_date_by_month(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Month"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date_format("order".created_at, :date_format_1) ' + "AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY date_format("order".created_at, :date_format_1) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["%Y-%m-01"], + ) + + def test_can_aggregate_date_by_week(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Week"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, CAST(date_sub("order".created_at, ' + "INTERVAL(WEEKDAY(order.created_at)) DAY) AS DATE) AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY CAST(date_sub("order".created_at, INTERVAL(WEEKDAY(order.created_at)) DAY) AS DATE) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + [], + ) + + def test_can_aggregate_date_by_day(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Day"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, date_format("order".created_at, :date_format_1) ' + "AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY date_format("order".created_at, :date_format_1) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["%Y-%m-%d"], + ) + + +class TestSQLAlchemyOnMSSQL(BaseTestSqlAlchemyCollectionWithModels): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.dialect = "mssql" + + def test_can_aggregate_date_by_year(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Year"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, ' + 'datefromparts(EXTRACT(year FROM "order".created_at), ' + ":datefromparts_1, :datefromparts_2) " + "AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY datefromparts(EXTRACT(year FROM "order".created_at), :datefromparts_1, :datefromparts_2) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["01", "01"], + ) + + def test_can_aggregate_date_by_quarter(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Quarter"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, ' + 'eomonth(datefromparts(EXTRACT(YEAR FROM "order".created_at), ' + 'datepart(QUARTER, "order".created_at) * 3, 1)) AS created_at__grouped__ \n' + 'FROM "order" ' + 'GROUP BY eomonth(datefromparts(EXTRACT(YEAR FROM "order".created_at), ' + 'datepart(QUARTER, "order".created_at) * 3, 1)) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + [], + ) + + def test_can_aggregate_date_by_month(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Month"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, ' + 'datefromparts(EXTRACT(year FROM "order".created_at), EXTRACT(month FROM "order".created_at), ' + ":datefromparts_1) AS created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY datefromparts(EXTRACT(year FROM "order".created_at), EXTRACT(month FROM "order".created_at), ' + ":datefromparts_1) " + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + ["01"], + ) + + def test_can_aggregate_date_by_week(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Week"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, ' + 'CAST(dateadd(day, -EXTRACT(dw FROM "order".created_at) + :param_1, "order".created_at) AS DATE) AS ' + "created_at__grouped__ \n" + 'FROM "order" ' + 'GROUP BY CAST(dateadd(day, -EXTRACT(dw FROM "order".created_at) + :param_1, "order".created_at) ' + "AS DATE) " + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + [2], + ) + + def test_can_aggregate_date_by_day(self): + with patch.object(self.datasource.Session, "begin") as mock_begin: + mock_session = Mock() + mock_session.execute = Mock(return_value=[]) + mock_session.bind.dialect.name = self.dialect + mock_begin.return_value.__enter__.return_value = mock_session + + filter_ = PaginatedFilter({}) + collection = self.datasource.get_collection("order") + self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Day"}], + } + ), + ) + ) + query = mock_session.execute.call_args.args[0] + sql_query = str(query) + self.assertEqual( + sql_query, + 'SELECT avg("order".amount) AS __aggregate__, ' + 'datefromparts(EXTRACT(year FROM "order".created_at), EXTRACT(month FROM "order".created_at), ' + 'EXTRACT(day FROM "order".created_at)) AS created_at__grouped__ \n' + 'FROM "order" ' + 'GROUP BY datefromparts(EXTRACT(year FROM "order".created_at), EXTRACT(month FROM "order".created_at), ' + 'EXTRACT(day FROM "order".created_at)) ' + "ORDER BY __aggregate__ DESC", + ) + self.assertEqual( + [p.value for p in query._get_embedded_bindparams()], + [], + ) + + class testSqlAlchemyCollectionFactory(TestCase): def test_create(self): mocked_collection = Mock() diff --git a/src/datasource_toolkit/tests/decorators/chart/test_chart_result_builder.py b/src/datasource_toolkit/tests/decorators/chart/test_chart_result_builder.py index 43c225121..33b6caf04 100644 --- a/src/datasource_toolkit/tests/decorators/chart/test_chart_result_builder.py +++ b/src/datasource_toolkit/tests/decorators/chart/test_chart_result_builder.py @@ -109,6 +109,23 @@ def test_time_based_should_return_correct_format_week(self): {"label": "W02-1986", "values": {"value": 7}}, ] + def test_time_based_should_return_correct_format_quarter(self): + result = ResultBuilder.time_based( + DateOperation.QUARTER, + { + "2023-01-07": 3, + "2023-01-08": 4, + "2023-07-26": 1, + "2023-12-31": 1, + }, + ) + assert result == [ + {"label": "Q1-2023", "values": {"value": 7}}, + {"label": "Q2-2023", "values": {"value": 0}}, + {"label": "Q3-2023", "values": {"value": 1}}, + {"label": "Q4-2023", "values": {"value": 1}}, + ] + def test_time_based_should_return_correct_format_week_iso_year(self): result = ResultBuilder.time_based( DateOperation.WEEK, From 583b342a4ba81693e13c8cb2520b01c8b139e068 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 19 Feb 2025 11:00:25 +0100 Subject: [PATCH 07/15] chore: add few more tests --- .../tests/test_sqlalchemy_collections.py | 196 +++++++++++++++++- 1 file changed, 195 insertions(+), 1 deletion(-) diff --git a/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py b/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py index 39ba6fa0d..657b62fcc 100644 --- a/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py +++ b/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py @@ -18,6 +18,7 @@ from forestadmin.datasource_sqlalchemy.exceptions import SqlAlchemyCollectionException from forestadmin.datasource_toolkit.interfaces.fields import Operator from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation +from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.branch import ConditionTreeBranch from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.leaf import ConditionTreeLeaf from forestadmin.datasource_toolkit.interfaces.query.filter.paginated import PaginatedFilter from forestadmin.datasource_toolkit.interfaces.query.projections import Projection @@ -256,6 +257,102 @@ def test_list_filter_relation(self): self.assertEqual(len(results), 1) self.assertEqual(results[0]["customer"]["id"], 1) + def test_list_with_filter_with_aggregator(self): + collection = self.datasource.get_collection("order") + filter_ = PaginatedFilter( + { + "condition_tree": ConditionTreeBranch( + "and", + [ConditionTreeLeaf("id", Operator.LESS_THAN, 6), ConditionTreeLeaf("id", Operator.GREATER_THAN, 1)], + ), + } + ) + + results = self.loop.run_until_complete( + collection.list(self.mocked_caller, filter_, Projection("id", "created_at")) + ) + self.assertEqual(len(results), 4) + + filter_ = PaginatedFilter( + { + "condition_tree": ConditionTreeBranch( + "or", [ConditionTreeLeaf("id", "equal", 6), ConditionTreeLeaf("id", "equal", 1)] + ), + } + ) + results = self.loop.run_until_complete( + collection.list(self.mocked_caller, filter_, Projection("id", "created_at")) + ) + self.assertEqual(len(results), 2) + + def test_list_should_handle_sort(self): + collection = self.datasource.get_collection("order") + filter_ = PaginatedFilter( + { + "condition_tree": ConditionTreeBranch( + "and", + [ConditionTreeLeaf("id", Operator.LESS_THAN, 6), ConditionTreeLeaf("id", Operator.GREATER_THAN, 1)], + ), + "sort": [{"field": "id", "ascending": True}], + } + ) + + results = self.loop.run_until_complete( + collection.list(self.mocked_caller, filter_, Projection("id", "created_at")) + ) + self.assertEqual(len(results), 4) + for i in range(2, 6): + self.assertEqual(results[i - 2]["id"], i) + + filter_ = PaginatedFilter( + { + "condition_tree": ConditionTreeBranch( + "and", + [ConditionTreeLeaf("id", Operator.LESS_THAN, 6), ConditionTreeLeaf("id", Operator.GREATER_THAN, 1)], + ), + "sort": [{"field": "id", "ascending": False}], + } + ) + + results = self.loop.run_until_complete( + collection.list(self.mocked_caller, filter_, Projection("id", "created_at")) + ) + self.assertEqual(len(results), 4) + self.assertEqual(results[0]["id"], 5) + self.assertEqual(results[1]["id"], 4) + self.assertEqual(results[2]["id"], 3) + self.assertEqual(results[3]["id"], 2) + + def test_list_should_handle_multiple_sort(self): + collection = self.datasource.get_collection("order") + filter_ = PaginatedFilter( + { + "condition_tree": ConditionTreeBranch( + "and", + [ConditionTreeLeaf("id", Operator.LESS_THAN, 6), ConditionTreeLeaf("id", Operator.GREATER_THAN, 1)], + ), + "sort": [ + {"field": "status", "ascending": True}, + {"field": "customer:id", "ascending": True}, + {"field": "amount", "ascending": False}, + ], + } + ) + + results = self.loop.run_until_complete( + collection.list(self.mocked_caller, filter_, Projection("id", "customer:id", "status", "amount")) + ) + self.assertEqual(len(results), 4) + self.assertEqual( + results, + [ + {"id": 5, "status": models.ORDER_STATUS.DELIVERED, "amount": 9526, "customer": {"id": 8}}, + {"id": 3, "status": models.ORDER_STATUS.DELIVERED, "amount": 5285, "customer": {"id": 9}}, + {"id": 4, "status": models.ORDER_STATUS.DELIVERED, "amount": 4684, "customer": {"id": 9}}, + {"id": 2, "status": models.ORDER_STATUS.DELIVERED, "amount": 2664, "customer": {"id": 10}}, + ], + ) + def test_create(self): order = { "id": 11, @@ -347,7 +444,30 @@ def test_aggregate(self): assert [*filter(lambda item: item["group"]["customer_id"] == 9, results)][0]["value"] == 4984.5 assert [*filter(lambda item: item["group"]["customer_id"] == 10, results)][0]["value"] == 3408.5 - def test_aggregate_by_date(self): + def test_aggregate_by_date_year(self): + filter_ = PaginatedFilter({"condition_tree": ConditionTreeLeaf("id", Operator.LESS_THAN, 11)}) + collection = self.datasource.get_collection("order") + + results = self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Year"}], + } + ), + ) + ) + + self.assertEqual(len(results), 3) + self.assertIn({"value": 5881.666666666667, "group": {"created_at": "2022-01-01"}}, results) + self.assertIn({"value": 5278.5, "group": {"created_at": "2023-01-01"}}, results) + self.assertIn({"value": 4433.8, "group": {"created_at": "2021-01-01"}}, results) + + def test_aggregate_by_date_quarter(self): filter_ = PaginatedFilter({"condition_tree": ConditionTreeLeaf("id", Operator.LESS_THAN, 11)}) collection = self.datasource.get_collection("order") @@ -374,6 +494,80 @@ def test_aggregate_by_date(self): self.assertIn({"value": 4684.0, "group": {"created_at": "2022-12-31"}}, results) self.assertIn({"value": 1459.0, "group": {"created_at": "2021-06-30"}}, results) + def test_aggregate_by_date_month(self): + filter_ = PaginatedFilter( + { + "condition_tree": ConditionTreeBranch( + "and", + [ + ConditionTreeLeaf("id", Operator.LESS_THAN, 11), + ConditionTreeLeaf("id", Operator.GREATER_THAN, 4), + ], + ) + } + ) + collection = self.datasource.get_collection("order") + + results = self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Month"}], + } + ), + ) + ) + + self.assertEqual(len(results), 6) + self.assertIn({"value": 9744.0, "group": {"created_at": "2021-07-01"}}, results) + self.assertIn({"value": 9526.0, "group": {"created_at": "2023-02-01"}}, results) + self.assertIn({"value": 7676.0, "group": {"created_at": "2022-08-01"}}, results) + self.assertIn({"value": 5354.0, "group": {"created_at": "2021-01-01"}}, results) + self.assertIn({"value": 4153.0, "group": {"created_at": "2021-03-01"}}, results) + self.assertIn({"value": 254.0, "group": {"created_at": "2021-05-01"}}, results) + + # TODO: test week + + def test_aggregate_by_date_day(self): + filter_ = PaginatedFilter( + { + "condition_tree": ConditionTreeBranch( + "and", + [ + ConditionTreeLeaf("id", Operator.LESS_THAN, 11), + ConditionTreeLeaf("id", Operator.GREATER_THAN, 4), + ], + ) + } + ) + collection = self.datasource.get_collection("order") + + results = self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Day"}], + } + ), + ) + ) + + self.assertEqual(len(results), 6) + self.assertIn({"value": 9744.0, "group": {"created_at": "2021-07-05"}}, results) + self.assertIn({"value": 9526.0, "group": {"created_at": "2023-02-27"}}, results) + self.assertIn({"value": 7676.0, "group": {"created_at": "2022-08-07"}}, results) + self.assertIn({"value": 5354.0, "group": {"created_at": "2021-01-13"}}, results) + self.assertIn({"value": 4153.0, "group": {"created_at": "2021-03-13"}}, results) + self.assertIn({"value": 254.0, "group": {"created_at": "2021-05-30"}}, results) + def test_get_native_driver_should_return_connection(self): with self.datasource.get_collection("order").get_native_driver() as connection: self.assertIsInstance(connection, Session) From 710579d7dc680847dbe03c4b2b92f3364808778c Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 19 Feb 2025 11:25:46 +0100 Subject: [PATCH 08/15] chore: deduplicate code --- .../resources/collections/stats.py | 29 ++++++--------- .../decorators/chart/result_builder.py | 35 ++++++++++--------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py index 4dd6b4071..becd73996 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py @@ -15,6 +15,11 @@ from forestadmin.agent_toolkit.resources.collections.requests import RequestCollection, RequestCollectionException from forestadmin.agent_toolkit.resources.context_variable_injector_mixin import ContextVariableInjectorResourceMixin from forestadmin.agent_toolkit.utils.context import FileResponse, HttpResponseBuilder, Request, RequestMethod, Response +from forestadmin.datasource_toolkit.decorators.chart.result_builder import ( + DateRangeFrequency, + make_formatted_date_range, + parse_date, +) from forestadmin.datasource_toolkit.exceptions import ForbiddenError, ForestException from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.base import ConditionTree @@ -155,38 +160,24 @@ async def line(self, request: RequestCollection) -> Response: for row in rows: label = row["group"][request.body["groupByFieldName"]] if label is not None: - if isinstance(label, str): - label = datetime.fromisoformat(label).date() - elif isinstance(label, datetime): - label = label.date() - elif isinstance(label, date): - pass - else: - ForestLogger.log( - "warning", - f"The time chart label type must be 'str' or 'date', not {type(label)}. Skipping this record.", - ) + label = parse_date(label) dates.append(label) values_label[self.FORMAT[request.body["timeRange"]](label)] = row["value"] dates.sort() end = dates[-1] start = dates[0] - data_points: List[Dict[str, Union[date, Dict[str, int]]]] = [] + data_points: List[Dict[str, Union[date, Dict[str, int], str]]] = [] - current = start - while current <= end: - label = self.FORMAT[request.body["timeRange"]](current) + for label in make_formatted_date_range( + start, end, DateRangeFrequency[request.body["timeRange"]], self.FORMAT[request.body["timeRange"]] + ): data_points.append( { "label": label, "values": {"value": values_label.get(label, 0)}, } ) - if request.body["timeRange"] == "Quarter": - current = (current + pd.DateOffset(months=3)).date() - else: - current = (current + pd.DateOffset(**{f'{request.body["timeRange"].lower()}s': 1})).date() return self._build_success_response(data_points) diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py index fb274e235..7fb172636 100644 --- a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py @@ -1,6 +1,6 @@ import enum from datetime import date, datetime -from typing import Callable, Dict, List, Optional, TypedDict, Union +from typing import Callable, Dict, Iterator, List, Optional, TypedDict, Union import pandas as pd from forestadmin.datasource_toolkit.interfaces.chart import ( @@ -17,18 +17,18 @@ from forestadmin.datasource_toolkit.interfaces.query.condition_tree.transforms.time import Frequency -class _DateRangeFrequency(enum.Enum): - Day: str = "days" - Week: str = "weeks" - Month: str = "months" - Quarter: str = "quarters" - Year: str = "years" +class DateRangeFrequency(enum.Enum): + Day = "days" + Week = "weeks" + Month = "months" + Quarter = "quarters" + Year = "years" MultipleTimeBasedLines = List[TypedDict("Line", {"label": str, "values": List[Union[int, float, None]]})] -def _parse_date(date_input: Union[str, date, datetime]) -> date: +def parse_date(date_input: Union[str, date, datetime]) -> date: if isinstance(date_input, str): return datetime.fromisoformat(date_input).date() elif isinstance(date_input, datetime): @@ -37,18 +37,19 @@ def _parse_date(date_input: Union[str, date, datetime]) -> date: return date_input -def _make_formatted_date_range( +def make_formatted_date_range( first: Union[date, datetime], last: Union[date, datetime], - frequency: _DateRangeFrequency, + frequency: DateRangeFrequency, format_fn: Callable[[Union[date, datetime]], str], -): +) -> Iterator[str]: current = first used = set() while current <= last: - yield format_fn(current) - used.add(format_fn(current)) - if frequency == _DateRangeFrequency.Quarter: + formatted = format_fn(current) + yield formatted + used.add(formatted) + if frequency == DateRangeFrequency.Quarter: current = (current + pd.DateOffset(months=3)).date() else: current = (current + pd.DateOffset(**{frequency.value: 1})).date() @@ -195,7 +196,7 @@ def _build_time_base_chart_result( """ if len(points) == 0: return [] - points_in_date_time = [{"date": _parse_date(point["date"]), "value": point["value"]} for point in points] + points_in_date_time = [{"date": parse_date(point["date"]), "value": point["value"]} for point in points] format_fn = ResultBuilder.FORMATS[DateOperation(time_range)] formatted = {} @@ -208,8 +209,8 @@ def _build_time_base_chart_result( dates = sorted([p["date"] for p in points_in_date_time]) first = dates[0] last = dates[-1] - for label in _make_formatted_date_range( - first, last, _DateRangeFrequency[DateOperation(time_range).value], format_fn + for label in make_formatted_date_range( + first, last, DateRangeFrequency[DateOperation(time_range).value], format_fn ): data_points.append({"label": label, "values": {"value": formatted.get(label, 0)}}) return data_points From 73e3e4eafac9404883a437dc53841ff715a19c16 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 19 Feb 2025 11:26:01 +0100 Subject: [PATCH 09/15] chore: remove useless code --- .../agent_toolkit/resources/collections/stats.py | 2 +- .../datasource_toolkit/decorators/chart/result_builder.py | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py index becd73996..5b5d65976 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py @@ -31,7 +31,7 @@ class StatsResource(BaseCollectionResource, ContextVariableInjectorResourceMixin): - FREQUENCIES = {"Day": "d", "Week": "W-MON", "Month": "BMS", "Year": "BYS", "Quarter": "Q"} + # FREQUENCIES = {"Day": "d", "Week": "W-MON", "Month": "BMS", "Year": "BYS", "Quarter": "Q"} FORMAT: Dict[str, Callable[[Union[date, datetime]], str]] = { "Day": lambda d: d.strftime("%d/%m/%Y"), diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py index 7fb172636..e8a089ad9 100644 --- a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py @@ -59,14 +59,6 @@ def make_formatted_date_range( class ResultBuilder: - FREQUENCIES = { - "Day": Frequency.DAY, - "Week": Frequency.WEEK, - "Month": Frequency.MONTH, - "Year": Frequency.YEAR, - "Quarter": Frequency.QUARTER, - } - FORMATS: Dict[DateOperation, Callable[[date], str]] = { DateOperation.DAY: lambda d: d.strftime("%d/%m/%Y"), DateOperation.WEEK: lambda d: d.strftime("W%V-%G"), From fc17e78e94672912dd20595387b1b81f5caf7168 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 19 Feb 2025 11:54:51 +0100 Subject: [PATCH 10/15] chore: continue deduplicating code --- .../resources/collections/stats.py | 36 ++++------- .../decorators/chart/result_builder.py | 63 +++---------------- .../datasource_toolkit/utils/date_utils.py | 49 +++++++++++++++ 3 files changed, 69 insertions(+), 79 deletions(-) create mode 100644 src/datasource_toolkit/forestadmin/datasource_toolkit/utils/date_utils.py diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py index 5b5d65976..9a824a373 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/stats.py @@ -1,8 +1,7 @@ -from datetime import date, datetime -from typing import Any, Callable, Dict, List, Literal, Optional, Union, cast +from datetime import date +from typing import Any, Dict, List, Literal, Optional, Union, cast from uuid import uuid1 -import pandas as pd from forestadmin.agent_toolkit.forest_logger import ForestLogger from forestadmin.agent_toolkit.resources.collections.base_collection_resource import BaseCollectionResource from forestadmin.agent_toolkit.resources.collections.decorators import ( @@ -15,32 +14,22 @@ from forestadmin.agent_toolkit.resources.collections.requests import RequestCollection, RequestCollectionException from forestadmin.agent_toolkit.resources.context_variable_injector_mixin import ContextVariableInjectorResourceMixin from forestadmin.agent_toolkit.utils.context import FileResponse, HttpResponseBuilder, Request, RequestMethod, Response -from forestadmin.datasource_toolkit.decorators.chart.result_builder import ( - DateRangeFrequency, - make_formatted_date_range, - parse_date, -) from forestadmin.datasource_toolkit.exceptions import ForbiddenError, ForestException -from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation +from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation, DateOperation from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.base import ConditionTree from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.branch import Aggregator, ConditionTreeBranch from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.leaf import ConditionTreeLeaf from forestadmin.datasource_toolkit.interfaces.query.filter.factory import FilterFactory from forestadmin.datasource_toolkit.interfaces.query.filter.unpaginated import Filter +from forestadmin.datasource_toolkit.utils.date_utils import ( + DATE_OPERATION_STR_FORMAT_FN, + make_formatted_date_range, + parse_date, +) from forestadmin.datasource_toolkit.utils.schema import SchemaUtils class StatsResource(BaseCollectionResource, ContextVariableInjectorResourceMixin): - # FREQUENCIES = {"Day": "d", "Week": "W-MON", "Month": "BMS", "Year": "BYS", "Quarter": "Q"} - - FORMAT: Dict[str, Callable[[Union[date, datetime]], str]] = { - "Day": lambda d: d.strftime("%d/%m/%Y"), - "Week": lambda d: d.strftime("W%V-%G"), - "Month": lambda d: d.strftime("%b %Y"), - "Year": lambda d: d.strftime("%Y"), - "Quarter": lambda d: f"Q{pd.Timestamp(d).quarter}-{d.year}", - } - def stats_method(self, type: str): return { "Value": self.value, @@ -146,12 +135,13 @@ async def line(self, request: RequestCollection) -> Response: if key not in request.body: raise ForestException(f"The parameter {key} is not defined") + date_operation = DateOperation(request.body["timeRange"]) current_filter = await self._get_filter(request) aggregation = Aggregation( { "operation": request.body["aggregator"], "field": request.body.get("aggregateFieldName"), - "groups": [{"field": request.body["groupByFieldName"], "operation": request.body["timeRange"]}], + "groups": [{"field": request.body["groupByFieldName"], "operation": date_operation}], } ) rows = await request.collection.aggregate(request.user, current_filter, aggregation) @@ -162,16 +152,14 @@ async def line(self, request: RequestCollection) -> Response: if label is not None: label = parse_date(label) dates.append(label) - values_label[self.FORMAT[request.body["timeRange"]](label)] = row["value"] + values_label[DATE_OPERATION_STR_FORMAT_FN[date_operation](label)] = row["value"] dates.sort() end = dates[-1] start = dates[0] data_points: List[Dict[str, Union[date, Dict[str, int], str]]] = [] - for label in make_formatted_date_range( - start, end, DateRangeFrequency[request.body["timeRange"]], self.FORMAT[request.body["timeRange"]] - ): + for label in make_formatted_date_range(start, end, date_operation): data_points.append( { "label": label, diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py index e8a089ad9..9fb944d3a 100644 --- a/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/chart/result_builder.py @@ -1,8 +1,6 @@ -import enum from datetime import date, datetime -from typing import Callable, Dict, Iterator, List, Optional, TypedDict, Union +from typing import Dict, List, Optional, TypedDict, Union -import pandas as pd from forestadmin.datasource_toolkit.interfaces.chart import ( DistributionChart, LeaderboardChart, @@ -14,59 +12,16 @@ ValueChart, ) from forestadmin.datasource_toolkit.interfaces.query.aggregation import DateOperation, DateOperationLiteral -from forestadmin.datasource_toolkit.interfaces.query.condition_tree.transforms.time import Frequency - - -class DateRangeFrequency(enum.Enum): - Day = "days" - Week = "weeks" - Month = "months" - Quarter = "quarters" - Year = "years" - +from forestadmin.datasource_toolkit.utils.date_utils import ( + DATE_OPERATION_STR_FORMAT_FN, + make_formatted_date_range, + parse_date, +) MultipleTimeBasedLines = List[TypedDict("Line", {"label": str, "values": List[Union[int, float, None]]})] -def parse_date(date_input: Union[str, date, datetime]) -> date: - if isinstance(date_input, str): - return datetime.fromisoformat(date_input).date() - elif isinstance(date_input, datetime): - return date_input.date() - elif isinstance(date_input, date): - return date_input - - -def make_formatted_date_range( - first: Union[date, datetime], - last: Union[date, datetime], - frequency: DateRangeFrequency, - format_fn: Callable[[Union[date, datetime]], str], -) -> Iterator[str]: - current = first - used = set() - while current <= last: - formatted = format_fn(current) - yield formatted - used.add(formatted) - if frequency == DateRangeFrequency.Quarter: - current = (current + pd.DateOffset(months=3)).date() - else: - current = (current + pd.DateOffset(**{frequency.value: 1})).date() - - if format_fn(last) not in used: - yield format_fn(last) - - class ResultBuilder: - FORMATS: Dict[DateOperation, Callable[[date], str]] = { - DateOperation.DAY: lambda d: d.strftime("%d/%m/%Y"), - DateOperation.WEEK: lambda d: d.strftime("W%V-%G"), - DateOperation.MONTH: lambda d: d.strftime("%b %Y"), - DateOperation.QUARTER: lambda d: f"Q{pd.Timestamp(d).quarter}-{d.year}", - DateOperation.YEAR: lambda d: d.strftime("%Y"), - } - @staticmethod def value(value: Union[int, float], previous_value: Optional[Union[int, float]] = None) -> ValueChart: return ValueChart(countCurrent=value, countPrevious=previous_value) @@ -189,7 +144,7 @@ def _build_time_base_chart_result( if len(points) == 0: return [] points_in_date_time = [{"date": parse_date(point["date"]), "value": point["value"]} for point in points] - format_fn = ResultBuilder.FORMATS[DateOperation(time_range)] + format_fn = DATE_OPERATION_STR_FORMAT_FN[DateOperation(time_range)] formatted = {} for point in points_in_date_time: @@ -201,8 +156,6 @@ def _build_time_base_chart_result( dates = sorted([p["date"] for p in points_in_date_time]) first = dates[0] last = dates[-1] - for label in make_formatted_date_range( - first, last, DateRangeFrequency[DateOperation(time_range).value], format_fn - ): + for label in make_formatted_date_range(first, last, DateOperation(time_range)): data_points.append({"label": label, "values": {"value": formatted.get(label, 0)}}) return data_points diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/utils/date_utils.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/utils/date_utils.py new file mode 100644 index 000000000..a9622df96 --- /dev/null +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/utils/date_utils.py @@ -0,0 +1,49 @@ +from datetime import date, datetime +from typing import Callable, Dict, Iterator, Union + +import pandas as pd +from forestadmin.datasource_toolkit.interfaces.query.aggregation import DateOperation + +DATE_OPERATION_STR_FORMAT_FN: Dict[DateOperation, Callable[[Union[date, datetime]], str]] = { + DateOperation.DAY: lambda d: d.strftime("%d/%m/%Y"), + DateOperation.WEEK: lambda d: d.strftime("W%V-%G"), + DateOperation.MONTH: lambda d: d.strftime("%b %Y"), + DateOperation.YEAR: lambda d: d.strftime("%Y"), + DateOperation.QUARTER: lambda d: f"Q{pd.Timestamp(d).quarter}-{d.year}", +} + +_DATE_OPERATION_OFFSET: Dict[DateOperation, pd.DateOffset] = { + DateOperation.YEAR: pd.DateOffset(years=1), + DateOperation.QUARTER: pd.DateOffset(months=3), + DateOperation.MONTH: pd.DateOffset(months=1), + DateOperation.WEEK: pd.DateOffset(weeks=1), + DateOperation.DAY: pd.DateOffset(days=1), +} + + +def parse_date(date_input: Union[str, date, datetime]) -> date: + if isinstance(date_input, str): + return datetime.fromisoformat(date_input).date() + elif isinstance(date_input, datetime): + return date_input.date() + elif isinstance(date_input, date): + return date_input + + +def make_formatted_date_range( + first: Union[date, datetime], + last: Union[date, datetime], + date_operation: DateOperation, +) -> Iterator[str]: + current = first + used = set() + format_fn = DATE_OPERATION_STR_FORMAT_FN[date_operation] + + while current <= last: + formatted = format_fn(current) + yield formatted + used.add(formatted) + current = (current + _DATE_OPERATION_OFFSET[date_operation]).date() + + if format_fn(last) not in used: + yield format_fn(last) From d8f2d132fcae01a93873ee2798bd4c345415aa24 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 19 Feb 2025 11:55:18 +0100 Subject: [PATCH 11/15] chore: pandas not required in agent toolkit anymore --- src/agent_toolkit/pyproject.toml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/agent_toolkit/pyproject.toml b/src/agent_toolkit/pyproject.toml index 2c38eb7e3..5134d8dbd 100644 --- a/src/agent_toolkit/pyproject.toml +++ b/src/agent_toolkit/pyproject.toml @@ -23,21 +23,7 @@ pyjwt = "^2" cachetools = "~=5.2" sseclient-py = "^1.5" forestadmin-datasource-toolkit = "1.22.11" -[[tool.poetry.dependencies.pandas]] -version = ">=1.4.0" -python = "<3.13.0" -[[tool.poetry.dependencies.pandas]] -version = ">=2.2.3" -python = ">=3.13.0" - -[[tool.poetry.dependencies.numpy]] -python = ">=3.8.0,<3.12" -version = ">=1.24.0" - -[[tool.poetry.dependencies.numpy]] -python = ">=3.13" -version = ">=1.3.0" [tool.poetry.dependencies."backports.zoneinfo"] version = "~0.2.1" From 1bf7cc5bb5002dca58fffc81a0061da81784a7eb Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 5 Mar 2025 16:37:16 +0100 Subject: [PATCH 12/15] chore: add a forgotten test on weeks --- .../tests/test_sqlalchemy_collections.py | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py b/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py index 657b62fcc..fe7993300 100644 --- a/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py +++ b/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py @@ -530,7 +530,41 @@ def test_aggregate_by_date_month(self): self.assertIn({"value": 4153.0, "group": {"created_at": "2021-03-01"}}, results) self.assertIn({"value": 254.0, "group": {"created_at": "2021-05-01"}}, results) - # TODO: test week + def test_aggregate_by_date_week(self): + filter_ = PaginatedFilter( + { + "condition_tree": ConditionTreeBranch( + "and", + [ + ConditionTreeLeaf("id", Operator.LESS_THAN, 11), + ConditionTreeLeaf("id", Operator.GREATER_THAN, 4), + ], + ) + } + ) + collection = self.datasource.get_collection("order") + + results = self.loop.run_until_complete( + collection.aggregate( + self.mocked_caller, + filter_, + Aggregation( + { + "operation": "Avg", + "field": "amount", + "groups": [{"field": "created_at", "operation": "Week"}], + } + ), + ) + ) + + self.assertEqual(len(results), 6) + self.assertIn({"value": 9744.0, "group": {"created_at": "2021-06-28"}}, results) + self.assertIn({"value": 9526.0, "group": {"created_at": "2023-02-20"}}, results) + self.assertIn({"value": 7676.0, "group": {"created_at": "2022-08-01"}}, results) + self.assertIn({"value": 5354.0, "group": {"created_at": "2021-01-11"}}, results) + self.assertIn({"value": 4153.0, "group": {"created_at": "2021-03-08"}}, results) + self.assertIn({"value": 254.0, "group": {"created_at": "2021-05-24"}}, results) def test_aggregate_by_date_day(self): filter_ = PaginatedFilter( From 95d7bc0d1cd491d30480cb25bbd948ba3daa8a11 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 5 Mar 2025 16:42:15 +0100 Subject: [PATCH 13/15] chore: fix linting --- src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py b/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py index fe7993300..b7040108c 100644 --- a/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py +++ b/src/datasource_sqlalchemy/tests/test_sqlalchemy_collections.py @@ -5,8 +5,6 @@ from unittest import TestCase from unittest.mock import Mock, patch -from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.branch import ConditionTreeBranch - if sys.version_info >= (3, 9): import zoneinfo else: From 7898161fbc98f2428d71e426771dea21592a65b4 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 5 Mar 2025 16:50:11 +0100 Subject: [PATCH 14/15] chore: fix formatting --- .../agent_toolkit/utils/forest_schema/action_fields.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/utils/forest_schema/action_fields.py b/src/agent_toolkit/forestadmin/agent_toolkit/utils/forest_schema/action_fields.py index bef60b9d6..cb99eced3 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/utils/forest_schema/action_fields.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/utils/forest_schema/action_fields.py @@ -119,7 +119,9 @@ def is_checkbox_group_field( return field is not None and field.get("widget", "") == "CheckboxGroup" @staticmethod - def is_dropdown_field(field: ActionField) -> TypeGuard[ + def is_dropdown_field( + field: ActionField, + ) -> TypeGuard[ Union[ DropdownDynamicSearchFieldConfiguration[str], DropdownDynamicSearchFieldConfiguration[int], @@ -129,7 +131,9 @@ def is_dropdown_field(field: ActionField) -> TypeGuard[ return field is not None and field.get("widget", "") == "Dropdown" @staticmethod - def is_user_dropdown_field(field: ActionField) -> TypeGuard[ + def is_user_dropdown_field( + field: ActionField, + ) -> TypeGuard[ Union[ PlainStringListDynamicFieldUserDropdownFieldConfiguration, PlainStringDynamicFieldUserDropdownFieldConfiguration, From f5c960052f11792c93160ca19570338311ddd10c Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 5 Mar 2025 17:48:00 +0100 Subject: [PATCH 15/15] chore: fix formatting --- .../forestadmin/agent_toolkit/utils/forest_schema/filterable.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/utils/forest_schema/filterable.py b/src/agent_toolkit/forestadmin/agent_toolkit/utils/forest_schema/filterable.py index 5cf10fd37..76545f343 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/utils/forest_schema/filterable.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/utils/forest_schema/filterable.py @@ -4,7 +4,6 @@ class FrontendFilterableUtils: - @classmethod def is_filterable(cls, operators: Set[Operator]) -> bool: return operators is not None and len(operators) > 0