diff --git a/setup.py b/setup.py index 83fa1444df..78be764284 100644 --- a/setup.py +++ b/setup.py @@ -129,6 +129,7 @@ setup( "elasticsearch": ["elasticsearch-dbapi>=0.2.0, <0.3.0"], "exasol": ["sqlalchemy-exasol>=2.1.0, <2.2"], "excel": ["xlrd>=1.2.0, <1.3"], + "firebird": ["sqlalchemy-firebird>=0.7.0, <0.8"], "gsheets": ["shillelagh>=0.2, <0.3"], "hana": ["hdbcli==2.4.162", "sqlalchemy_hana==0.4.0"], "hive": ["pyhive[hive]>=0.6.1", "tableschema", "thrift>=0.11.0, <1.0.0"], diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index c21b16f25c..8db57e96cc 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -456,7 +456,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods ) return database.compile_sqla_query(qry) - if LimitMethod.FORCE_LIMIT: + if cls.limit_method == LimitMethod.FORCE_LIMIT: parsed_query = sql_parse.ParsedQuery(sql) sql = parsed_query.set_or_update_query_limit(limit) diff --git a/superset/db_engine_specs/firebird.py b/superset/db_engine_specs/firebird.py new file mode 100644 index 0000000000..72b462ab45 --- /dev/null +++ b/superset/db_engine_specs/firebird.py @@ -0,0 +1,83 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from datetime import datetime +from typing import Optional + +from superset.db_engine_specs.base import BaseEngineSpec, LimitMethod +from superset.utils import core as utils + + +class FirebirdEngineSpec(BaseEngineSpec): + """Engine for Firebird""" + + engine = "firebird" + engine_name = "Firebird" + + # Firebird uses FIRST to limit: `SELECT FIRST 10 * FROM table` + limit_method = LimitMethod.FETCH_MANY + + _time_grain_expressions = { + None: "{col}", + "PT1S": ( + "CAST(CAST({col} AS DATE) " + "|| ' ' " + "|| EXTRACT(HOUR FROM {col}) " + "|| ':' " + "|| EXTRACT(MINUTE FROM {col}) " + "|| ':' " + "|| FLOOR(EXTRACT(SECOND FROM {col})) AS TIMESTAMP)" + ), + "PT1M": ( + "CAST(CAST({col} AS DATE) " + "|| ' ' " + "|| EXTRACT(HOUR FROM {col}) " + "|| ':' " + "|| EXTRACT(MINUTE FROM {col}) " + "|| ':00' AS TIMESTAMP)" + ), + "PT1H": ( + "CAST(CAST({col} AS DATE) " + "|| ' ' " + "|| EXTRACT(HOUR FROM {col}) " + "|| ':00:00' AS TIMESTAMP)" + ), + "P1D": "CAST({col} AS DATE)", + "P1M": ( + "CAST(EXTRACT(YEAR FROM {col}) " + "|| '-' " + "|| EXTRACT(MONTH FROM {col}) " + "|| '-01' AS DATE)" + ), + "P1Y": "CAST(EXTRACT(YEAR FROM {col}) || '-01-01' AS DATE)", + } + + @classmethod + def epoch_to_dttm(cls) -> str: + return "DATEADD(second, {col}, CAST('00:00:00' AS TIMESTAMP))" + + @classmethod + def convert_dttm(cls, target_type: str, dttm: datetime) -> Optional[str]: + tt = target_type.upper() + if tt == utils.TemporalType.TIMESTAMP: + dttm_formatted = dttm.isoformat(sep=" ") + dttm_valid_precision = dttm_formatted[: len("YYYY-MM-DD HH:MM:SS.MMMM")] + return f"CAST('{dttm_valid_precision}' AS TIMESTAMP)" + if tt == utils.TemporalType.DATE: + return f"CAST('{dttm.date().isoformat()}' AS DATE)" + if tt == utils.TemporalType.TIME: + return f"CAST('{dttm.time().isoformat()}' AS TIME)" + return None diff --git a/tests/db_engine_specs/firebird_tests.py b/tests/db_engine_specs/firebird_tests.py new file mode 100644 index 0000000000..5e00e2ed4d --- /dev/null +++ b/tests/db_engine_specs/firebird_tests.py @@ -0,0 +1,81 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from datetime import datetime +from unittest import mock + +import pytest + +from superset.db_engine_specs.firebird import FirebirdEngineSpec + +grain_expressions = { + None: "timestamp_column", + "PT1S": ( + "CAST(CAST(timestamp_column AS DATE) " + "|| ' ' " + "|| EXTRACT(HOUR FROM timestamp_column) " + "|| ':' " + "|| EXTRACT(MINUTE FROM timestamp_column) " + "|| ':' " + "|| FLOOR(EXTRACT(SECOND FROM timestamp_column)) AS TIMESTAMP)" + ), + "PT1M": ( + "CAST(CAST(timestamp_column AS DATE) " + "|| ' ' " + "|| EXTRACT(HOUR FROM timestamp_column) " + "|| ':' " + "|| EXTRACT(MINUTE FROM timestamp_column) " + "|| ':00' AS TIMESTAMP)" + ), + "P1D": "CAST(timestamp_column AS DATE)", + "P1M": ( + "CAST(EXTRACT(YEAR FROM timestamp_column) " + "|| '-' " + "|| EXTRACT(MONTH FROM timestamp_column) " + "|| '-01' AS DATE)" + ), + "P1Y": "CAST(EXTRACT(YEAR FROM timestamp_column) || '-01-01' AS DATE)", +} + + +@pytest.mark.parametrize("grain,expected", grain_expressions.items()) +def test_time_grain_expressions(grain, expected): + assert ( + FirebirdEngineSpec._time_grain_expressions[grain].format(col="timestamp_column") + == expected + ) + + +def test_epoch_to_dttm(): + assert ( + FirebirdEngineSpec.epoch_to_dttm().format(col="timestamp_column") + == "DATEADD(second, timestamp_column, CAST('00:00:00' AS TIMESTAMP))" + ) + + +def test_convert_dttm(): + dttm = datetime(2021, 1, 1) + assert ( + FirebirdEngineSpec.convert_dttm("timestamp", dttm) + == "CAST('2021-01-01 00:00:00' AS TIMESTAMP)" + ) + assert ( + FirebirdEngineSpec.convert_dttm("TIMESTAMP", dttm) + == "CAST('2021-01-01 00:00:00' AS TIMESTAMP)" + ) + assert FirebirdEngineSpec.convert_dttm("TIME", dttm) == "CAST('00:00:00' AS TIME)" + assert FirebirdEngineSpec.convert_dttm("DATE", dttm) == "CAST('2021-01-01' AS DATE)" + assert FirebirdEngineSpec.convert_dttm("STRING", dttm) is None