From b55bb4e3ce612cd60c126f7873c6692bc6fded8e Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:49:58 -0300 Subject: [PATCH] fix(query-history): enable sorting by Duration column (#39637) Co-authored-by: Claude Sonnet 4.6 (cherry picked from commit c4a8b34b11f0ef4216282ab52aa35c02072818a8) --- superset/models/sql_lab.py | 15 ++++ superset/queries/api.py | 1 + superset/queries/schemas.py | 1 + tests/integration_tests/queries/api_tests.py | 80 ++++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index 7d303ffa99a..382b272af42 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -44,6 +44,7 @@ from sqlalchemy import ( Text, ) from sqlalchemy.engine.url import URL +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import backref, relationship from sqlalchemy.sql.elements import ColumnElement, literal_column from superset_core.queries.models import ( @@ -159,6 +160,20 @@ class Query( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=True ) + @hybrid_property + def duration(self) -> Optional[float]: + start = self.start_running_time or self.start_time + if self.end_time is not None and start is not None: + return float(self.end_time - start) + return None + + @duration.expression # type: ignore[no-redef] + def duration(cls) -> ColumnElement: # noqa: N805 + return sqla.func.coalesce( + cls.end_time - sqla.func.coalesce(cls.start_running_time, cls.start_time), + 0, + ) + database = relationship( "Database", foreign_keys=[database_id], diff --git a/superset/queries/api.py b/superset/queries/api.py index 3b4b1e632f1..a1e9627738b 100644 --- a/superset/queries/api.py +++ b/superset/queries/api.py @@ -136,6 +136,7 @@ class QueryRestApi(BaseSupersetModelRestApi): order_columns = [ "changed_on", "database.database_name", + "duration", "rows", "schema", "start_time", diff --git a/superset/queries/schemas.py b/superset/queries/schemas.py index b66d0593667..589dc271910 100644 --- a/superset/queries/schemas.py +++ b/superset/queries/schemas.py @@ -60,6 +60,7 @@ class QuerySchema(Schema): schema = fields.String() sql = fields.String() sql_tables = fields.Method("get_sql_tables") + start_running_time = fields.Float(attribute="start_running_time") start_time = fields.Float(attribute="start_time") status = fields.String() tab_name = fields.String() diff --git a/tests/integration_tests/queries/api_tests.py b/tests/integration_tests/queries/api_tests.py index db7b6b825e1..a4ed1ddc697 100644 --- a/tests/integration_tests/queries/api_tests.py +++ b/tests/integration_tests/queries/api_tests.py @@ -54,6 +54,9 @@ class TestQueryApi(SupersetTestCase): tab_name: str = "", status: str = "success", changed_on: datetime = datetime(2020, 1, 1), + start_time: float | None = None, + start_running_time: float | None = None, + end_time: float | None = None, ) -> Query: database = db.session.query(Database).get(database_id) user = db.session.query(security_manager.user_model).get(user_id) @@ -70,6 +73,9 @@ class TestQueryApi(SupersetTestCase): tab_name=tab_name, status=status, changed_on=changed_on, + start_time=start_time, + start_running_time=start_running_time, + end_time=end_time, ) db.session.add(query) db.session.commit() @@ -275,6 +281,7 @@ class TestQueryApi(SupersetTestCase): "schema", "sql", "sql_tables", + "start_running_time", "start_time", "status", "tab_name", @@ -361,6 +368,7 @@ class TestQueryApi(SupersetTestCase): order_columns = [ "changed_on", "database.database_name", + "duration", "rows", "schema", "sql", @@ -374,6 +382,78 @@ class TestQueryApi(SupersetTestCase): rv = self.client.get(uri) assert rv.status_code == 200 + def test_get_list_query_order_duration(self): + """ + Query API: Test that sorting by duration orders by end_time - start_time, + falling back to start_time when start_running_time is absent, and treating + NULL durations (no end_time) as zero. + """ + admin = self.get_user("admin") + example_db = get_example_database() + base_time = 1_000_000.0 + + # duration = 0.031 (uses start_running_time as start) + q_long = self.insert_query( + example_db.id, + admin.id, + self.get_random_string(), + start_time=base_time, + start_running_time=base_time + 0.005, + end_time=base_time + 0.036, + ) + # duration = 0.021 + q_medium = self.insert_query( + example_db.id, + admin.id, + self.get_random_string(), + start_time=base_time, + start_running_time=None, + end_time=base_time + 0.021, + ) + # duration = 0 (no end_time, NULL treated as 0) + q_null = self.insert_query( + example_db.id, + admin.id, + self.get_random_string(), + start_time=base_time, + start_running_time=None, + end_time=None, + ) + + # Use a unique sql_editor_id to isolate these test queries + test_editor_id = self.get_random_string() + q_long.sql_editor_id = test_editor_id + q_medium.sql_editor_id = test_editor_id + q_null.sql_editor_id = test_editor_id + db.session.commit() + + self.login(ADMIN_USERNAME) + arguments = { + "order_column": "duration", + "order_direction": "asc", + "filters": [{"col": "sql_editor_id", "opr": "eq", "value": test_editor_id}], + } + uri = f"api/v1/query/?q={rison.dumps(arguments)}" + rv = self.client.get(uri) + assert rv.status_code == 200 + data = rv.get_json() + ids = [r["id"] for r in data["result"]] + assert ids == [q_null.id, q_medium.id, q_long.id] + + # descending should be the reverse + arguments["order_direction"] = "desc" + uri = f"api/v1/query/?q={rison.dumps(arguments)}" + rv = self.client.get(uri) + assert rv.status_code == 200 + data = rv.get_json() + ids = [r["id"] for r in data["result"]] + assert ids == [q_long.id, q_medium.id, q_null.id] + + db.session.delete(q_long) + db.session.delete(q_medium) + db.session.delete(q_null) + db.session.commit() + def test_get_list_query_no_data_access(self): """ Query API: Test get queries no data access