This is an automated email from the ASF dual-hosted git repository.

aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 7a2913e2dbe fix(sqlite): write midnight as a bare date for DATE 
columns in time filters (#44805)
7a2913e2dbe is described below

commit 7a2913e2dbe892805f67138696ee5561d67ab7c5
Author: Daniel Alyoshin <[email protected]>
AuthorDate: Sun Oct 4 19:51:11 2026 -0400

    fix(sqlite): write midnight as a bare date for DATE columns in time filters 
(#44805)
    
    Co-authored-by: Amin Ghadersohi 
<[email protected]>
---
 UPDATING.md                                        | 12 +++
 superset/db_engine_specs/d1.py                     | 20 -----
 superset/db_engine_specs/gsheets.py                | 23 +-----
 superset/db_engine_specs/shillelagh.py             | 20 ++++-
 superset/db_engine_specs/sqlite.py                 | 16 +++-
 tests/unit_tests/db_engine_specs/test_d1.py        | 48 +-----------
 .../unit_tests/db_engine_specs/test_shillelagh.py  | 13 ++++
 tests/unit_tests/db_engine_specs/test_sqlite.py    | 83 ++++++++++++++++++++
 tests/unit_tests/extensions/test_sqlalchemy.py     | 89 ++++++++++++++++++++++
 9 files changed, 231 insertions(+), 93 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 9f2e5bd0cd7..50ed73e9f8c 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -44,6 +44,18 @@ assists people when migrating to a new version.
   loader rejects semantic bundles;
   use the chart, dashboard or assets importer instead.
 
+### SQLite time filters on `DATE` columns
+
+On SQLite, Shillelagh and the Superset meta database, a time filter on a `DATE`
+column writes its bounds as a date, such as `'2026-09-20'`, and no longer uses
+the column's **Datetime format** (`python_date_format`) or the database's
+`python_date_format_by_column_name`. This fixes ranges that started and ended
+one day late on `DATE` columns holding `YYYY-MM-DD` text. A `DATE` column that
+holds values in another format, such as `20260920`, `09/20/2026` or epoch
+seconds, now matches no rows, even with a Datetime format set. Columns declared
+as `INTEGER` are not affected. On Shillelagh and the meta database, a bound
+with a time of day is cut to its date.
+
 ### Apache Doris connection form and `DBS_AVAILABLE_DENYLIST`
 
 `DBS_AVAILABLE_DENYLIST` is matched against an engine spec's `default_driver`.
diff --git a/superset/db_engine_specs/d1.py b/superset/db_engine_specs/d1.py
index 056d7942d7b..45bb47f7fd4 100644
--- a/superset/db_engine_specs/d1.py
+++ b/superset/db_engine_specs/d1.py
@@ -18,12 +18,10 @@
 from __future__ import annotations
 
 import re
-from datetime import datetime, time
 from re import Pattern
 from typing import Any, TYPE_CHECKING
 
 from flask_babel import lazy_gettext as _
-from sqlalchemy import types
 from sqlalchemy.engine.url import URL
 
 from superset.db_engine_specs.base import DatabaseCategory
@@ -131,24 +129,6 @@ class CloudflareD1EngineSpec(SqliteEngineSpec):
             raise ValueError("Forbidden query parameter(s): {'base_url'}")
         super().validate_database_uri(sqlalchemy_uri)
 
-    @classmethod
-    def convert_dttm(
-        cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None 
= None
-    ) -> str | None:
-        """
-        Write midnight as a bare date for DATE columns.
-
-        D1 keeps a DATE as text such as ``2026-09-20`` and compares it as 
text, so
-        ``'2026-09-20 00:00:00'`` sorts after the day it starts and a time 
filter
-        on a DATE column would be one day off. Any other time keeps its time 
part,
-        which sorts between two days, as a comparison of dates should.
-        """
-        if isinstance(cls.get_sqla_column_type(target_type), types.Date):
-            if dttm.time() == time.min:
-                return f"'{dttm.date().isoformat()}'"
-            return f"""'{dttm.isoformat(sep=" ", timespec="seconds")}'"""
-        return super().convert_dttm(target_type, dttm, db_extra=db_extra)
-
     @classmethod
     def _extract_error_message(cls, ex: Exception) -> str:
         """
diff --git a/superset/db_engine_specs/gsheets.py 
b/superset/db_engine_specs/gsheets.py
index 2eb210b46ce..ec335b74afe 100644
--- a/superset/db_engine_specs/gsheets.py
+++ b/superset/db_engine_specs/gsheets.py
@@ -35,7 +35,7 @@ from marshmallow.exceptions import ValidationError
 from requests import Session
 from shillelagh.adapters.api.gsheets.lib import SCOPES
 from shillelagh.exceptions import UnauthenticatedError
-from sqlalchemy import text, types
+from sqlalchemy import text
 from sqlalchemy.engine import create_engine
 from sqlalchemy.engine.reflection import Inspector
 from sqlalchemy.engine.url import URL
@@ -214,27 +214,6 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
     oauth2_token_request_uri = "https://oauth2.googleapis.com/token";  # noqa: 
S105
     oauth2_exception = (UnauthenticatedError, OAuth2TokenRefreshError)
 
-    @classmethod
-    def convert_dttm(
-        cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None 
= None
-    ) -> str | None:
-        """
-        Convert a datetime to a SQL literal understood by shillelagh's GSheets
-        adapter.
-
-        ``SqliteEngineSpec.convert_dttm`` (inherited via 
``ShillelaghEngineSpec``)
-        has no case for ``types.Date`` and returns ``None``, which makes 
Superset
-        fall back to a literal that still carries a time-of-day component. The
-        GSheets adapter's virtual table layer parses that literal with
-        ``datetime.date.fromisoformat``, which rejects the trailing time and
-        silently drops the filter value, producing an invalid query against the
-        Google Sheets API. A bare ``YYYY-MM-DD`` literal is required instead.
-        """
-        sqla_type = cls.get_sqla_column_type(target_type)
-        if isinstance(sqla_type, types.Date):
-            return f"'{dttm.date().isoformat()}'"
-        return super().convert_dttm(target_type, dttm, db_extra=db_extra)
-
     @classmethod
     def get_oauth2_authorization_uri(
         cls,
diff --git a/superset/db_engine_specs/shillelagh.py 
b/superset/db_engine_specs/shillelagh.py
index d0fbc79cec0..6eacae77b38 100644
--- a/superset/db_engine_specs/shillelagh.py
+++ b/superset/db_engine_specs/shillelagh.py
@@ -16,9 +16,10 @@
 # under the License.
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from datetime import datetime
+from typing import Any, TYPE_CHECKING
 
-from sqlalchemy import event
+from sqlalchemy import event, types
 
 from superset.db_engine_specs.base import DatabaseCategory
 from superset.db_engine_specs.sqlite import SqliteEngineSpec
@@ -59,6 +60,21 @@ class ShillelaghEngineSpec(SqliteEngineSpec):
         ),
     }
 
+    @classmethod
+    def convert_dttm(
+        cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None 
= None
+    ) -> str | None:
+        """
+        Write a bare date for DATE columns.
+
+        Shillelagh reads a date filter with ``date.fromisoformat``, which 
rejects a
+        time part and drops the filter without an error, so every row would 
come
+        back. A time other than midnight is cut to its date.
+        """
+        if isinstance(cls.get_sqla_column_type(target_type), types.Date):
+            return f"'{dttm.date().isoformat()}'"
+        return super().convert_dttm(target_type, dttm, db_extra=db_extra)
+
     @classmethod
     def get_function_names(
         cls,
diff --git a/superset/db_engine_specs/sqlite.py 
b/superset/db_engine_specs/sqlite.py
index 3955c079556..18a3a01390f 100644
--- a/superset/db_engine_specs/sqlite.py
+++ b/superset/db_engine_specs/sqlite.py
@@ -18,7 +18,7 @@
 from __future__ import annotations
 
 import re
-from datetime import datetime
+from datetime import datetime, time
 from re import Pattern
 from typing import Any, TYPE_CHECKING
 
@@ -164,8 +164,20 @@ class SqliteEngineSpec(BaseEngineSpec):
     def convert_dttm(
         cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None 
= None
     ) -> str | None:
+        """
+        Write midnight as a bare date for DATE columns.
+
+        SQLite has no date type, so a DATE column usually holds text such as
+        ``2026-09-20``. ``'2026-09-20 00:00:00'`` sorts after that text, so a 
time
+        filter on a DATE column would be one day off. Any other time keeps its 
time
+        part, which sorts between two days, as a comparison of dates should. 
Values
+        that look like numbers, such as ``20260920`` or epoch seconds, are 
stored as
+        numbers, and SQLite sorts every number before any text.
+        """
         sqla_type = cls.get_sqla_column_type(target_type)
-        if isinstance(sqla_type, (types.String, types.DateTime)):
+        if isinstance(sqla_type, types.Date) and dttm.time() == time.min:
+            return f"'{dttm.date().isoformat()}'"
+        if isinstance(sqla_type, (types.String, types.Date, types.DateTime)):
             return f"""'{dttm.isoformat(sep=" ", timespec="seconds")}'"""
         return None
 
diff --git a/tests/unit_tests/db_engine_specs/test_d1.py 
b/tests/unit_tests/db_engine_specs/test_d1.py
index 220fb84e498..4c54e900366 100644
--- a/tests/unit_tests/db_engine_specs/test_d1.py
+++ b/tests/unit_tests/db_engine_specs/test_d1.py
@@ -21,7 +21,7 @@ from datetime import datetime
 
 import pytest
 from pytest_mock import MockerFixture
-from sqlalchemy import create_engine, text, types
+from sqlalchemy import types
 from sqlalchemy.engine.url import make_url
 
 from superset.errors import SupersetErrorType
@@ -289,49 +289,3 @@ def test_convert_dttm(
     from superset.db_engine_specs.d1 import CloudflareD1EngineSpec as spec  # 
noqa: N813
 
     assert_convert_dttm(spec, target_type, expected_result, dttm)
-
-
[email protected](
-    "start,end,expected",
-    [
-        (
-            datetime(2026, 9, 20),
-            datetime(2026, 9, 22),
-            ["2026-09-20", "2026-09-21"],
-        ),
-        (
-            datetime(2026, 9, 20, 12),
-            datetime(2026, 9, 22, 12),
-            ["2026-09-21", "2026-09-22"],
-        ),
-    ],
-)
-def test_time_filter_on_date_column(
-    start: datetime,
-    end: datetime,
-    expected: list[str],
-) -> None:
-    """
-    Test that a time filter on a DATE column stored as text returns the right 
days.
-
-    D1 is SQLite, so the comparison runs on an in-memory SQLite database. Each 
day
-    counts as its midnight, as it would in a comparison of dates.
-    """
-    from superset.db_engine_specs.d1 import CloudflareD1EngineSpec as spec  # 
noqa: N813
-
-    since = spec.convert_dttm("DATE", start)
-    until = spec.convert_dttm("DATE", end)
-    sql = f"SELECT day FROM t WHERE day >= {since} AND day < {until} ORDER BY 
day"  # noqa: S608
-
-    engine = create_engine("sqlite://")
-    with engine.connect() as connection:
-        connection.execute(text("CREATE TABLE t (day DATE)"))
-        connection.execute(
-            text(
-                "INSERT INTO t VALUES "
-                "('2026-09-19'), ('2026-09-20'), ('2026-09-21'), 
('2026-09-22')"
-            )
-        )
-        rows = connection.execute(text(sql)).fetchall()
-
-    assert [row[0] for row in rows] == expected
diff --git a/tests/unit_tests/db_engine_specs/test_shillelagh.py 
b/tests/unit_tests/db_engine_specs/test_shillelagh.py
index e66014f5c24..8a7def2ed06 100644
--- a/tests/unit_tests/db_engine_specs/test_shillelagh.py
+++ b/tests/unit_tests/db_engine_specs/test_shillelagh.py
@@ -17,6 +17,7 @@
 
 import sqlite3
 from contextlib import closing
+from datetime import datetime
 
 import pytest
 from sqlalchemy import create_engine, text
@@ -98,3 +99,15 @@ def test_non_apsw_backend_is_left_alone() -> None:
 
     with engine.connect() as connection:
         assert connection.execute(text("SELECT 1")).scalar() == 1
+
+
[email protected]("spec", [ShillelaghEngineSpec, SupersetEngineSpec])
+def test_convert_dttm_date(spec: type[ShillelaghEngineSpec]) -> None:
+    """
+    A DATE column gets a bare date, which shillelagh can read as a date.
+    """
+    assert spec.convert_dttm("DATE", datetime(2026, 9, 20)) == "'2026-09-20'"
+    assert spec.convert_dttm("DATE", datetime(2026, 9, 20, 12)) == 
"'2026-09-20'"
+    assert (
+        spec.convert_dttm("DATETIME", datetime(2026, 9, 20)) == "'2026-09-20 
00:00:00'"
+    )
diff --git a/tests/unit_tests/db_engine_specs/test_sqlite.py 
b/tests/unit_tests/db_engine_specs/test_sqlite.py
index 0efb6fdcbc8..9c92f9aa672 100644
--- a/tests/unit_tests/db_engine_specs/test_sqlite.py
+++ b/tests/unit_tests/db_engine_specs/test_sqlite.py
@@ -33,6 +33,7 @@ from tests.unit_tests.fixtures.common import dttm  # noqa: 
F401
         ("Text", "'2019-01-02 03:04:05'"),
         ("DateTime", "'2019-01-02 03:04:05'"),
         ("TimeStamp", "'2019-01-02 03:04:05'"),
+        ("Date", "'2019-01-02 03:04:05'"),
         ("Other", None),
     ],
 )
@@ -46,6 +47,88 @@ def test_convert_dttm(
     assert_convert_dttm(spec, target_type, expected_result, dttm)
 
 
[email protected](
+    "target_type,expected_result",
+    [
+        ("Date", "'2019-01-02'"),
+        ("Text", "'2019-01-02 00:00:00'"),
+        ("DateTime", "'2019-01-02 00:00:00'"),
+        ("TimeStamp", "'2019-01-02 00:00:00'"),
+        ("Other", None),
+    ],
+)
+def test_convert_dttm_midnight(
+    target_type: str,
+    expected_result: Optional[str],
+) -> None:
+    """
+    Test that midnight is written as a bare date for DATE columns only.
+    """
+    from superset.db_engine_specs.sqlite import SqliteEngineSpec as spec  # 
noqa: N813
+
+    assert_convert_dttm(spec, target_type, expected_result, datetime(2019, 1, 
2))
+
+
[email protected]("sqlalchemy_uri", ["sqlite://", 
"d1://account:token@db"])
[email protected]("time_grain", [None, TimeGrain.DAY])
[email protected](
+    "start,end,expected",
+    [
+        (
+            datetime(2026, 9, 20),
+            datetime(2026, 9, 22),
+            ["2026-09-20", "2026-09-21"],
+        ),
+        (
+            datetime(2026, 9, 20, 12),
+            datetime(2026, 9, 22, 12),
+            ["2026-09-21", "2026-09-22"],
+        ),
+    ],
+)
+def test_time_filter_on_date_column(
+    app_context: None,
+    sqlalchemy_uri: str,
+    time_grain: Optional[str],
+    start: datetime,
+    end: datetime,
+    expected: list[str],
+) -> None:
+    """
+    Test that a time filter on a DATE column stored as text returns the right 
days.
+
+    The filter comes from ``get_time_filter``, as in a chart query. D1 is 
SQLite,
+    so both run on an in-memory SQLite database. Each day counts as its 
midnight,
+    as it would in a comparison of dates.
+    """
+    from superset.connectors.sqla.models import SqlaTable, TableColumn
+    from superset.models.core import Database
+
+    column = TableColumn(column_name="day", type="DATE", is_dttm=True)
+    table = SqlaTable(
+        table_name="t",
+        columns=[column],
+        database=Database(database_name="db", sqlalchemy_uri=sqlalchemy_uri),
+    )
+    time_filter = table.get_time_filter(column, start, end, 
time_grain=time_grain)
+    assert time_filter is not None
+
+    engine = create_engine("sqlite://")
+    where = time_filter.compile(engine, compile_kwargs={"literal_binds": True})
+    sql = f"SELECT day FROM t WHERE {where} ORDER BY day"  # noqa: S608
+    with engine.connect() as connection:
+        connection.execute(text("CREATE TABLE t (day DATE)"))
+        connection.execute(
+            text(
+                "INSERT INTO t VALUES "
+                "('2026-09-19'), ('2026-09-20'), ('2026-09-21'), 
('2026-09-22')"
+            )
+        )
+        rows = connection.execute(text(sql)).fetchall()
+
+    assert [row[0] for row in rows] == expected
+
+
 @pytest.mark.parametrize(
     "dttm,grain,expected",
     [
diff --git a/tests/unit_tests/extensions/test_sqlalchemy.py 
b/tests/unit_tests/extensions/test_sqlalchemy.py
index 998534a015b..fdb41775c06 100644
--- a/tests/unit_tests/extensions/test_sqlalchemy.py
+++ b/tests/unit_tests/extensions/test_sqlalchemy.py
@@ -19,6 +19,7 @@
 import os
 import re
 from collections.abc import Iterator
+from datetime import date, datetime
 from typing import TYPE_CHECKING
 
 import pytest
@@ -796,3 +797,91 @@ def test_allowed_dbs(mocker: MockerFixture, app_context: 
None, table1: None) ->
 (Background on this error at: https://sqlalche.me/e/XX/f405)
         """.strip()
     )
+
+
[email protected]
+def table_dates(session: Session, database1: "Database") -> Iterator[None]:
+    """
+    A table with a DATE column holding 2026-09-19 to 2026-09-22.
+    """
+    with database1.get_sqla_engine() as engine:
+        with engine.begin() as conn:
+            conn.execute(text("CREATE TABLE table_dates (day DATE)"))
+            conn.execute(
+                text(
+                    "INSERT INTO table_dates (day) VALUES "
+                    "('2026-09-19'), ('2026-09-20'), ('2026-09-21'), 
('2026-09-22')"
+                )
+            )
+        db.session.commit()
+
+        yield
+
+        with engine.begin() as conn:
+            conn.execute(text("DROP TABLE table_dates"))
+        db.session.commit()
+
+
+@with_feature_flags(ENABLE_SUPERSET_META_DB=True)
[email protected](
+    "start,end",
+    [
+        (datetime(2026, 9, 20), datetime(2026, 9, 22)),
+        (datetime(2026, 9, 20, 12), datetime(2026, 9, 22, 12)),
+    ],
+)
+def test_time_filter_on_date_column(
+    mocker: MockerFixture,
+    app_context: None,
+    table_dates: None,
+    start: datetime,
+    end: datetime,
+) -> None:
+    """
+    Test that a time filter on a DATE column is applied.
+
+    Shillelagh reads a date filter with ``date.fromisoformat``, so a bound 
with a
+    time part would be dropped and every row would come back. The filter comes
+    from ``get_time_filter``, as in a chart query.
+    """
+    from superset.connectors.sqla.models import SqlaTable, TableColumn
+    from superset.models.core import Database
+
+    # Mock the security_manager.raise_for_access to allow access
+    mocker.patch(
+        "superset.extensions.metadb.security_manager.raise_for_access",
+        return_value=None,
+    )
+
+    # Mock Flask g.user for security checks
+    # In Python 3.8+, we can't directly patch flask.g
+    # Instead, we need to ensure g.user exists in the context
+    from flask import g
+
+    g.user = mocker.MagicMock()
+    g.user.is_anonymous = False
+
+    try:
+        engine = create_engine("superset://")
+    except Exception as e:
+        # Skip test if superset:// dialect can't be loaded (common in Docker)
+        pytest.skip(f"Superset dialect not available: {e}")
+
+    column = TableColumn(column_name="day", type="DATE", is_dttm=True)
+    table = SqlaTable(
+        table_name="database1.table_dates",
+        columns=[column],
+        database=Database(database_name="meta", sqlalchemy_uri="superset://"),
+    )
+    time_filter = table.get_time_filter(column, start, end)
+    assert time_filter is not None
+    where = time_filter.compile(engine, compile_kwargs={"literal_binds": True})
+
+    with engine.connect() as conn:
+        results = conn.execute(
+            text(
+                'SELECT day FROM "database1.table_dates" '  # noqa: S608
+                f"WHERE {where} ORDER BY day"
+            )
+        )
+        assert list(results) == [(date(2026, 9, 20),), (date(2026, 9, 21),)]

Reply via email to