You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ar...@apache.org on 2024/03/08 00:44:10 UTC

(superset) branch table-time-comparison created (now 6c11355037)

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

arivero pushed a change to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git


      at 6c11355037 Table with Time Comparison:

This branch includes the following new commits:

     new b540980df1 refactor(plugins): Time Comparison Utils
     new c3d04b3fa8 Table with Time Comparison:
     new aa74402839 Table with Time Comparison:
     new 5106a2bb1a Table with Time Comparison:
     new 82fe8f5223 Table with Time Comparison:
     new 381e9b755e Table with Time Comparison:
     new 0d4bad4979 Table with Time Comparison:
     new 739a80e41f Table with Time Comparison:
     new 6c11355037 Table with Time Comparison:

The 9 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 01/09: refactor(plugins): Time Comparison Utils

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit b540980df17dd5f958a1f3d7e84d3cf471e4508e
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Fri Mar 8 01:39:32 2024 +0100

    refactor(plugins): Time Comparison Utils
    
    (cherry picked from commit 127df24c0837df0f6f7174ecb87a660dc1262458)
---
 .../packages/superset-ui-core/src/index.ts         |   1 +
 .../superset-ui-core/src/query/types/Query.ts      |   2 +
 .../superset-ui-core/src/time-comparison/README.md |  47 ++++
 .../src/time-comparison/getComparisonFilters.ts    |  67 +++++
 .../src/time-comparison/getComparisonInfo.ts       |  65 +++++
 .../src/{validator => time-comparison}/index.ts    |  11 +-
 .../index.ts => time-comparison/types.ts}          |  18 +-
 .../superset-ui-core/src/validator/index.ts        |   1 +
 ...dex.ts => validateTimeComparisonRangeValues.ts} |  25 +-
 .../time-comparison/getComparisonFilters.test.ts   | 144 +++++++++++
 .../test/time-comparison/getComparisonInfo.test.ts | 174 +++++++++++++
 .../time-comparison/index.test.ts}                 |  20 +-
 .../validateTimeComparisonRangeValues.test.ts      |  58 +++++
 .../src/plugin/buildQuery.ts                       |  79 ++----
 .../src/plugin/controlPanel.ts                     |  23 +-
 .../src/plugin/transformProps.ts                   |  24 +-
 .../src/utils.ts                                   | 277 ---------------------
 superset/charts/schemas.py                         |   8 +
 superset/common/utils/time_range_utils.py          |   3 +
 superset/constants.py                              |   8 +
 superset/utils/date_parser.py                      |  46 +++-
 tests/unit_tests/utils/date_parser_tests.py        |  72 ++++++
 22 files changed, 780 insertions(+), 393 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/index.ts b/superset-frontend/packages/superset-ui-core/src/index.ts
index ea7a4efde7..7258a3b648 100644
--- a/superset-frontend/packages/superset-ui-core/src/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/index.ts
@@ -37,3 +37,4 @@ export * from './math-expression';
 export * from './ui-overrides';
 export * from './hooks';
 export * from './currency-format';
+export * from './time-comparison';
diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
index 8999a2b574..718f10514c 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
@@ -69,6 +69,8 @@ export type QueryObjectExtras = Partial<{
   time_grain_sqla?: TimeGranularity;
   /** WHERE condition */
   where?: string;
+  /** Instant Time Comparison */
+  instant_time_comparison_range?: string;
 }>;
 
 export type ResidualQueryObjectData = {
diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/README.md b/superset-frontend/packages/superset-ui-core/src/time-comparison/README.md
new file mode 100644
index 0000000000..ccb0ac9e4b
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/README.md
@@ -0,0 +1,47 @@
+<!--
+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.
+-->
+
+## @superset-ui/core/time-comparison
+
+This is a collection of methods used to support Time Comparison in charts.
+
+#### Example usage
+
+```js
+import { getComparisonTimeRangeInfo } from '@superset-ui/core';
+const { since, until } = getComparisonTimeRangeInfo(
+  adhocFilters,
+  extraFormData,
+);
+console.log(adhocFilters, extraFormData);
+```
+
+or
+
+```js
+import { ComparisonTimeRangeType } from '@superset-ui/core';
+ComparisonTimeRangeType.Custom; // 'c'
+ComparisonTimeRangeType.InheritRange; // 'r'
+```
+
+#### API
+
+`fn(args)`
+
+- Do something
diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/getComparisonFilters.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/getComparisonFilters.ts
new file mode 100644
index 0000000000..f58a9c7280
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/getComparisonFilters.ts
@@ -0,0 +1,67 @@
+/**
+ * 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.
+ */
+
+import { QueryFormData } from '../query';
+import { AdhocFilter } from '../types';
+
+/**
+ * This method is used to get the query filters to be applied to the comparison query after
+ * overriding the time range in case an extra form data is provided.
+ * For example when rendering a chart that uses time comparison in a dashboard with time filters.
+ * @param formData - the form data
+ * @param extraFormData - the extra form data
+ * @returns the query filters to be applied to the comparison query
+ */
+export const getComparisonFilters = (
+  formData: QueryFormData,
+  extraFormData: any,
+): AdhocFilter[] => {
+  const timeFilterIndex: number =
+    formData.adhoc_filters?.findIndex(
+      filter => 'operator' in filter && filter.operator === 'TEMPORAL_RANGE',
+    ) ?? -1;
+
+  const timeFilter: AdhocFilter | null =
+    timeFilterIndex !== -1 && formData.adhoc_filters
+      ? formData.adhoc_filters[timeFilterIndex]
+      : null;
+
+  if (
+    timeFilter &&
+    'comparator' in timeFilter &&
+    typeof timeFilter.comparator === 'string'
+  ) {
+    if (extraFormData?.time_range) {
+      timeFilter.comparator = extraFormData.time_range;
+    }
+  }
+
+  const comparisonQueryFilter = timeFilter ? [timeFilter] : [];
+
+  const otherFilters = formData.adhoc_filters?.filter(
+    (_value: any, index: number) => timeFilterIndex !== index,
+  );
+  const comparisonQueryFilters = otherFilters
+    ? [...comparisonQueryFilter, ...otherFilters]
+    : comparisonQueryFilter;
+
+  return comparisonQueryFilters;
+};
+
+export default getComparisonFilters;
diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/getComparisonInfo.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/getComparisonInfo.ts
new file mode 100644
index 0000000000..f73167efde
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/getComparisonInfo.ts
@@ -0,0 +1,65 @@
+/**
+ * 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.
+ */
+
+import { QueryFormData } from '../query';
+import { getComparisonFilters } from './getComparisonFilters';
+import { ComparisonTimeRangeType } from './types';
+
+/**
+ * This is the main function to get the comparison info. It will return the formData
+ * that a viz can use to query the comparison data and the time shift text needed for
+ * the comparison time range based on the control value.
+ * @param formData
+ * @param timeComparison
+ * @param extraFormData
+ * @returns the processed formData
+ */
+
+export const getComparisonInfo = (
+  formData: QueryFormData,
+  timeComparison: string,
+  extraFormData: any,
+): QueryFormData => {
+  let comparisonFormData;
+
+  if (timeComparison !== ComparisonTimeRangeType.Custom) {
+    comparisonFormData = {
+      ...formData,
+      adhoc_filters: getComparisonFilters(formData, extraFormData),
+      extra_form_data: {
+        ...extraFormData,
+        time_range: undefined,
+      },
+    };
+  } else {
+    // This is when user selects Custom as time comparison
+    comparisonFormData = {
+      ...formData,
+      adhoc_filters: formData.adhoc_custom,
+      extra_form_data: {
+        ...extraFormData,
+        time_range: undefined,
+      },
+    };
+  }
+
+  return comparisonFormData;
+};
+
+export default getComparisonInfo;
diff --git a/superset-frontend/packages/superset-ui-core/src/validator/index.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts
similarity index 62%
copy from superset-frontend/packages/superset-ui-core/src/validator/index.ts
copy to superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts
index 6294bddec7..4b9fb361fd 100644
--- a/superset-frontend/packages/superset-ui-core/src/validator/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts
@@ -17,10 +17,7 @@
  * under the License.
  */
 
-export { default as legacyValidateInteger } from './legacyValidateInteger';
-export { default as legacyValidateNumber } from './legacyValidateNumber';
-export { default as validateInteger } from './validateInteger';
-export { default as validateNumber } from './validateNumber';
-export { default as validateNonEmpty } from './validateNonEmpty';
-export { default as validateMaxValue } from './validateMaxValue';
-export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl';
+export * from './types';
+
+export { default as getComparisonInfo } from './getComparisonInfo';
+export { default as getComparisonFilters } from './getComparisonFilters';
diff --git a/superset-frontend/packages/superset-ui-core/src/validator/index.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/types.ts
similarity index 62%
copy from superset-frontend/packages/superset-ui-core/src/validator/index.ts
copy to superset-frontend/packages/superset-ui-core/src/time-comparison/types.ts
index 6294bddec7..d9d61a19cd 100644
--- a/superset-frontend/packages/superset-ui-core/src/validator/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/types.ts
@@ -17,10 +17,14 @@
  * under the License.
  */
 
-export { default as legacyValidateInteger } from './legacyValidateInteger';
-export { default as legacyValidateNumber } from './legacyValidateNumber';
-export { default as validateInteger } from './validateInteger';
-export { default as validateNumber } from './validateNumber';
-export { default as validateNonEmpty } from './validateNonEmpty';
-export { default as validateMaxValue } from './validateMaxValue';
-export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl';
+/**
+ * Supported comparison time ranges
+ */
+
+export enum ComparisonTimeRangeType {
+  Custom = 'c',
+  InheritedRange = 'r',
+  Month = 'm',
+  Week = 'w',
+  Year = 'y',
+}
diff --git a/superset-frontend/packages/superset-ui-core/src/validator/index.ts b/superset-frontend/packages/superset-ui-core/src/validator/index.ts
index 6294bddec7..1198c4e0a5 100644
--- a/superset-frontend/packages/superset-ui-core/src/validator/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/validator/index.ts
@@ -24,3 +24,4 @@ export { default as validateNumber } from './validateNumber';
 export { default as validateNonEmpty } from './validateNonEmpty';
 export { default as validateMaxValue } from './validateMaxValue';
 export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl';
+export { default as validateTimeComparisonRangeValues } from './validateTimeComparisonRangeValues';
diff --git a/superset-frontend/packages/superset-ui-core/src/validator/index.ts b/superset-frontend/packages/superset-ui-core/src/validator/validateTimeComparisonRangeValues.ts
similarity index 57%
copy from superset-frontend/packages/superset-ui-core/src/validator/index.ts
copy to superset-frontend/packages/superset-ui-core/src/validator/validateTimeComparisonRangeValues.ts
index 6294bddec7..c639ec6caf 100644
--- a/superset-frontend/packages/superset-ui-core/src/validator/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/validator/validateTimeComparisonRangeValues.ts
@@ -17,10 +17,21 @@
  * under the License.
  */
 
-export { default as legacyValidateInteger } from './legacyValidateInteger';
-export { default as legacyValidateNumber } from './legacyValidateNumber';
-export { default as validateInteger } from './validateInteger';
-export { default as validateNumber } from './validateNumber';
-export { default as validateNonEmpty } from './validateNonEmpty';
-export { default as validateMaxValue } from './validateMaxValue';
-export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl';
+import { ComparisonTimeRangeType } from '../time-comparison';
+import { t } from '../translation';
+import { ensureIsArray } from '../utils';
+
+export const validateTimeComparisonRangeValues = (
+  timeRangeValue?: any,
+  controlValue?: any,
+) => {
+  const isCustomTimeRange = timeRangeValue === ComparisonTimeRangeType.Custom;
+  const isCustomControlEmpty = controlValue?.every(
+    (val: any) => ensureIsArray(val).length === 0,
+  );
+  return isCustomTimeRange && isCustomControlEmpty
+    ? [t('Filters for comparison must have a value')]
+    : [];
+};
+
+export default validateTimeComparisonRangeValues;
diff --git a/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonFilters.test.ts b/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonFilters.test.ts
new file mode 100644
index 0000000000..449fe5c492
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonFilters.test.ts
@@ -0,0 +1,144 @@
+/*
+ * 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.
+ */
+
+import { getComparisonFilters } from '@superset-ui/core';
+
+const form_data = {
+  datasource: '22__table',
+  viz_type: 'pop_kpi',
+  slice_id: 97,
+  url_params: {
+    form_data_key:
+      'TaBakyDiAx2VsQ47gLmlsJKeN4foqnoxUKdbQrM05qnKMRjO9PDe42iZN1oxmxZ8',
+    save_action: 'overwrite',
+    slice_id: '97',
+  },
+  metrics: ['count'],
+  adhoc_filters: [
+    {
+      clause: 'WHERE',
+      comparator: '2004-02-16 : 2024-02-16',
+      datasourceWarning: false,
+      expressionType: 'SIMPLE',
+      filterOptionName: 'filter_8274fo9pogn_ihi8x28o7a',
+      isExtra: false,
+      isNew: false,
+      operator: 'TEMPORAL_RANGE',
+      sqlExpression: null,
+      subject: 'order_date',
+    } as any,
+  ],
+  time_comparison: 'y',
+  adhoc_custom: [
+    {
+      clause: 'WHERE',
+      comparator: 'No filter',
+      expressionType: 'SIMPLE',
+      operator: 'TEMPORAL_RANGE',
+      subject: 'order_date',
+    },
+  ],
+  row_limit: 10000,
+  y_axis_format: 'SMART_NUMBER',
+  header_font_size: 60,
+  subheader_font_size: 26,
+  comparison_color_enabled: true,
+  extra_form_data: {},
+  force: false,
+  result_format: 'json',
+  result_type: 'full',
+};
+
+const mockExtraFormData = {
+  time_range: 'new and cool range from extra form data',
+};
+
+describe('getComparisonFilters', () => {
+  it('Keeps the original adhoc_filters since no extra data was passed', () => {
+    const result = getComparisonFilters(form_data, {});
+
+    expect(result).toEqual(form_data.adhoc_filters);
+  });
+
+  it('Updates the time_range if the filter if extra form data is passed', () => {
+    const result = getComparisonFilters(form_data, mockExtraFormData);
+
+    const expectedFilters = [
+      {
+        clause: 'WHERE',
+        comparator: 'new and cool range from extra form data',
+        datasourceWarning: false,
+        expressionType: 'SIMPLE',
+        filterOptionName: 'filter_8274fo9pogn_ihi8x28o7a',
+        isExtra: false,
+        isNew: false,
+        operator: 'TEMPORAL_RANGE',
+        sqlExpression: null,
+        subject: 'order_date',
+      } as any,
+    ];
+
+    expect(result.length).toEqual(1);
+    expect(result[0]).toEqual(expectedFilters[0]);
+  });
+
+  it('handles no time range filters', () => {
+    const result = getComparisonFilters(
+      {
+        ...form_data,
+        adhoc_filters: [
+          {
+            expressionType: 'SIMPLE',
+            subject: 'address_line1',
+            operator: 'IN',
+            comparator: ['7734 Strong St.'],
+            clause: 'WHERE',
+            isExtra: false,
+          },
+        ],
+      },
+      {},
+    );
+
+    const expectedFilters = [
+      {
+        expressionType: 'SIMPLE',
+        subject: 'address_line1',
+        operator: 'IN',
+        comparator: ['7734 Strong St.'],
+        clause: 'WHERE',
+        isExtra: false,
+      },
+    ];
+    expect(result.length).toEqual(1);
+    expect(result[0]).toEqual(expectedFilters[0]);
+  });
+
+  it('If adhoc_filter is undefrined the code wont break', () => {
+    const result = getComparisonFilters(
+      {
+        ...form_data,
+        adhoc_filters: undefined,
+      },
+      {},
+    );
+
+    expect(result).toEqual([]);
+  });
+});
diff --git a/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonInfo.test.ts b/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonInfo.test.ts
new file mode 100644
index 0000000000..1af9cc9e4e
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/test/time-comparison/getComparisonInfo.test.ts
@@ -0,0 +1,174 @@
+/*
+ * 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.
+ */
+
+import { getComparisonInfo, ComparisonTimeRangeType } from '@superset-ui/core';
+
+const form_data = {
+  datasource: '22__table',
+  viz_type: 'pop_kpi',
+  slice_id: 97,
+  url_params: {
+    form_data_key:
+      'TaBakyDiAx2VsQ47gLmlsJKeN4foqnoxUKdbQrM05qnKMRjO9PDe42iZN1oxmxZ8',
+    save_action: 'overwrite',
+    slice_id: '97',
+  },
+  metrics: ['count'],
+  adhoc_filters: [
+    {
+      clause: 'WHERE',
+      comparator: '2004-02-16 : 2024-02-16',
+      datasourceWarning: false,
+      expressionType: 'SIMPLE',
+      filterOptionName: 'filter_8274fo9pogn_ihi8x28o7a',
+      isExtra: false,
+      isNew: false,
+      operator: 'TEMPORAL_RANGE',
+      sqlExpression: null,
+      subject: 'order_date',
+    } as any,
+  ],
+  time_comparison: 'y',
+  adhoc_custom: [
+    {
+      clause: 'WHERE',
+      comparator: 'No filter',
+      expressionType: 'SIMPLE',
+      operator: 'TEMPORAL_RANGE',
+      subject: 'order_date',
+    },
+  ],
+  row_limit: 10000,
+  y_axis_format: 'SMART_NUMBER',
+  header_font_size: 60,
+  subheader_font_size: 26,
+  comparison_color_enabled: true,
+  extra_form_data: {},
+  force: false,
+  result_format: 'json',
+  result_type: 'full',
+};
+
+const mockExtraFormData = {
+  time_range: 'new and cool range from extra form data',
+};
+
+describe('getComparisonInfo', () => {
+  it('Keeps the original adhoc_filters since no extra data was passed', () => {
+    const resultFormData = getComparisonInfo(
+      form_data,
+      ComparisonTimeRangeType.Year,
+      {},
+    );
+    expect(resultFormData).toEqual(form_data);
+  });
+
+  it('Updates the time_range of the adhoc_filters when extra form data is passed', () => {
+    const resultFormData = getComparisonInfo(
+      form_data,
+      ComparisonTimeRangeType.Month,
+      mockExtraFormData,
+    );
+
+    const expectedFilters = [
+      {
+        clause: 'WHERE',
+        comparator: 'new and cool range from extra form data',
+        datasourceWarning: false,
+        expressionType: 'SIMPLE',
+        filterOptionName: 'filter_8274fo9pogn_ihi8x28o7a',
+        isExtra: false,
+        isNew: false,
+        operator: 'TEMPORAL_RANGE',
+        sqlExpression: null,
+        subject: 'order_date',
+      } as any,
+    ];
+
+    expect(resultFormData.adhoc_filters?.length).toEqual(1);
+    expect(resultFormData.adhoc_filters).toEqual(expectedFilters);
+  });
+
+  it('handles no time range filters', () => {
+    const resultFormData = getComparisonInfo(
+      {
+        ...form_data,
+        adhoc_filters: [
+          {
+            expressionType: 'SIMPLE',
+            subject: 'address_line1',
+            operator: 'IN',
+            comparator: ['7734 Strong St.'],
+            clause: 'WHERE',
+            isExtra: false,
+          },
+        ],
+      },
+      ComparisonTimeRangeType.Week,
+      {},
+    );
+
+    const expectedFilters = [
+      {
+        expressionType: 'SIMPLE',
+        subject: 'address_line1',
+        operator: 'IN',
+        comparator: ['7734 Strong St.'],
+        clause: 'WHERE',
+        isExtra: false,
+      },
+    ];
+    expect(resultFormData.adhoc_filters?.length).toEqual(1);
+    expect(resultFormData.adhoc_filters?.[0]).toEqual(expectedFilters[0]);
+  });
+
+  it('If adhoc_filter is undefrined the code wont break', () => {
+    const resultFormData = getComparisonInfo(
+      {
+        ...form_data,
+        adhoc_filters: undefined,
+      },
+      ComparisonTimeRangeType.InheritedRange,
+      {},
+    );
+
+    expect(resultFormData.adhoc_filters?.length).toEqual(0);
+    expect(resultFormData.adhoc_filters).toEqual([]);
+  });
+
+  it('Handles the custom time filters and return the correct time shift text', () => {
+    const resultFormData = getComparisonInfo(
+      form_data,
+      ComparisonTimeRangeType.Custom,
+      {},
+    );
+
+    const expectedFilters = [
+      {
+        clause: 'WHERE',
+        comparator: 'No filter',
+        expressionType: 'SIMPLE',
+        operator: 'TEMPORAL_RANGE',
+        subject: 'order_date',
+      },
+    ];
+    expect(resultFormData.adhoc_filters?.length).toEqual(1);
+    expect(resultFormData.adhoc_filters).toEqual(expectedFilters);
+  });
+});
diff --git a/superset-frontend/packages/superset-ui-core/src/validator/index.ts b/superset-frontend/packages/superset-ui-core/test/time-comparison/index.test.ts
similarity index 62%
copy from superset-frontend/packages/superset-ui-core/src/validator/index.ts
copy to superset-frontend/packages/superset-ui-core/test/time-comparison/index.test.ts
index 6294bddec7..9ff8a31ab7 100644
--- a/superset-frontend/packages/superset-ui-core/src/validator/index.ts
+++ b/superset-frontend/packages/superset-ui-core/test/time-comparison/index.test.ts
@@ -17,10 +17,16 @@
  * under the License.
  */
 
-export { default as legacyValidateInteger } from './legacyValidateInteger';
-export { default as legacyValidateNumber } from './legacyValidateNumber';
-export { default as validateInteger } from './validateInteger';
-export { default as validateNumber } from './validateNumber';
-export { default as validateNonEmpty } from './validateNonEmpty';
-export { default as validateMaxValue } from './validateMaxValue';
-export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl';
+import {
+  ComparisonTimeRangeType,
+  getComparisonFilters,
+  getComparisonInfo,
+} from '@superset-ui/core';
+
+describe('index', () => {
+  it('exports modules', () => {
+    [ComparisonTimeRangeType, getComparisonFilters, getComparisonInfo].forEach(
+      x => expect(x).toBeDefined(),
+    );
+  });
+});
diff --git a/superset-frontend/packages/superset-ui-core/test/validator/validateTimeComparisonRangeValues.test.ts b/superset-frontend/packages/superset-ui-core/test/validator/validateTimeComparisonRangeValues.test.ts
new file mode 100644
index 0000000000..ac0d5a481b
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/test/validator/validateTimeComparisonRangeValues.test.ts
@@ -0,0 +1,58 @@
+/*
+ * 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.
+ */
+
+import {
+  ComparisonTimeRangeType,
+  validateTimeComparisonRangeValues,
+} from '@superset-ui/core';
+import './setup';
+
+describe('validateTimeComparisonRangeValues()', () => {
+  it('returns the warning message if invalid', () => {
+    expect(
+      validateTimeComparisonRangeValues(ComparisonTimeRangeType.Custom, []),
+    ).toBeTruthy();
+    expect(
+      validateTimeComparisonRangeValues(
+        ComparisonTimeRangeType.Custom,
+        undefined,
+      ),
+    ).toBeTruthy();
+    expect(
+      validateTimeComparisonRangeValues(ComparisonTimeRangeType.Custom, null),
+    ).toBeTruthy();
+  });
+  it('returns empty array if the input is valid', () => {
+    expect(
+      validateTimeComparisonRangeValues(ComparisonTimeRangeType.Year, []),
+    ).toEqual([]);
+    expect(
+      validateTimeComparisonRangeValues(
+        ComparisonTimeRangeType.Year,
+        undefined,
+      ),
+    ).toEqual([]);
+    expect(
+      validateTimeComparisonRangeValues(ComparisonTimeRangeType.Year, null),
+    ).toEqual([]);
+    expect(
+      validateTimeComparisonRangeValues(ComparisonTimeRangeType.Custom, [1]),
+    ).toEqual([]);
+  });
+});
diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/buildQuery.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/buildQuery.ts
index 63ea2cb78c..641f9e3858 100644
--- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/buildQuery.ts
+++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/buildQuery.ts
@@ -17,11 +17,11 @@
  * under the License.
  */
 import {
-  AdhocFilter,
   buildQueryContext,
+  getComparisonInfo,
+  ComparisonTimeRangeType,
   QueryFormData,
 } from '@superset-ui/core';
-import { computeQueryBComparator } from '../utils';
 
 /**
  * The buildQuery function is used to create an instance of QueryContext that's
@@ -52,63 +52,28 @@ export default function buildQuery(formData: QueryFormData) {
     },
   ]);
 
-  const timeFilterIndex: number =
-    formData.adhoc_filters?.findIndex(
-      filter => 'operator' in filter && filter.operator === 'TEMPORAL_RANGE',
-    ) ?? -1;
+  const comparisonFormData = getComparisonInfo(
+    formData,
+    timeComparison,
+    extraFormData,
+  );
 
-  const timeFilter: AdhocFilter | null =
-    timeFilterIndex !== -1 && formData.adhoc_filters
-      ? formData.adhoc_filters[timeFilterIndex]
-      : null;
-
-  let formDataB: QueryFormData;
-  let queryBComparator = null;
-
-  if (timeComparison !== 'c') {
-    queryBComparator = computeQueryBComparator(
-      formData.adhoc_filters || [],
-      timeComparison,
-      extraFormData,
-    );
-
-    const queryBFilter: any = {
-      ...timeFilter,
-      comparator: queryBComparator,
-    };
-
-    const otherFilters = formData.adhoc_filters?.filter(
-      (_value: any, index: number) => timeFilterIndex !== index,
-    );
-    const queryBFilters = otherFilters
-      ? [queryBFilter, ...otherFilters]
-      : [queryBFilter];
-
-    formDataB = {
-      ...formData,
-      adhoc_filters: queryBFilters,
-      extra_form_data: {
-        ...extraFormData,
-        time_range: undefined,
-      },
-    };
-  } else {
-    formDataB = {
-      ...formData,
-      adhoc_filters: formData.adhoc_custom,
-      extra_form_data: {
-        ...extraFormData,
-        time_range: undefined,
+  const queryContextB = buildQueryContext(
+    comparisonFormData,
+    baseQueryObject => [
+      {
+        ...baseQueryObject,
+        groupby,
+        extras: {
+          ...baseQueryObject.extras,
+          instant_time_comparison_range:
+            timeComparison !== ComparisonTimeRangeType.Custom
+              ? timeComparison
+              : undefined,
+        },
       },
-    };
-  }
-
-  const queryContextB = buildQueryContext(formDataB, baseQueryObject => [
-    {
-      ...baseQueryObject,
-      groupby,
-    },
-  ]);
+    ],
+  );
 
   return {
     ...queryContextA,
diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/controlPanel.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/controlPanel.ts
index 8f16a2b8d4..7feb3445df 100644
--- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/controlPanel.ts
+++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/controlPanel.ts
@@ -16,7 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { ensureIsArray, t, validateNonEmpty } from '@superset-ui/core';
+import {
+  ComparisonTimeRangeType,
+  t,
+  validateNonEmpty,
+  validateTimeComparisonRangeValues,
+} from '@superset-ui/core';
 import {
   ControlPanelConfig,
   ControlPanelState,
@@ -24,19 +29,6 @@ import {
   sharedControls,
 } from '@superset-ui/chart-controls';
 
-const validateTimeComparisonRangeValues = (
-  timeRangeValue?: any,
-  controlValue?: any,
-) => {
-  const isCustomTimeRange = timeRangeValue === 'c';
-  const isCustomControlEmpty = controlValue?.every(
-    (val: any) => ensureIsArray(val).length === 0,
-  );
-  return isCustomTimeRange && isCustomControlEmpty
-    ? [t('Filters for comparison must have a value')]
-    : [];
-};
-
 const config: ControlPanelConfig = {
   controlPanelSections: [
     {
@@ -88,7 +80,8 @@ const config: ControlPanelConfig = {
               description:
                 'This only applies when selecting the Range for Comparison Type: Custom',
               visibility: ({ controls }) =>
-                controls?.time_comparison?.value === 'c',
+                controls?.time_comparison?.value ===
+                ComparisonTimeRangeType.Custom,
               mapStateToProps: (
                 state: ControlPanelState,
                 controlState: ControlState,
diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/transformProps.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/transformProps.ts
index 2199984d0a..9dddc21d55 100644
--- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/transformProps.ts
@@ -23,8 +23,8 @@ import {
   getValueFormatter,
   NumberFormats,
   getNumberFormatter,
+  formatTimeRange,
 } from '@superset-ui/core';
-import { computeQueryBComparator, formatCustomComparator } from '../utils';
 
 export const parseMetricValue = (metricValue: number | string | null) => {
   if (typeof metricValue === 'string') {
@@ -85,7 +85,11 @@ export default function transformProps(chartProps: ChartProps) {
     comparisonColorEnabled,
   } = formData;
   const { data: dataA = [] } = queriesData[0];
-  const { data: dataB = [] } = queriesData[1];
+  const {
+    data: dataB = [],
+    from_dttm: comparisonFromDatetime,
+    to_dttm: comparisonToDatetime,
+  } = queriesData[1];
   const data = dataA;
   const metricName = getMetricLabel(metrics[0]);
   let bigNumber: number | string =
@@ -129,18 +133,10 @@ export default function transformProps(chartProps: ChartProps) {
   prevNumber = numberFormatter(prevNumber);
   valueDifference = numberFormatter(valueDifference);
   const percentDifference: string = formatPercentChange(percentDifferenceNum);
-  const comparatorText =
-    formData.timeComparison !== 'c'
-      ? ` ${computeQueryBComparator(
-          formData.adhocFilters,
-          formData.timeComparison,
-          formData.extraFormData,
-          ' - ',
-        )}`
-      : `${formatCustomComparator(
-          formData.adhocCustom,
-          formData.extraFormData,
-        )}`;
+  const comparatorText = formatTimeRange('%Y-%m-%d', [
+    comparisonFromDatetime,
+    comparisonToDatetime,
+  ]);
 
   return {
     width,
diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/utils.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/utils.ts
deleted file mode 100644
index eda69c2bb2..0000000000
--- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/utils.ts
+++ /dev/null
@@ -1,277 +0,0 @@
-/**
- * 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.
- */
-
-import { AdhocFilter } from '@superset-ui/core';
-import moment, { Moment } from 'moment';
-
-type MomentTuple = [moment.Moment | null, moment.Moment | null];
-
-const getSinceUntil = (
-  timeRange: string | null = null,
-  relativeStart: string | null = null,
-  relativeEnd: string | null = null,
-): MomentTuple => {
-  const separator = ' : ';
-  const effectiveRelativeStart = relativeStart || 'today';
-  const effectiveRelativeEnd = relativeEnd || 'today';
-
-  if (!timeRange) {
-    return [null, null];
-  }
-
-  let modTimeRange: string | null = timeRange;
-
-  if (timeRange === 'NO_TIME_RANGE' || timeRange === '_(NO_TIME_RANGE)') {
-    return [null, null];
-  }
-
-  if (timeRange?.startsWith('last') && !timeRange.includes(separator)) {
-    modTimeRange = timeRange + separator + effectiveRelativeEnd;
-  }
-
-  if (timeRange?.startsWith('next') && !timeRange.includes(separator)) {
-    modTimeRange = effectiveRelativeStart + separator + timeRange;
-  }
-
-  if (
-    timeRange?.startsWith('previous calendar week') &&
-    !timeRange.includes(separator)
-  ) {
-    return [
-      moment().subtract(1, 'week').startOf('week'),
-      moment().startOf('week'),
-    ];
-  }
-
-  if (
-    timeRange?.startsWith('previous calendar month') &&
-    !timeRange.includes(separator)
-  ) {
-    return [
-      moment().subtract(1, 'month').startOf('month'),
-      moment().startOf('month'),
-    ];
-  }
-
-  if (
-    timeRange?.startsWith('previous calendar year') &&
-    !timeRange.includes(separator)
-  ) {
-    return [
-      moment().subtract(1, 'year').startOf('year'),
-      moment().startOf('year'),
-    ];
-  }
-
-  const timeRangeLookup: Array<[RegExp, (...args: string[]) => Moment]> = [
-    [
-      /^last\s+(day|week|month|quarter|year)$/i,
-      (unit: string) =>
-        moment().subtract(1, unit as moment.unitOfTime.DurationConstructor),
-    ],
-    [
-      /^last\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s?$/i,
-      (delta: string, unit: string) =>
-        moment().subtract(delta, unit as moment.unitOfTime.DurationConstructor),
-    ],
-    [
-      /^next\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s?$/i,
-      (delta: string, unit: string) =>
-        moment().add(delta, unit as moment.unitOfTime.DurationConstructor),
-    ],
-    [
-      // eslint-disable-next-line no-useless-escape
-      /DATEADD\(DATETIME\("([^"]+)"\),\s*(-?\d+),\s*([^\)]+)\)/i,
-      (timePart: string, delta: string, unit: string) => {
-        if (timePart === 'now') {
-          return moment().add(
-            delta,
-            unit as moment.unitOfTime.DurationConstructor,
-          );
-        }
-        if (moment(timePart.toUpperCase(), true).isValid()) {
-          return moment(timePart).add(
-            delta,
-            unit as moment.unitOfTime.DurationConstructor,
-          );
-        }
-        return moment();
-      },
-    ],
-  ];
-
-  const sinceAndUntilPartition = modTimeRange
-    .split(separator, 2)
-    .map(part => part.trim());
-
-  const sinceAndUntil: (Moment | null)[] = sinceAndUntilPartition.map(part => {
-    if (!part) {
-      return null;
-    }
-
-    let transformedValue: Moment | null = null;
-    // Matching time_range_lookup
-    const matched = timeRangeLookup.some(([pattern, fn]) => {
-      const result = part.match(pattern);
-      if (result) {
-        transformedValue = fn(...result.slice(1));
-        return true;
-      }
-
-      if (part === 'today') {
-        transformedValue = moment().startOf('day');
-        return true;
-      }
-
-      if (part === 'now') {
-        transformedValue = moment();
-        return true;
-      }
-      return false;
-    });
-
-    if (matched && transformedValue !== null) {
-      // Handle the transformed value
-    } else {
-      // Handle the case when there was no match
-      transformedValue = moment(`${part}`);
-    }
-
-    return transformedValue;
-  });
-
-  const [_since, _until] = sinceAndUntil;
-
-  if (_since && _until && _since.isAfter(_until)) {
-    throw new Error('From date cannot be larger than to date');
-  }
-
-  return [_since, _until];
-};
-
-const calculatePrev = (
-  startDate: Moment | null,
-  endDate: Moment | null,
-  calcType: String,
-) => {
-  if (!startDate || !endDate) {
-    return [null, null];
-  }
-
-  const daysBetween = endDate.diff(startDate, 'days');
-
-  let startDatePrev = moment();
-  let endDatePrev = moment();
-  if (calcType === 'y') {
-    startDatePrev = startDate.subtract(1, 'year');
-    endDatePrev = endDate.subtract(1, 'year');
-  } else if (calcType === 'w') {
-    startDatePrev = startDate.subtract(1, 'week');
-    endDatePrev = endDate.subtract(1, 'week');
-  } else if (calcType === 'm') {
-    startDatePrev = startDate.subtract(1, 'month');
-    endDatePrev = endDate.subtract(1, 'month');
-  } else if (calcType === 'r') {
-    startDatePrev = startDate.clone().subtract(daysBetween.valueOf(), 'day');
-    endDatePrev = startDate;
-  } else {
-    startDatePrev = startDate.subtract(1, 'year');
-    endDatePrev = endDate.subtract(1, 'year');
-  }
-
-  return [startDatePrev, endDatePrev];
-};
-
-const getTimeRange = (
-  adhocFilters: AdhocFilter[],
-  extraFormData: any,
-): string | null => {
-  const timeFilterIndex =
-    adhocFilters?.findIndex(
-      filter => 'operator' in filter && filter.operator === 'TEMPORAL_RANGE',
-    ) ?? -1;
-
-  const timeFilter =
-    timeFilterIndex !== -1 ? adhocFilters[timeFilterIndex] : null;
-
-  if (
-    timeFilter &&
-    'comparator' in timeFilter &&
-    typeof timeFilter.comparator === 'string'
-  ) {
-    let timeRange = timeFilter.comparator.toLocaleLowerCase();
-    if (extraFormData?.time_range) {
-      timeRange = extraFormData.time_range;
-    }
-    return timeRange;
-  }
-
-  return null;
-};
-
-export const computeQueryBComparator = (
-  adhocFilters: AdhocFilter[],
-  timeComparison: string,
-  extraFormData: any,
-  join = ':',
-) => {
-  const timeRange = getTimeRange(adhocFilters, extraFormData);
-
-  let testSince = null;
-  let testUntil = null;
-
-  if (timeRange) {
-    [testSince, testUntil] = getSinceUntil(timeRange);
-  }
-
-  if (timeComparison !== 'c') {
-    const [prevStartDateMoment, prevEndDateMoment] = calculatePrev(
-      testSince,
-      testUntil,
-      timeComparison,
-    );
-
-    return `${prevStartDateMoment?.format(
-      'YYYY-MM-DDTHH:mm:ss',
-    )} ${join} ${prevEndDateMoment?.format('YYYY-MM-DDTHH:mm:ss')}`.replace(
-      /Z/g,
-      '',
-    );
-  }
-
-  return null;
-};
-
-export const formatCustomComparator = (
-  adhocFilters: AdhocFilter[],
-  extraFormData: any,
-): string => {
-  const timeRange = getTimeRange(adhocFilters, extraFormData);
-
-  if (timeRange) {
-    const [start, end] = timeRange.split(' : ').map(dateStr => {
-      const formattedDate = moment(dateStr).format('YYYY-MM-DDTHH:mm:ss');
-      return formattedDate.replace(/Z/g, '');
-    });
-
-    return `${start} - ${end}`;
-  }
-
-  return '';
-};
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index 48e0cbb318..611f7af597 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -990,6 +990,14 @@ class ChartDataExtrasSchema(Schema):
         ),
         allow_none=True,
     )
+    instant_time_comparison_range = fields.String(
+        metadata={
+            "description": "This is only set using the new time comparison controls "
+            "that is made available in some plugins behind the experimental "
+            "feature flag."
+        },
+        allow_none=True,
+    )
 
 
 class AnnotationLayerSchema(Schema):
diff --git a/superset/common/utils/time_range_utils.py b/superset/common/utils/time_range_utils.py
index 5f9139c047..2ceb9f766e 100644
--- a/superset/common/utils/time_range_utils.py
+++ b/superset/common/utils/time_range_utils.py
@@ -39,6 +39,9 @@ def get_since_until_from_time_range(
         ),
         time_range=time_range,
         time_shift=time_shift,
+        instant_time_comparison_range=(extras or {}).get(
+            "instant_time_comparison_range"
+        ),
     )
 
 
diff --git a/superset/constants.py b/superset/constants.py
index 4f01674bd4..bf4e7717d5 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -42,6 +42,14 @@ QUERY_EARLY_CANCEL_KEY = "early_cancel_query"
 LRU_CACHE_MAX_SIZE = 256
 
 
+# Used when calculating the time shift for time comparison
+class InstantTimeComparison(StrEnum):
+    INHERITED = "r"
+    YEAR = "y"
+    MONTH = "m"
+    WEEK = "w"
+
+
 class RouteMethod:  # pylint: disable=too-few-public-methods
     """
     Route methods are a FAB concept around ModelView and RestModelView
diff --git a/superset/utils/date_parser.py b/superset/utils/date_parser.py
index 2d49424a82..0253edee1c 100644
--- a/superset/utils/date_parser.py
+++ b/superset/utils/date_parser.py
@@ -46,7 +46,7 @@ from superset.commands.chart.exceptions import (
     TimeRangeAmbiguousError,
     TimeRangeParseFailError,
 )
-from superset.constants import LRU_CACHE_MAX_SIZE, NO_TIME_RANGE
+from superset.constants import InstantTimeComparison, LRU_CACHE_MAX_SIZE, NO_TIME_RANGE
 
 ParserElement.enablePackrat()
 
@@ -142,13 +142,14 @@ def parse_past_timedelta(
     )
 
 
-def get_since_until(  # pylint: disable=too-many-arguments,too-many-locals,too-many-branches
+def get_since_until(  # pylint: disable=too-many-arguments,too-many-locals,too-many-branches,too-many-statements
     time_range: Optional[str] = None,
     since: Optional[str] = None,
     until: Optional[str] = None,
     time_shift: Optional[str] = None,
     relative_start: Optional[str] = None,
     relative_end: Optional[str] = None,
+    instant_time_comparison_range: Optional[str] = None,
 ) -> tuple[Optional[datetime], Optional[datetime]]:
     """Return `since` and `until` date time tuple from string representations of
     time_range, since, until and time_shift.
@@ -263,6 +264,47 @@ def get_since_until(  # pylint: disable=too-many-arguments,too-many-locals,too-m
         _since = _since if _since is None else (_since - time_delta)
         _until = _until if _until is None else (_until - time_delta)
 
+    if instant_time_comparison_range:
+        # This is only set using the new time comparison controls
+        # that is made available in some plugins behind the experimental
+        # feature flag.
+        # pylint: disable=import-outside-toplevel
+        from superset import feature_flag_manager
+
+        if feature_flag_manager.is_feature_enabled("CHART_PLUGINS_EXPERIMENTAL"):
+            time_unit = ""
+            delta_in_days = None
+            if instant_time_comparison_range == InstantTimeComparison.YEAR:
+                time_unit = "YEAR"
+            elif instant_time_comparison_range == InstantTimeComparison.MONTH:
+                time_unit = "MONTH"
+            elif instant_time_comparison_range == InstantTimeComparison.WEEK:
+                time_unit = "WEEK"
+            elif instant_time_comparison_range == InstantTimeComparison.INHERITED:
+                delta_in_days = (_until - _since).days if _since and _until else None
+                time_unit = "DAY"
+
+            if time_unit:
+                strtfime_since = (
+                    _since.strftime("%Y-%m-%dT%H:%M:%S") if _since else relative_start
+                )
+                strtfime_until = (
+                    _until.strftime("%Y-%m-%dT%H:%M:%S") if _until else relative_end
+                )
+
+                since_and_until = [
+                    (
+                        f"DATEADD(DATETIME('{strtfime_since}'), "
+                        f"-{delta_in_days or 1}, {time_unit})"
+                    ),
+                    (
+                        f"DATEADD(DATETIME('{strtfime_until}'), "
+                        f"-{delta_in_days or 1}, {time_unit})"
+                    ),
+                ]
+
+                _since, _until = map(datetime_eval, since_and_until)
+
     if _since and _until and _since > _until:
         raise ValueError(_("From date cannot be larger than to date"))
 
diff --git a/tests/unit_tests/utils/date_parser_tests.py b/tests/unit_tests/utils/date_parser_tests.py
index 0311377237..41f4c95022 100644
--- a/tests/unit_tests/utils/date_parser_tests.py
+++ b/tests/unit_tests/utils/date_parser_tests.py
@@ -35,6 +35,7 @@ from superset.utils.date_parser import (
     parse_human_timedelta,
     parse_past_timedelta,
 )
+from tests.unit_tests.conftest import with_feature_flags
 
 
 def mock_parse_human_datetime(s: str) -> Optional[datetime]:
@@ -157,10 +158,81 @@ def test_get_since_until() -> None:
     expected = datetime(2015, 1, 1, 0, 0, 0), datetime(2016, 1, 1, 0, 0, 0)
     assert result == expected
 
+    # Tests for our new instant_time_comparison logic and Feature Flag off
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="y",
+    )
+    expected = datetime(2000, 1, 1), datetime(2018, 1, 1)
+    assert result == expected
+
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="m",
+    )
+    expected = datetime(2000, 1, 1), datetime(2018, 1, 1)
+    assert result == expected
+
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="w",
+    )
+    expected = datetime(2000, 1, 1), datetime(2018, 1, 1)
+    assert result == expected
+
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="r",
+    )
+    expected = datetime(2000, 1, 1), datetime(2018, 1, 1)
+    assert result == expected
+
     with pytest.raises(ValueError):
         get_since_until(time_range="tomorrow : yesterday")
 
 
+@with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=True)
+@patch("superset.utils.date_parser.parse_human_datetime", mock_parse_human_datetime)
+def test_get_since_until_instant_time_comparison_enabled() -> None:
+    result: tuple[Optional[datetime], Optional[datetime]]
+    expected: tuple[Optional[datetime], Optional[datetime]]
+
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="y",
+    )
+    expected = datetime(1999, 1, 1), datetime(2017, 1, 1)
+    assert result == expected
+
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="m",
+    )
+    expected = datetime(1999, 12, 1), datetime(2017, 12, 1)
+    assert result == expected
+
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="w",
+    )
+    expected = datetime(1999, 12, 25), datetime(2017, 12, 25)
+    assert result == expected
+
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="r",
+    )
+    expected = datetime(1981, 12, 31), datetime(2000, 1, 1)
+    assert result == expected
+
+    result = get_since_until(
+        time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
+        instant_time_comparison_range="unknown",
+    )
+    expected = datetime(2000, 1, 1), datetime(2018, 1, 1)
+    assert result == expected
+
+
 @patch("superset.utils.date_parser.parse_human_datetime", mock_parse_human_datetime)
 def test_datetime_eval() -> None:
     result = datetime_eval("datetime('now')")


(superset) 04/09: Table with Time Comparison:

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 5106a2bb1a0d3c7cd465abb5c55928fbfb381f10
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Mon Mar 4 13:09:08 2024 +0100

    Table with Time Comparison:
    
    - Handle columns name with with spaces
---
 superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx    | 2 +-
 superset-frontend/plugins/plugin-chart-table/src/consts.ts         | 2 ++
 superset-frontend/plugins/plugin-chart-table/src/transformProps.ts | 3 +--
 3 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
index d4d5de970a..0fca8cfd79 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
@@ -431,7 +431,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
       // Check if element's label is one of the comparison labels
       if (comparisonLabels.includes(element.label)) {
         // Extract the key portion after the space, assuming the format is always "label key"
-        const keyPortion = element.key.split(' ')[1];
+        const keyPortion = element.key.substring(element.label.length);
 
         // If the key portion is not in the map, initialize it with the current index
         if (!resultMap[keyPortion]) {
diff --git a/superset-frontend/plugins/plugin-chart-table/src/consts.ts b/superset-frontend/plugins/plugin-chart-table/src/consts.ts
index e370c4b029..1f173be403 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/consts.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/consts.ts
@@ -30,3 +30,5 @@ export const PAGE_SIZE_OPTIONS = formatSelectOptions<number>([
   100,
   200,
 ]);
+
+export const COMPARISON_PREFIX = 'prev_';
diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
index e36684baff..00ff94504f 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
@@ -47,12 +47,11 @@ import {
   TableChartProps,
   TableChartTransformedProps,
 } from './types';
+import { COMPARISON_PREFIX } from './consts';
 
 const { PERCENT_3_POINT } = NumberFormats;
 const { DATABASE_DATETIME } = TimeFormats;
 
-const COMPARISON_PREFIX = 'prev_';
-
 function isNumeric(key: string, data: DataRecord[] = []) {
   return data.every(
     x => x[key] === null || x[key] === undefined || typeof x[key] === 'number',


(superset) 05/09: Table with Time Comparison:

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 82fe8f5223e313d738f869b38fbd4927881936c2
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Mon Mar 4 18:12:57 2024 +0100

    Table with Time Comparison:
    
    - Add colums separators to better identify comparison groups
---
 .../plugin-chart-table/src/DataTable/DataTable.tsx | 41 +----------
 .../plugins/plugin-chart-table/src/Styles.tsx      |  8 +++
 .../plugins/plugin-chart-table/src/TableChart.tsx  | 79 ++++++++++++++++++++--
 3 files changed, 83 insertions(+), 45 deletions(-)

diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx
index a0af54eb6a..79ab44981e 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx
@@ -67,7 +67,7 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
   rowCount: number;
   wrapperRef?: MutableRefObject<HTMLDivElement>;
   onColumnOrderChange: () => void;
-  groupHeaderColumns?: Record<string, number[]>;
+  renderGroupingHeaders?: () => JSX.Element;
 }
 
 export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> {
@@ -100,7 +100,7 @@ export default typedMemo(function DataTable<D extends object>({
   serverPagination,
   wrapperRef: userWrapperRef,
   onColumnOrderChange,
-  groupHeaderColumns,
+  renderGroupingHeaders,
   ...moreUseTableOptions
 }: DataTableProps<D>): JSX.Element {
   const tableHooks: PluginHook<D>[] = [
@@ -250,46 +250,11 @@ export default typedMemo(function DataTable<D extends object>({
     e.preventDefault();
   };
 
-  const renderDynamicHeaders = () => {
-    // TODO: Make use of ColumnGroup to render the aditional headers
-    const headers: any = [];
-    let currentColumnIndex = 0;
-
-    Object.entries(groupHeaderColumns || {}).forEach(([key, value], index) => {
-      // Calculate the number of placeholder columns needed before the current header
-      const startPosition = value[0];
-      const colSpan = value.length;
-
-      // Add placeholder <th> for columns before this header
-      for (let i = currentColumnIndex; i < startPosition; i += 1) {
-        headers.push(
-          <th
-            key={`placeholder-${i}`}
-            style={{ borderBottom: 0 }}
-            aria-label={`Header-${i}`}
-          />,
-        );
-      }
-
-      // Add the current header <th>
-      headers.push(
-        <th key={`header-${key}`} colSpan={colSpan} style={{ borderBottom: 0 }}>
-          {key}
-        </th>,
-      );
-
-      // Update the current column index
-      currentColumnIndex = startPosition + colSpan;
-    });
-
-    return headers;
-  };
-
   const renderTable = () => (
     <table {...getTableProps({ className: tableClassName })}>
       <thead>
         {/* Render dynamic headers based on resultMap */}
-        {groupHeaderColumns ? <tr>{renderDynamicHeaders()}</tr> : null}
+        {renderGroupingHeaders ? renderGroupingHeaders() : null}
         {headerGroups.map(headerGroup => {
           const { key: headerGroupKey, ...headerGroupProps } =
             headerGroup.getHeaderGroupProps();
diff --git a/superset-frontend/plugins/plugin-chart-table/src/Styles.tsx b/superset-frontend/plugins/plugin-chart-table/src/Styles.tsx
index 9219b6f003..ba03a83614 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/Styles.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/Styles.tsx
@@ -111,5 +111,13 @@ export default styled.div`
       text-align: center;
       padding: 1em 0.6em;
     }
+
+    .right-border-only {
+      border-right: 2px solid ${theme.colors.grayscale.light2};
+    }
+
+    table .right-border-only:last-child {
+      border-right: none;
+    }
   `}
 `;
diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
index 0fca8cfd79..a41001a446 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
@@ -48,6 +48,7 @@ import {
   css,
   t,
   tn,
+  useTheme,
 } from '@superset-ui/core';
 
 import { isEmpty } from 'lodash';
@@ -251,6 +252,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
   });
   // keep track of whether column order changed, so that column widths can too
   const [columnOrderToggle, setColumnOrderToggle] = useState(false);
+  const theme = useTheme();
 
   // only take relevant page size options
   const pageSizeOptions = useMemo(() => {
@@ -446,6 +448,62 @@ export default function TableChart<D extends DataRecord = DataRecord>(
     return resultMap;
   };
 
+  const renderGroupingHeaders = (): JSX.Element => {
+    // TODO: Make use of ColumnGroup to render the aditional headers
+    const headers: any = [];
+    let currentColumnIndex = 0;
+
+    Object.entries(groupHeaderColumns || {}).forEach(([key, value]) => {
+      // Calculate the number of placeholder columns needed before the current header
+      const startPosition = value[0];
+      const colSpan = value.length;
+
+      // Add placeholder <th> for columns before this header
+      for (let i = currentColumnIndex; i < startPosition; i += 1) {
+        headers.push(
+          <th
+            key={`placeholder-${i}`}
+            style={{ borderBottom: 0 }}
+            aria-label={`Header-${i}`}
+          />,
+        );
+      }
+
+      // Add the current header <th>
+      headers.push(
+        <th key={`header-${key}`} colSpan={colSpan} style={{ borderBottom: 0 }}>
+          {key}
+        </th>,
+      );
+
+      // Update the current column index
+      currentColumnIndex = startPosition + colSpan;
+    });
+
+    return (
+      <tr
+        css={css`
+          th {
+            border-right: 2px solid ${theme.colors.grayscale.light2};
+          }
+          th:first-child {
+            border-left: none;
+          }
+          th:last-child {
+            border-right: none;
+          }
+        `}
+      >
+        {headers}
+      </tr>
+    );
+  };
+
+  const groupHeaderColumns = useMemo(
+    () => getHeaderColumns(columnsMeta, enableTimeComparison),
+    [columnsMeta, enableTimeComparison],
+  );
+
   const getColumnConfigs = useCallback(
     (column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
       const {
@@ -493,6 +551,18 @@ export default function TableChart<D extends DataRecord = DataRecord>(
         className += ' dt-is-filter';
       }
 
+      if (enableTimeComparison) {
+        if (!isMetric && !isPercentMetric) {
+          className += ' right-border-only';
+        } else if (comparisonLabels.includes(label)) {
+          const groupinHeader = key.substring(label.length);
+          const columnsUnderHeader = groupHeaderColumns[groupinHeader] || [];
+          if (i === columnsUnderHeader[columnsUnderHeader.length - 1]) {
+            className += ' right-border-only';
+          }
+        }
+      }
+
       return {
         id: String(i), // to allow duplicate column keys
         // must use custom accessor to allow `.` in column names
@@ -704,11 +774,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
     [columnsMeta, getColumnConfigs],
   );
 
-  const groupHeaderColumns = useMemo(
-    () => getHeaderColumns(columnsMeta, enableTimeComparison),
-    [columnsMeta, enableTimeComparison],
-  );
-
   const handleServerPaginationChange = useCallback(
     (pageNumber: number, pageSize: number) => {
       updateExternalFormData(setDataMask, pageNumber, pageSize);
@@ -773,8 +838,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
         selectPageSize={pageSize !== null && SelectPageSize}
         // not in use in Superset, but needed for unit tests
         sticky={sticky}
-        groupHeaderColumns={
-          !isEmpty(groupHeaderColumns) ? groupHeaderColumns : undefined
+        renderGroupingHeaders={
+          !isEmpty(groupHeaderColumns) ? renderGroupingHeaders : undefined
         }
       />
     </Styles>


(superset) 07/09: Table with Time Comparison:

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 0d4bad4979a73199b37074ff36521dc38b84bf05
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Mon Mar 4 23:07:00 2024 +0100

    Table with Time Comparison:
    
    - Add comments to help navigate the new JOIN query operation ofr time_comparison
---
 superset/connectors/sqla/models.py | 49 ++++++++++++++++++++++++++++----------
 1 file changed, 37 insertions(+), 12 deletions(-)

diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 6259628d5b..a872f75553 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -1438,12 +1438,25 @@ class SqlaTable(
         mutate: bool,
         instant_time_comparison_info: dict[str, Any],
     ) -> tuple[str, list[str]]:
+        """
+        Main goal of this method is to create a JOIN between a given query object and
+        other that shifts the time filters. This is different from time_offsets because
+        we are not joining result sets but rather we're applying the JOIN at query level.
+        Use case: Compare paginated data in a Table Chart. But ideally can be leveraged by
+        anything that needs the experimental instant time comparison.
+        """
+        # So we don't override the original QueryObject
         query_obj_clone = copy.copy(query_obj)
         final_query_sql = ""
+        # The inner query object doesn't need limits or offset
         query_obj_clone["row_limit"] = None
         query_obj_clone["row_offset"] = None
+        # Let's get what range should we be using when building the time_comparison shift
+        # This is computing the time_shift based on some predefined options of deltas
         instant_time_comparison_range = instant_time_comparison_info.get("range")
         if instant_time_comparison_range == InstantTimeComparison.CUSTOM:
+            # If it's a custom filter, we take the 1st temporal filter and change it with
+            # whatever value we received in the request as the custom filter.
             custom_filter = instant_time_comparison_info.get("filter", {})
             temporal_filters = [
                 filter["col"]
@@ -1462,23 +1475,28 @@ class SqlaTable(
             new_filters = temporal_filters + non_temporal_filters
             query_obj_clone["filter"] = new_filters
         if instant_time_comparison_range != InstantTimeComparison.CUSTOM:
+            # When not custom, we're supposed to use the predefined time ranges
+            # Year, Month, Week or Inherited
             query_obj_clone["extras"] = {
                 **query_obj_clone.get("extras", {}),
                 "instant_time_comparison_range": instant_time_comparison_range,
             }
-        sqlaq_2 = self.get_sqla_query(**query_obj_clone)
+        shifted_sqlaq = self.get_sqla_query(**query_obj_clone)
+        # We JOIN only over columns, not metrics or anything else since those cannot be
+        # joined
         join_columns = query_obj_clone.get("columns") or []
-        sqla_query_a = sqlaq.sqla_query
-        sqla_query_b = sqlaq_2.sqla_query
-        sqla_query_b_subquery = sqla_query_b.subquery()
-        query_a_cte = sqla_query_a.cte("query_a_results")
-        column_names_a = [column.key for column in sqla_query_a.c]
+        original_query_a = sqlaq.sqla_query
+        shifted_query_b = shifted_sqlaq.sqla_query
+        shifted_query_b_subquery = shifted_query_b.subquery()
+        query_a_cte = original_query_a.cte("query_a_results")
+        column_names_a = [column.key for column in original_query_a.c]
         exclude_columns_b = set(query_obj_clone.get("columns") or [])
+        # Let's prepare the columns set to be used in query A and B
         selected_columns_a = [query_a_cte.c[col].label(col) for col in column_names_a]
         # Renamed columns from Query B (with "prev_" prefix)
         selected_columns_b = [
-            sqla_query_b_subquery.c[col].label(f"prev_{col}")
-            for col in sqla_query_b_subquery.c.keys()
+            shifted_query_b_subquery.c[col].label(f"prev_{col}")
+            for col in shifted_query_b_subquery.c.keys()
             if col not in exclude_columns_b
         ]
         # Combine selected columns from both queries
@@ -1486,25 +1504,30 @@ class SqlaTable(
         if join_columns and not query_obj_clone.get("is_rowcount"):
             # Proceed with JOIN operation as before since join_columns is not empty
             join_conditions = [
-                sqla_query_b_subquery.c[col] == query_a_cte.c[col]
+                shifted_query_b_subquery.c[col] == query_a_cte.c[col]
                 for col in join_columns
-                if col in sqla_query_b_subquery.c and col in query_a_cte.c
+                if col in shifted_query_b_subquery.c and col in query_a_cte.c
             ]
             final_query = sa.select(*final_selected_columns).select_from(
-                sqla_query_b_subquery.join(query_a_cte, sa.and_(*join_conditions))
+                shifted_query_b_subquery.join(query_a_cte, sa.and_(*join_conditions))
             )
         else:
+            # When dealing with queries that have no columns or that are totals,
+            # rowcounts etc we join with the 1 = 1 to create a result set that have
+            # both sets (original and prev)
             final_query = sa.select(*final_selected_columns).select_from(
-                sqla_query_b_subquery.join(
+                shifted_query_b_subquery.join(
                     query_a_cte, sa.literal(True) == sa.literal(True)
                 )
             )
+        # Transform the query as you would within get_query_str_extended
         final_query_sql = self.database.compile_sqla_query(final_query)
         final_query_sql = self._apply_cte(final_query_sql, sqlaq.cte)
         final_query_sql = sqlparse.format(final_query_sql, reindent=True)
         if mutate:
             final_query_sql = self.mutate_query_from_config(final_query_sql)
 
+        # Prepare the labels for the columns to be used
         labels_expected = self.extract_column_names(final_selected_columns)
 
         return final_query_sql, labels_expected
@@ -1520,6 +1543,8 @@ class SqlaTable(
         sql = self._apply_cte(sql, sqlaq.cte)
         sql = sqlparse.format(sql, reindent=True)
 
+        # Need to tell apart the regular queries from the ones that need
+        # Time comparison
         query_obj_clone = copy.copy(query_obj)
         query_object_extras: dict[str, Any] = query_obj.get("extras", {})
         instant_time_comparison_info = query_object_extras.get(


(superset) 09/09: Table with Time Comparison:

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 6c11355037d7f9ab9b34cbd5c361277bb385eaeb
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Thu Mar 7 18:02:25 2024 +0100

    Table with Time Comparison:
    
    - Fix table rendering when switching from Agg mode to Raw mode and viceversa
---
 .../plugins/plugin-chart-table/src/buildQuery.ts      |  5 +++--
 .../plugins/plugin-chart-table/src/controlPanel.tsx   | 19 ++++++++++++++-----
 .../plugins/plugin-chart-table/src/transformProps.ts  |  3 ++-
 3 files changed, 19 insertions(+), 8 deletions(-)

diff --git a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
index f892d2fe94..e90eafeebe 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
@@ -64,10 +64,11 @@ const buildQuery: BuildQuery<TableChartFormData> = (
     time_comparison: timeComparison,
     enable_time_comparison,
   } = formData;
+  const queryMode = getQueryMode(formData);
   const canUseTimeComparison =
     enable_time_comparison &&
-    isFeatureEnabled(FeatureFlag.ChartPluginsExperimental);
-  const queryMode = getQueryMode(formData);
+    isFeatureEnabled(FeatureFlag.ChartPluginsExperimental) &&
+    queryMode === QueryMode.Aggregate;
   const sortByMetric = ensureIsArray(formData.timeseries_limit_metric)[0];
   const time_grain_sqla =
     extra_form_data?.time_grain_sqla || formData.time_grain_sqla;
diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
index 78a6a1eeab..cf0ad3d952 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
@@ -94,7 +94,13 @@ const queryMode: ControlConfig<'RadioButtonControl'> = {
     [QueryMode.Raw, QueryModeLabel[QueryMode.Raw]],
   ],
   mapStateToProps: ({ controls }) => ({ value: getQueryMode(controls) }),
-  rerender: ['all_columns', 'groupby', 'metrics', 'percent_metrics'],
+  rerender: [
+    'all_columns',
+    'groupby',
+    'metrics',
+    'percent_metrics',
+    'enable_time_comparison',
+  ],
 };
 
 const allColumnsControl: typeof sharedControls.groupby = {
@@ -269,8 +275,9 @@ const config: ControlPanelConfig = {
               label: t('Enable Time Comparison'),
               description: t('Enable time comparison (experimental feature)'),
               default: false,
-              visibility: () =>
-                isFeatureEnabled(FeatureFlag.ChartPluginsExperimental),
+              visibility: ({ controls }) =>
+                isFeatureEnabled(FeatureFlag.ChartPluginsExperimental) &&
+                isAggMode({ controls }),
             },
           },
         ],
@@ -297,7 +304,8 @@ const config: ControlPanelConfig = {
               ),
               visibility: ({ controls }) =>
                 Boolean(controls?.enable_time_comparison?.value) &&
-                isFeatureEnabled(FeatureFlag.ChartPluginsExperimental),
+                isFeatureEnabled(FeatureFlag.ChartPluginsExperimental) &&
+                isAggMode({ controls }),
             },
           },
         ],
@@ -312,7 +320,8 @@ const config: ControlPanelConfig = {
               visibility: ({ controls }) =>
                 Boolean(controls?.enable_time_comparison?.value) &&
                 controls?.time_comparison?.value ===
-                  ComparisonTimeRangeType.Custom,
+                  ComparisonTimeRangeType.Custom &&
+                isAggMode({ controls }),
               mapStateToProps: (
                 state: ControlPanelState,
                 controlState: ControlState,
diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
index 00ff94504f..64fb198016 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
@@ -377,7 +377,8 @@ const transformProps = (
   } = formData;
   const canUseTimeComparison =
     enableTimeComparison &&
-    isFeatureEnabled(FeatureFlag.ChartPluginsExperimental);
+    isFeatureEnabled(FeatureFlag.ChartPluginsExperimental) &&
+    queryMode === QueryMode.Aggregate;
   const timeGrain = extractTimegrain(formData);
 
   const [metrics, percentMetrics, columns] = processColumns(chartProps);


(superset) 03/09: Table with Time Comparison:

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit aa74402839407ac8fe07320245b66528de66b8ef
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Fri Mar 1 16:56:08 2024 +0100

    Table with Time Comparison:
    
    - Stop using the new instant_time_comparison_info as a direct propery of queryObject. Put it in the extras
---
 .../superset-ui-core/src/query/types/Query.ts         |  4 +---
 .../plugins/plugin-chart-table/src/buildQuery.ts      |  4 ++--
 superset/charts/schemas.py                            | 19 +++++++++++--------
 superset/common/query_object.py                       |  4 ----
 superset/connectors/sqla/models.py                    | 11 +++++++----
 tests/unit_tests/connectors/test_models.py            | 15 +++++++++------
 tests/unit_tests/queries/query_object_test.py         |  1 -
 7 files changed, 30 insertions(+), 28 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
index db3a090dd6..83b90253eb 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
@@ -71,6 +71,7 @@ export type QueryObjectExtras = Partial<{
   where?: string;
   /** Instant Time Comparison */
   instant_time_comparison_range?: string;
+  instant_time_comparison_info?: QueryObjectInstantTimeComparisonInfo;
 }>;
 
 export type ResidualQueryObjectData = {
@@ -156,9 +157,6 @@ export interface QueryObject
   series_columns?: QueryFormColumn[];
   series_limit?: number;
   series_limit_metric?: Maybe<QueryFormMetric>;
-
-  /** Instant Time Comparison */
-  instant_time_comparison_info?: QueryObjectInstantTimeComparisonInfo;
 }
 
 export interface QueryContext {
diff --git a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
index 9e93c268a4..f892d2fe94 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
@@ -183,8 +183,8 @@ const buildQuery: BuildQuery<TableChartFormData> = (
 
     // Customize the query for time comparison
     if (canUseTimeComparison) {
-      queryObject = {
-        ...queryObject,
+      queryObject.extras = {
+        ...queryObject.extras,
         instant_time_comparison_info: {
           range: timeComparison,
           filter:
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index 34731af571..b5563a3446 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -1008,6 +1008,17 @@ class ChartDataExtrasSchema(Schema):
         },
         allow_none=True,
     )
+    instant_time_comparison_info = fields.Nested(
+        # TODO: The instant_time_comparison_range must be deleted in favor of this one
+        # once we have the DATEDIFF in place. Keeping for backward compatibility in the
+        # meantime.
+        InstantTimeComparisonInfoSchema,
+        metadata={
+            "description": "Extra parameters to use instant time comparison"
+            " with JOINs using a single query"
+        },
+        allow_none=True,
+    )
 
 
 class AnnotationLayerSchema(Schema):
@@ -1360,14 +1371,6 @@ class ChartDataQueryObjectSchema(Schema):
         fields.String(),
         allow_none=True,
     )
-    instant_time_comparison_info = fields.Nested(
-        InstantTimeComparisonInfoSchema,
-        metadata={
-            "description": "Extra parameters to use instant time comparison"
-            " with JOINs using a single query"
-        },
-        allow_none=True,
-    )
 
 
 class ChartDataQueryContextSchema(Schema):
diff --git a/superset/common/query_object.py b/superset/common/query_object.py
index 77f3a08ce8..5109c465e0 100644
--- a/superset/common/query_object.py
+++ b/superset/common/query_object.py
@@ -107,7 +107,6 @@ class QueryObject:  # pylint: disable=too-many-instance-attributes
     time_shift: str | None
     time_range: str | None
     to_dttm: datetime | None
-    instant_time_comparison_info: dict[str, Any] | None
 
     def __init__(  # pylint: disable=too-many-locals
         self,
@@ -133,7 +132,6 @@ class QueryObject:  # pylint: disable=too-many-instance-attributes
         series_limit_metric: Metric | None = None,
         time_range: str | None = None,
         time_shift: str | None = None,
-        instant_time_comparison_info: dict[str, Any] | None = None,
         **kwargs: Any,
     ):
         self._set_annotation_layers(annotation_layers)
@@ -163,7 +161,6 @@ class QueryObject:  # pylint: disable=too-many-instance-attributes
         self.time_offsets = kwargs.get("time_offsets", [])
         self.inner_from_dttm = kwargs.get("inner_from_dttm")
         self.inner_to_dttm = kwargs.get("inner_to_dttm")
-        self.instant_time_comparison_info = instant_time_comparison_info
         self._rename_deprecated_fields(kwargs)
         self._move_deprecated_extra_fields(kwargs)
 
@@ -338,7 +335,6 @@ class QueryObject:  # pylint: disable=too-many-instance-attributes
             "series_limit_metric": self.series_limit_metric,
             "to_dttm": self.to_dttm,
             "time_shift": self.time_shift,
-            "instant_time_comparison_info": self.instant_time_comparison_info,
         }
         return query_object_dict
 
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index b6e14ce62e..6259628d5b 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -1515,14 +1515,17 @@ class SqlaTable(
         mutate: bool = True,
     ) -> QueryStringExtended:
         # So we don't mutate the original query_obj
-        query_obj_clone = copy.copy(query_obj)
-        instant_time_comparison_info = query_obj.get("instant_time_comparison_info")
-        query_obj_clone.pop("instant_time_comparison_info", None)
-        sqlaq = self.get_sqla_query(**query_obj_clone)
+        sqlaq = self.get_sqla_query(**query_obj)
         sql = self.database.compile_sqla_query(sqlaq.sqla_query)
         sql = self._apply_cte(sql, sqlaq.cte)
         sql = sqlparse.format(sql, reindent=True)
 
+        query_obj_clone = copy.copy(query_obj)
+        query_object_extras: dict[str, Any] = query_obj.get("extras", {})
+        instant_time_comparison_info = query_object_extras.get(
+            "instant_time_comparison_info", {}
+        )
+
         if mutate:
             sql = self.mutate_query_from_config(sql)
 
diff --git a/tests/unit_tests/connectors/test_models.py b/tests/unit_tests/connectors/test_models.py
index cf179c9dfa..1a176f4860 100644
--- a/tests/unit_tests/connectors/test_models.py
+++ b/tests/unit_tests/connectors/test_models.py
@@ -73,7 +73,13 @@ class TestInstantTimeComparisonQueryGeneration:
         return {
             "apply_fetch_values_predicate": False,
             "columns": ["name"],
-            "extras": {"having": "", "where": ""},
+            "extras": {
+                "having": "",
+                "where": "",
+                "instant_time_comparison_info": {
+                    "range": "y",
+                },
+            },
             "filter": [
                 {"op": "TEMPORAL_RANGE", "val": "1984-01-01 : 2024-02-14", "col": "ds"}
             ],
@@ -132,9 +138,6 @@ class TestInstantTimeComparisonQueryGeneration:
                     "sqlExpression": None,
                 },
             ],
-            "instant_time_comparison_info": {
-                "range": "y",
-            },
         }
 
     @with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=True)
@@ -256,7 +259,7 @@ class TestInstantTimeComparisonQueryGeneration:
     def test_creates_query_without_time_comparison(session: Session):
         table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
         query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
-        query_obj["instant_time_comparison_info"] = None
+        query_obj["extras"]["instant_time_comparison_info"] = None
         str = table.get_query_str_extended(query_obj)
         expected_str = """
             SELECT name AS name,
@@ -279,7 +282,7 @@ class TestInstantTimeComparisonQueryGeneration:
     def test_creates_time_comparison_query_custom_filters(session: Session):
         table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
         query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
-        query_obj["instant_time_comparison_info"] = {
+        query_obj["extras"]["instant_time_comparison_info"] = {
             "range": "c",
             "filter": {
                 "op": "TEMPORAL_RANGE",
diff --git a/tests/unit_tests/queries/query_object_test.py b/tests/unit_tests/queries/query_object_test.py
index f90ab8255d..81a654653f 100644
--- a/tests/unit_tests/queries/query_object_test.py
+++ b/tests/unit_tests/queries/query_object_test.py
@@ -47,7 +47,6 @@ def test_default_query_object_to_dict():
         "granularity": None,
         "inner_from_dttm": None,
         "inner_to_dttm": None,
-        "instant_time_comparison_info": None,
         "is_rowcount": False,
         "is_timeseries": False,
         "metrics": None,


(superset) 02/09: Table with Time Comparison:

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit c3d04b3fa870cd8338edbcd4888bf491e5fac57d
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Fri Mar 1 14:41:35 2024 +0100

    Table with Time Comparison:
    
    - Using one single query with some new properties added to QueryObject so we generate the comparison data instead of two queries
    - Use joins when generating the comparison query
    - Add time comparison control to Table chart
    - Render Time comparison metrics in Table chart
    - Render header with column name on top of each group of 4 metrics columns
    - Modify useSticky to consider multiple rows of headers when computing the columns widths
    - Add tests for new query building function
---
 .../superset-ui-core/src/query/types/Query.ts      |  10 +
 .../src/query/types/QueryResponse.ts               |   1 +
 .../plugin-chart-table/src/DataTable/DataTable.tsx |  45 ++-
 .../src/DataTable/hooks/useSticky.tsx              |   4 +-
 .../plugins/plugin-chart-table/src/TableChart.tsx  |  42 +++
 .../plugins/plugin-chart-table/src/buildQuery.ts   |  58 +++-
 .../plugin-chart-table/src/controlPanel.tsx        |  72 ++++
 .../plugin-chart-table/src/transformProps.ts       | 165 ++++++++-
 .../plugins/plugin-chart-table/src/types.ts        |   2 +
 .../plugin-chart-table/src/utils/isEqualColumns.ts |   3 +-
 .../plugins/plugin-chart-table/test/testData.ts    |   1 +
 superset/charts/schemas.py                         |  20 +-
 superset/common/query_context_processor.py         |   3 +
 superset/common/query_object.py                    |   4 +
 superset/connectors/sqla/models.py                 | 126 ++++++-
 superset/constants.py                              |   1 +
 tests/unit_tests/connectors/__init__.py            |  16 +
 tests/unit_tests/connectors/test_models.py         | 383 +++++++++++++++++++++
 tests/unit_tests/queries/query_object_test.py      |   1 +
 19 files changed, 942 insertions(+), 15 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
index 718f10514c..db3a090dd6 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
@@ -77,6 +77,13 @@ export type ResidualQueryObjectData = {
   [key: string]: unknown;
 };
 
+export type QueryObjectInstantTimeComparisonInfo = {
+  /** The range to use as comparison range */
+  range: string;
+  /** The custom filter value to use if range is Custom */
+  filter?: QueryObjectFilterClause;
+};
+
 /**
  * Query object directly compatible with the new chart data API.
  * A stricter version of query form data.
@@ -149,6 +156,9 @@ export interface QueryObject
   series_columns?: QueryFormColumn[];
   series_limit?: number;
   series_limit_metric?: Maybe<QueryFormMetric>;
+
+  /** Instant Time Comparison */
+  instant_time_comparison_info?: QueryObjectInstantTimeComparisonInfo;
 }
 
 export interface QueryContext {
diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts b/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts
index 1705814df1..d910e9a778 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts
@@ -78,6 +78,7 @@ export interface ChartDataResponseResult {
     | 'timed_out';
   from_dttm: number | null;
   to_dttm: number | null;
+  instant_time_comparison_range: string | null;
 }
 
 export interface TimeseriesChartDataResponseResult
diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx
index 6c5123806f..a0af54eb6a 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx
@@ -67,6 +67,7 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
   rowCount: number;
   wrapperRef?: MutableRefObject<HTMLDivElement>;
   onColumnOrderChange: () => void;
+  groupHeaderColumns?: Record<string, number[]>;
 }
 
 export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> {
@@ -99,6 +100,7 @@ export default typedMemo(function DataTable<D extends object>({
   serverPagination,
   wrapperRef: userWrapperRef,
   onColumnOrderChange,
+  groupHeaderColumns,
   ...moreUseTableOptions
 }: DataTableProps<D>): JSX.Element {
   const tableHooks: PluginHook<D>[] = [
@@ -248,14 +250,55 @@ export default typedMemo(function DataTable<D extends object>({
     e.preventDefault();
   };
 
+  const renderDynamicHeaders = () => {
+    // TODO: Make use of ColumnGroup to render the aditional headers
+    const headers: any = [];
+    let currentColumnIndex = 0;
+
+    Object.entries(groupHeaderColumns || {}).forEach(([key, value], index) => {
+      // Calculate the number of placeholder columns needed before the current header
+      const startPosition = value[0];
+      const colSpan = value.length;
+
+      // Add placeholder <th> for columns before this header
+      for (let i = currentColumnIndex; i < startPosition; i += 1) {
+        headers.push(
+          <th
+            key={`placeholder-${i}`}
+            style={{ borderBottom: 0 }}
+            aria-label={`Header-${i}`}
+          />,
+        );
+      }
+
+      // Add the current header <th>
+      headers.push(
+        <th key={`header-${key}`} colSpan={colSpan} style={{ borderBottom: 0 }}>
+          {key}
+        </th>,
+      );
+
+      // Update the current column index
+      currentColumnIndex = startPosition + colSpan;
+    });
+
+    return headers;
+  };
+
   const renderTable = () => (
     <table {...getTableProps({ className: tableClassName })}>
       <thead>
+        {/* Render dynamic headers based on resultMap */}
+        {groupHeaderColumns ? <tr>{renderDynamicHeaders()}</tr> : null}
         {headerGroups.map(headerGroup => {
           const { key: headerGroupKey, ...headerGroupProps } =
             headerGroup.getHeaderGroupProps();
           return (
-            <tr key={headerGroupKey || headerGroup.id} {...headerGroupProps}>
+            <tr
+              key={headerGroupKey || headerGroup.id}
+              {...headerGroupProps}
+              style={{ borderTop: 0 }}
+            >
               {headerGroup.headers.map(column =>
                 column.render('Header', {
                   key: column.id,
diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx
index ba3466bb40..1e56987486 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx
@@ -181,7 +181,9 @@ function StickyWrap({
     }
     const fullTableHeight = (bodyThead.parentNode as HTMLTableElement)
       .clientHeight;
-    const ths = bodyThead.childNodes[0]
+    // instead of always using the first tr, we use the last one to support
+    // multi-level headers assuming the last one is the more detailed one
+    const ths = bodyThead.childNodes?.[bodyThead.childNodes?.length - 1 || 0]
       .childNodes as NodeListOf<HTMLTableHeaderCellElement>;
     const widths = Array.from(ths).map(
       th => th.getBoundingClientRect()?.width || th.clientWidth,
diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
index 840020cad8..d4d5de970a 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
@@ -50,6 +50,7 @@ import {
   tn,
 } from '@superset-ui/core';
 
+import { isEmpty } from 'lodash';
 import { DataColumnMeta, TableChartTransformedProps } from './types';
 import DataTable, {
   DataTableProps,
@@ -238,6 +239,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
     allowRearrangeColumns = false,
     onContextMenu,
     emitCrossFilters,
+    enableTimeComparison,
   } = props;
   const timestampFormatter = useCallback(
     value => getTimeFormatterForGranularity(timeGrain)(value),
@@ -413,6 +415,37 @@ export default function TableChart<D extends DataRecord = DataRecord>(
         }
       : undefined;
 
+  const comparisonLabels = [t('Main'), '#', '△', '%'];
+
+  const getHeaderColumns = (
+    columnsMeta: DataColumnMeta[],
+    enableTimeComparison?: boolean,
+  ) => {
+    const resultMap: Record<string, number[]> = {};
+
+    if (!enableTimeComparison) {
+      return resultMap;
+    }
+
+    columnsMeta.forEach((element, index) => {
+      // Check if element's label is one of the comparison labels
+      if (comparisonLabels.includes(element.label)) {
+        // Extract the key portion after the space, assuming the format is always "label key"
+        const keyPortion = element.key.split(' ')[1];
+
+        // If the key portion is not in the map, initialize it with the current index
+        if (!resultMap[keyPortion]) {
+          resultMap[keyPortion] = [index];
+        } else {
+          // Add the index to the existing array
+          resultMap[keyPortion].push(index);
+        }
+      }
+    });
+
+    return resultMap;
+  };
+
   const getColumnConfigs = useCallback(
     (column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
       const {
@@ -596,6 +629,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
             style={{
               ...sharedStyle,
               ...style,
+              borderTop: 0,
             }}
             tabIndex={0}
             onKeyDown={(e: React.KeyboardEvent<HTMLElement>) => {
@@ -670,6 +704,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
     [columnsMeta, getColumnConfigs],
   );
 
+  const groupHeaderColumns = useMemo(
+    () => getHeaderColumns(columnsMeta, enableTimeComparison),
+    [columnsMeta, enableTimeComparison],
+  );
+
   const handleServerPaginationChange = useCallback(
     (pageNumber: number, pageSize: number) => {
       updateExternalFormData(setDataMask, pageNumber, pageSize);
@@ -734,6 +773,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
         selectPageSize={pageSize !== null && SelectPageSize}
         // not in use in Superset, but needed for unit tests
         sticky={sticky}
+        groupHeaderColumns={
+          !isEmpty(groupHeaderColumns) ? groupHeaderColumns : undefined
+        }
       />
     </Styles>
   );
diff --git a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
index 69631a5f35..9e93c268a4 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
@@ -19,11 +19,17 @@
 import {
   AdhocColumn,
   buildQueryContext,
+  buildQueryObject,
+  ComparisonTimeRangeType,
   ensureIsArray,
+  FeatureFlag,
+  getComparisonInfo,
   getMetricLabel,
+  isFeatureEnabled,
   isPhysicalColumn,
   QueryMode,
   QueryObject,
+  QueryObjectFilterClause,
   removeDuplicates,
 } from '@superset-ui/core';
 import { PostProcessingRule } from '@superset-ui/core/src/query/types/PostProcessing';
@@ -55,7 +61,12 @@ const buildQuery: BuildQuery<TableChartFormData> = (
     percent_metrics: percentMetrics,
     order_desc: orderDesc = false,
     extra_form_data,
+    time_comparison: timeComparison,
+    enable_time_comparison,
   } = formData;
+  const canUseTimeComparison =
+    enable_time_comparison &&
+    isFeatureEnabled(FeatureFlag.ChartPluginsExperimental);
   const queryMode = getQueryMode(formData);
   const sortByMetric = ensureIsArray(formData.timeseries_limit_metric)[0];
   const time_grain_sqla =
@@ -69,6 +80,34 @@ const buildQuery: BuildQuery<TableChartFormData> = (
     };
   }
 
+  const addComparisonPercentMetrics = (metrics: string[]) =>
+    metrics.reduce((acc, metric) => {
+      const prevMetric = `prev_${metric}`;
+      return acc.concat([metric, prevMetric]);
+    }, [] as string[]);
+
+  const comparisonFormData = getComparisonInfo(
+    formDataCopy,
+    timeComparison,
+    extra_form_data,
+  );
+
+  const getFirstTemporalFilter = (
+    queryObject?: QueryObject,
+  ): QueryObjectFilterClause | undefined => {
+    const { filters = [] } = queryObject || {};
+    const timeFilterIndex: number =
+      filters?.findIndex(
+        filter => 'op' in filter && filter.op === 'TEMPORAL_RANGE',
+      ) ?? -1;
+
+    const timeFilter: QueryObjectFilterClause | undefined =
+      timeFilterIndex !== -1 && filters ? filters[timeFilterIndex] : undefined;
+    return timeFilter;
+  };
+  const comparisonQueryObject = buildQueryObject(comparisonFormData);
+  const firstTemporalFilter = getFirstTemporalFilter(comparisonQueryObject);
+
   return buildQueryContext(formDataCopy, baseQueryObject => {
     let { metrics, orderby = [], columns = [] } = baseQueryObject;
     let postProcessing: PostProcessingRule[] = [];
@@ -85,8 +124,11 @@ const buildQuery: BuildQuery<TableChartFormData> = (
       }
       // add postprocessing for percent metrics only when in aggregation mode
       if (percentMetrics && percentMetrics.length > 0) {
+        const percentMetricsLabelsWithTimeComparison = canUseTimeComparison
+          ? addComparisonPercentMetrics(percentMetrics.map(getMetricLabel))
+          : percentMetrics.map(getMetricLabel);
         const percentMetricLabels = removeDuplicates(
-          percentMetrics.map(getMetricLabel),
+          percentMetricsLabelsWithTimeComparison,
         );
         metrics = removeDuplicates(
           metrics.concat(percentMetrics),
@@ -139,6 +181,20 @@ const buildQuery: BuildQuery<TableChartFormData> = (
       ...moreProps,
     };
 
+    // Customize the query for time comparison
+    if (canUseTimeComparison) {
+      queryObject = {
+        ...queryObject,
+        instant_time_comparison_info: {
+          range: timeComparison,
+          filter:
+            timeComparison === ComparisonTimeRangeType.Custom
+              ? firstTemporalFilter
+              : undefined,
+        },
+      };
+    }
+
     if (
       formData.server_pagination &&
       options?.extras?.cachedChanges?.[formData.slice_id] &&
diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
index ad39b504cb..c7710c81df 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
@@ -20,14 +20,18 @@
 import React from 'react';
 import {
   ChartDataResponseResult,
+  ComparisonTimeRangeType,
   ensureIsArray,
+  FeatureFlag,
   GenericDataType,
   isAdhocColumn,
+  isFeatureEnabled,
   isPhysicalColumn,
   QueryFormColumn,
   QueryMode,
   smartDateFormatter,
   t,
+  validateTimeComparisonRangeValues,
 } from '@superset-ui/core';
 import {
   ColumnOption,
@@ -257,6 +261,74 @@ const config: ControlPanelConfig = {
           },
         ],
         ['adhoc_filters'],
+        [
+          {
+            name: 'enable_time_comparison',
+            config: {
+              type: 'CheckboxControl',
+              label: t('Enable Time Comparison'),
+              description: t('Enable time comparison (experimental feature)'),
+              default: false,
+              visibility: () =>
+                isFeatureEnabled(FeatureFlag.ChartPluginsExperimental),
+            },
+          },
+        ],
+        [
+          {
+            name: 'time_comparison',
+            config: {
+              type: 'SelectControl',
+              label: t('Range for Comparison'),
+              default: 'r',
+              choices: [
+                ['r', 'Inherit range from time filters'],
+                ['y', 'Year'],
+                ['m', 'Month'],
+                ['w', 'Week'],
+                ['c', 'Custom'],
+              ],
+              rerender: ['adhoc_custom'],
+              description: t(
+                'Set the time range that will be used for the comparison metrics. ' +
+                  'For example, "Year" will compare to the same dates one year earlier. ' +
+                  'Use "Inherit range from time filters" to shift the comparison time range' +
+                  'by the same length as your time range and use "Custom" to set a custom comparison range.',
+              ),
+              visibility: ({ controls }) =>
+                Boolean(controls?.enable_time_comparison?.value) &&
+                isFeatureEnabled(FeatureFlag.ChartPluginsExperimental),
+            },
+          },
+        ],
+        [
+          {
+            name: `adhoc_custom`,
+            config: {
+              ...sharedControls.adhoc_filters,
+              label: t('Filters for Comparison'),
+              description:
+                'This only applies when selecting the Range for Comparison Type: Custom',
+              visibility: ({ controls }) =>
+                Boolean(controls?.enable_time_comparison?.value) &&
+                controls?.time_comparison?.value ===
+                  ComparisonTimeRangeType.Custom,
+              mapStateToProps: (
+                state: ControlPanelState,
+                controlState: ControlState,
+              ) => ({
+                ...(sharedControls.adhoc_filters.mapStateToProps?.(
+                  state,
+                  controlState,
+                ) || {}),
+                externalValidationErrors: validateTimeComparisonRangeValues(
+                  state.controls?.time_comparison?.value,
+                  controlState.value,
+                ),
+              }),
+            },
+          },
+        ],
         [
           {
             name: 'timeseries_limit_metric',
diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
index 0a2a3449c6..e36684baff 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
@@ -21,14 +21,17 @@ import {
   CurrencyFormatter,
   DataRecord,
   extractTimegrain,
+  FeatureFlag,
   GenericDataType,
   getMetricLabel,
   getNumberFormatter,
   getTimeFormatter,
   getTimeFormatterForGranularity,
+  isFeatureEnabled,
   NumberFormats,
   QueryMode,
   smartDateFormatter,
+  t,
   TimeFormats,
   TimeFormatter,
 } from '@superset-ui/core';
@@ -48,6 +51,8 @@ import {
 const { PERCENT_3_POINT } = NumberFormats;
 const { DATABASE_DATETIME } = TimeFormats;
 
+const COMPARISON_PREFIX = 'prev_';
+
 function isNumeric(key: string, data: DataRecord[] = []) {
   return data.every(
     x => x[key] === null || x[key] === undefined || typeof x[key] === 'number',
@@ -81,6 +86,88 @@ const processDataRecords = memoizeOne(function processDataRecords(
   return data;
 });
 
+const calculateDifferences = (
+  originalValue: number,
+  comparisonValue: number,
+) => {
+  const valueDifference = originalValue - comparisonValue;
+  let percentDifferenceNum;
+  if (!originalValue && !comparisonValue) {
+    percentDifferenceNum = 0;
+  } else if (!originalValue || !comparisonValue) {
+    percentDifferenceNum = originalValue ? 1 : -1;
+  } else {
+    percentDifferenceNum =
+      (originalValue - comparisonValue) / Math.abs(comparisonValue);
+  }
+  return { valueDifference, percentDifferenceNum };
+};
+
+const processComparisonTotals = (totals: DataRecord | undefined) => {
+  if (!totals) {
+    return totals;
+  }
+  const transformedTotals: DataRecord = {};
+  Object.keys(totals).forEach(key => {
+    if (totals[key] !== undefined && !key.includes(COMPARISON_PREFIX)) {
+      transformedTotals[`Main ${key}`] = totals[key];
+      transformedTotals[`# ${key}`] = totals[`${COMPARISON_PREFIX}${key}`];
+      const { valueDifference, percentDifferenceNum } = calculateDifferences(
+        totals[key] as number,
+        totals[`${COMPARISON_PREFIX}${key}`] as number,
+      );
+      transformedTotals[`△ ${key}`] = valueDifference;
+      transformedTotals[`% ${key}`] = percentDifferenceNum;
+    }
+  });
+  return transformedTotals;
+};
+
+const processComparisonDataRecords = memoizeOne(
+  function processComparisonDataRecords(
+    originalData: DataRecord[] | undefined,
+    originalColumns: DataColumnMeta[],
+  ) {
+    // Transform data
+    return originalData?.map(originalItem => {
+      const transformedItem: DataRecord = {};
+      originalColumns.forEach(origCol => {
+        if (
+          (origCol.isMetric || origCol.isPercentMetric) &&
+          !origCol.key.includes(COMPARISON_PREFIX) &&
+          origCol.isNumeric
+        ) {
+          const originalValue = originalItem[origCol.key] || 0;
+          const comparisonValue = origCol.isMetric
+            ? originalItem?.[`${COMPARISON_PREFIX}${origCol.key}`] || 0
+            : originalItem[`%${COMPARISON_PREFIX}${origCol.key.slice(1)}`] || 0;
+          const { valueDifference, percentDifferenceNum } =
+            calculateDifferences(
+              originalValue as number,
+              comparisonValue as number,
+            );
+
+          transformedItem[`Main ${origCol.key}`] = originalValue;
+          transformedItem[`# ${origCol.key}`] = comparisonValue;
+          transformedItem[`△ ${origCol.key}`] = valueDifference;
+          transformedItem[`% ${origCol.key}`] = percentDifferenceNum;
+        }
+      });
+
+      Object.keys(originalItem).forEach(key => {
+        const isMetricOrPercentMetric = originalColumns.some(
+          col => col.key === key && (col.isMetric || col.isPercentMetric),
+        );
+        if (!isMetricOrPercentMetric) {
+          transformedItem[key] = originalItem[key];
+        }
+      });
+
+      return transformedItem;
+    });
+  },
+);
+
 const processColumns = memoizeOne(function processColumns(
   props: TableChartProps,
 ) {
@@ -186,6 +273,55 @@ const processColumns = memoizeOne(function processColumns(
   ];
 }, isEqualColumns);
 
+const processComparisonColumns = (
+  columns: DataColumnMeta[],
+  props: TableChartProps,
+) =>
+  columns
+    .map(col => {
+      const {
+        datasource: { columnFormats },
+        rawFormData: { column_config: columnConfig = {} },
+      } = props;
+      const config = columnConfig[col.key] || {};
+      const savedFormat = columnFormats?.[col.key];
+      const numberFormat = config.d3NumberFormat || savedFormat;
+      if (col.isNumeric && !col.key.includes(COMPARISON_PREFIX)) {
+        return [
+          {
+            ...col,
+            label: t('Main'),
+            key: `${t('Main')} ${col.key}`,
+          },
+          {
+            ...col,
+            label: `#`,
+            key: `# ${col.key}`,
+          },
+          {
+            ...col,
+            label: `△`,
+            key: `△ ${col.key}`,
+          },
+          {
+            ...col,
+            formatter: getNumberFormatter(numberFormat || PERCENT_3_POINT),
+            label: `%`,
+            key: `% ${col.key}`,
+          },
+        ];
+      }
+      if (
+        !col.isMetric &&
+        !col.isPercentMetric &&
+        !col.key.includes(COMPARISON_PREFIX)
+      ) {
+        return [col];
+      }
+      return [];
+    })
+    .flat();
+
 /**
  * Automatically set page size based on number of cells.
  */
@@ -238,23 +374,35 @@ const transformProps = (
     show_totals: showTotals,
     conditional_formatting: conditionalFormatting,
     allow_rearrange_columns: allowRearrangeColumns,
+    enable_time_comparison: enableTimeComparison = false,
   } = formData;
+  const canUseTimeComparison =
+    enableTimeComparison &&
+    isFeatureEnabled(FeatureFlag.ChartPluginsExperimental);
   const timeGrain = extractTimegrain(formData);
 
   const [metrics, percentMetrics, columns] = processColumns(chartProps);
+  let comparisonColumns: DataColumnMeta[] = [];
+  if (canUseTimeComparison) {
+    comparisonColumns = processComparisonColumns(columns, chartProps);
+  }
 
   let baseQuery;
   let countQuery;
   let totalQuery;
   let rowCount;
+  const queriesDataWithoutComparisonQueries = queriesData.filter(
+    ({ instant_time_comparison_range }) => !instant_time_comparison_range,
+  );
   if (serverPagination) {
-    [baseQuery, countQuery, totalQuery] = queriesData;
+    [baseQuery, countQuery, totalQuery] = queriesDataWithoutComparisonQueries;
     rowCount = (countQuery?.data?.[0]?.rowcount as number) ?? 0;
   } else {
-    [baseQuery, totalQuery] = queriesData;
+    [baseQuery, totalQuery] = queriesDataWithoutComparisonQueries;
     rowCount = baseQuery?.rowcount ?? 0;
   }
   const data = processDataRecords(baseQuery?.data, columns);
+  const comparisonData = processComparisonDataRecords(baseQuery?.data, columns);
   const totals =
     showTotals && queryMode === QueryMode.Aggregate
       ? totalQuery?.data[0]
@@ -262,13 +410,19 @@ const transformProps = (
   const columnColorFormatters =
     getColorFormatters(conditionalFormatting, data) ?? defaultColorFormatters;
 
+  const comparisonTotals = processComparisonTotals(totals);
+
+  const passedData = canUseTimeComparison ? comparisonData || [] : data;
+  const passedTotals = canUseTimeComparison ? comparisonTotals : totals;
+  const passedColumns = canUseTimeComparison ? comparisonColumns : columns;
+
   return {
     height,
     width,
     isRawRecords: queryMode === QueryMode.Raw,
-    data,
-    totals,
-    columns,
+    data: passedData,
+    totals: passedTotals,
+    columns: passedColumns,
     serverPagination,
     metrics,
     percentMetrics,
@@ -292,6 +446,7 @@ const transformProps = (
     timeGrain,
     allowRearrangeColumns,
     onContextMenu,
+    enableTimeComparison: canUseTimeComparison,
   };
 };
 
diff --git a/superset-frontend/plugins/plugin-chart-table/src/types.ts b/superset-frontend/plugins/plugin-chart-table/src/types.ts
index 02bae809fe..1806eddb1a 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/types.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/types.ts
@@ -91,6 +91,7 @@ export type TableChartFormData = QueryFormData & {
   time_grain_sqla?: TimeGranularity;
   column_config?: Record<string, TableColumnConfig>;
   allow_rearrange_columns?: boolean;
+  enable_time_comparison?: boolean;
 };
 
 export interface TableChartProps extends ChartProps {
@@ -135,6 +136,7 @@ export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
     clientY: number,
     filters?: ContextMenuFilters,
   ) => void;
+  enableTimeComparison?: boolean;
 }
 
 export default {};
diff --git a/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts b/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts
index 28731c73c2..8153ea856a 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts
@@ -41,6 +41,7 @@ export default function isEqualColumns(
     JSON.stringify(a.formData.extraFormData || null) ===
       JSON.stringify(b.formData.extraFormData || null) &&
     JSON.stringify(a.rawFormData.column_config || null) ===
-      JSON.stringify(b.rawFormData.column_config || null)
+      JSON.stringify(b.rawFormData.column_config || null) &&
+    a.formData.enableTimeComparison === b.formData.enableTimeComparison
   );
 }
diff --git a/superset-frontend/plugins/plugin-chart-table/test/testData.ts b/superset-frontend/plugins/plugin-chart-table/test/testData.ts
index 24abc3381e..af2fbe5a65 100644
--- a/superset-frontend/plugins/plugin-chart-table/test/testData.ts
+++ b/superset-frontend/plugins/plugin-chart-table/test/testData.ts
@@ -84,6 +84,7 @@ const basicQueryResult: ChartDataResponseResult = {
   status: 'success',
   from_dttm: null,
   to_dttm: null,
+  instant_time_comparison_range: null,
 };
 
 /**
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index 611f7af597..34731af571 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -26,6 +26,7 @@ from marshmallow.validate import Length, Range
 
 from superset import app
 from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
+from superset.constants import InstantTimeComparison
 from superset.db_engine_specs.base import builtin_time_grains
 from superset.tags.models import TagType
 from superset.utils import pandas_postprocessing, schema as utils
@@ -948,6 +949,14 @@ class ChartDataFilterSchema(Schema):
     )
 
 
+class InstantTimeComparisonInfoSchema(Schema):
+    range = fields.String(
+        metadata={"description": "Type of time comparison to be used"},
+        validate=validate.OneOf(choices=[ran.value for ran in InstantTimeComparison]),
+    )
+    filter = fields.Nested(ChartDataFilterSchema, allow_none=True)
+
+
 class ChartDataExtrasSchema(Schema):
     relative_start = fields.String(
         metadata={
@@ -994,7 +1003,8 @@ class ChartDataExtrasSchema(Schema):
         metadata={
             "description": "This is only set using the new time comparison controls "
             "that is made available in some plugins behind the experimental "
-            "feature flag."
+            "feature flag. If passed as extra, the time range will be changed inside this"
+            " query object."
         },
         allow_none=True,
     )
@@ -1350,6 +1360,14 @@ class ChartDataQueryObjectSchema(Schema):
         fields.String(),
         allow_none=True,
     )
+    instant_time_comparison_info = fields.Nested(
+        InstantTimeComparisonInfoSchema,
+        metadata={
+            "description": "Extra parameters to use instant time comparison"
+            " with JOINs using a single query"
+        },
+        allow_none=True,
+    )
 
 
 class ChartDataQueryContextSchema(Schema):
diff --git a/superset/common/query_context_processor.py b/superset/common/query_context_processor.py
index d8b5bea4bb..77f84989b1 100644
--- a/superset/common/query_context_processor.py
+++ b/superset/common/query_context_processor.py
@@ -197,6 +197,9 @@ class QueryContextProcessor:
             "from_dttm": query_obj.from_dttm,
             "to_dttm": query_obj.to_dttm,
             "label_map": label_map,
+            "instant_time_comparison_range": query_obj.extras.get(
+                "instant_time_comparison_range"
+            ),
         }
 
     def query_cache_key(self, query_obj: QueryObject, **kwargs: Any) -> str | None:
diff --git a/superset/common/query_object.py b/superset/common/query_object.py
index 5109c465e0..77f3a08ce8 100644
--- a/superset/common/query_object.py
+++ b/superset/common/query_object.py
@@ -107,6 +107,7 @@ class QueryObject:  # pylint: disable=too-many-instance-attributes
     time_shift: str | None
     time_range: str | None
     to_dttm: datetime | None
+    instant_time_comparison_info: dict[str, Any] | None
 
     def __init__(  # pylint: disable=too-many-locals
         self,
@@ -132,6 +133,7 @@ class QueryObject:  # pylint: disable=too-many-instance-attributes
         series_limit_metric: Metric | None = None,
         time_range: str | None = None,
         time_shift: str | None = None,
+        instant_time_comparison_info: dict[str, Any] | None = None,
         **kwargs: Any,
     ):
         self._set_annotation_layers(annotation_layers)
@@ -161,6 +163,7 @@ class QueryObject:  # pylint: disable=too-many-instance-attributes
         self.time_offsets = kwargs.get("time_offsets", [])
         self.inner_from_dttm = kwargs.get("inner_from_dttm")
         self.inner_to_dttm = kwargs.get("inner_to_dttm")
+        self.instant_time_comparison_info = instant_time_comparison_info
         self._rename_deprecated_fields(kwargs)
         self._move_deprecated_extra_fields(kwargs)
 
@@ -335,6 +338,7 @@ class QueryObject:  # pylint: disable=too-many-instance-attributes
             "series_limit_metric": self.series_limit_metric,
             "to_dttm": self.to_dttm,
             "time_shift": self.time_shift,
+            "instant_time_comparison_info": self.instant_time_comparison_info,
         }
         return query_object_dict
 
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 089b9c2f28..b6e14ce62e 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 import builtins
+import copy
 import dataclasses
 import json
 import logging
@@ -81,7 +82,7 @@ from superset.connectors.sqla.utils import (
     get_physical_table_metadata,
     get_virtual_table_metadata,
 )
-from superset.constants import EMPTY_STRING, NULL_STRING
+from superset.constants import EMPTY_STRING, InstantTimeComparison, NULL_STRING
 from superset.db_engine_specs.base import BaseEngineSpec, TimestampExpression
 from superset.exceptions import (
     ColumnNotFoundException,
@@ -105,6 +106,7 @@ from superset.models.helpers import (
     ImportExportMixin,
     QueryResult,
     QueryStringExtended,
+    SqlaQuery,
     validate_adhoc_subquery,
 )
 from superset.models.slice import Slice
@@ -120,7 +122,7 @@ from superset.superset_typing import (
 )
 from superset.utils import core as utils
 from superset.utils.backports import StrEnum
-from superset.utils.core import GenericDataType, MediumText
+from superset.utils.core import FilterOperator, GenericDataType, MediumText
 
 config = app.config
 metadata = Model.metadata  # pylint: disable=no-member
@@ -1413,24 +1415,138 @@ class SqlaTable(
     def get_template_processor(self, **kwargs: Any) -> BaseTemplateProcessor:
         return get_template_processor(table=self, database=self.database, **kwargs)
 
+    def extract_column_names(self, final_selected_columns: Any) -> list[str]:
+        column_names = []
+        for selected_col in final_selected_columns:
+            # The key attribute usually holds the name or alias of the column
+            column_name = selected_col.key if hasattr(selected_col, "key") else None
+            # If the column has a name attribute, use it as a fallback
+            if not column_name and hasattr(selected_col, "name"):
+                column_name = selected_col.name
+            # For labeled elements, the name is stored in the 'name' attribute
+            if hasattr(selected_col, "name"):
+                column_name = selected_col.name
+            # Append the extracted name to the list
+            if column_name:
+                column_names.append(column_name)
+        return column_names
+
+    def process_time_compare_join(  # pylint: disable=too-many-locals
+        self,
+        query_obj: QueryObjectDict,
+        sqlaq: SqlaQuery,
+        mutate: bool,
+        instant_time_comparison_info: dict[str, Any],
+    ) -> tuple[str, list[str]]:
+        query_obj_clone = copy.copy(query_obj)
+        final_query_sql = ""
+        query_obj_clone["row_limit"] = None
+        query_obj_clone["row_offset"] = None
+        instant_time_comparison_range = instant_time_comparison_info.get("range")
+        if instant_time_comparison_range == InstantTimeComparison.CUSTOM:
+            custom_filter = instant_time_comparison_info.get("filter", {})
+            temporal_filters = [
+                filter["col"]
+                for filter in query_obj_clone.get("filter", {})
+                if filter.get("op", None) == FilterOperator.TEMPORAL_RANGE
+            ]
+            non_temporal_filters = [
+                filter["col"]
+                for filter in query_obj_clone.get("filter", {})
+                if filter.get("op", None) != FilterOperator.TEMPORAL_RANGE
+            ]
+            if len(temporal_filters) > 0:
+                # Edit the firt temporal filter to include the custom filter
+                temporal_filters[0] = custom_filter
+
+            new_filters = temporal_filters + non_temporal_filters
+            query_obj_clone["filter"] = new_filters
+        if instant_time_comparison_range != InstantTimeComparison.CUSTOM:
+            query_obj_clone["extras"] = {
+                **query_obj_clone.get("extras", {}),
+                "instant_time_comparison_range": instant_time_comparison_range,
+            }
+        sqlaq_2 = self.get_sqla_query(**query_obj_clone)
+        join_columns = query_obj_clone.get("columns") or []
+        sqla_query_a = sqlaq.sqla_query
+        sqla_query_b = sqlaq_2.sqla_query
+        sqla_query_b_subquery = sqla_query_b.subquery()
+        query_a_cte = sqla_query_a.cte("query_a_results")
+        column_names_a = [column.key for column in sqla_query_a.c]
+        exclude_columns_b = set(query_obj_clone.get("columns") or [])
+        selected_columns_a = [query_a_cte.c[col].label(col) for col in column_names_a]
+        # Renamed columns from Query B (with "prev_" prefix)
+        selected_columns_b = [
+            sqla_query_b_subquery.c[col].label(f"prev_{col}")
+            for col in sqla_query_b_subquery.c.keys()
+            if col not in exclude_columns_b
+        ]
+        # Combine selected columns from both queries
+        final_selected_columns = selected_columns_a + selected_columns_b
+        if join_columns and not query_obj_clone.get("is_rowcount"):
+            # Proceed with JOIN operation as before since join_columns is not empty
+            join_conditions = [
+                sqla_query_b_subquery.c[col] == query_a_cte.c[col]
+                for col in join_columns
+                if col in sqla_query_b_subquery.c and col in query_a_cte.c
+            ]
+            final_query = sa.select(*final_selected_columns).select_from(
+                sqla_query_b_subquery.join(query_a_cte, sa.and_(*join_conditions))
+            )
+        else:
+            final_query = sa.select(*final_selected_columns).select_from(
+                sqla_query_b_subquery.join(
+                    query_a_cte, sa.literal(True) == sa.literal(True)
+                )
+            )
+        final_query_sql = self.database.compile_sqla_query(final_query)
+        final_query_sql = self._apply_cte(final_query_sql, sqlaq.cte)
+        final_query_sql = sqlparse.format(final_query_sql, reindent=True)
+        if mutate:
+            final_query_sql = self.mutate_query_from_config(final_query_sql)
+
+        labels_expected = self.extract_column_names(final_selected_columns)
+
+        return final_query_sql, labels_expected
+
     def get_query_str_extended(
         self,
         query_obj: QueryObjectDict,
         mutate: bool = True,
     ) -> QueryStringExtended:
-        sqlaq = self.get_sqla_query(**query_obj)
+        # So we don't mutate the original query_obj
+        query_obj_clone = copy.copy(query_obj)
+        instant_time_comparison_info = query_obj.get("instant_time_comparison_info")
+        query_obj_clone.pop("instant_time_comparison_info", None)
+        sqlaq = self.get_sqla_query(**query_obj_clone)
         sql = self.database.compile_sqla_query(sqlaq.sqla_query)
         sql = self._apply_cte(sql, sqlaq.cte)
         sql = sqlparse.format(sql, reindent=True)
+
         if mutate:
             sql = self.mutate_query_from_config(sql)
+
+        if (
+            is_feature_enabled("CHART_PLUGINS_EXPERIMENTAL")
+            and instant_time_comparison_info
+        ):
+            (
+                final_query_sql,
+                labels_expected,
+            ) = self.process_time_compare_join(
+                query_obj_clone, sqlaq, mutate, instant_time_comparison_info
+            )
+        else:
+            final_query_sql = sql
+            labels_expected = sqlaq.labels_expected
+
         return QueryStringExtended(
             applied_template_filters=sqlaq.applied_template_filters,
             applied_filter_columns=sqlaq.applied_filter_columns,
             rejected_filter_columns=sqlaq.rejected_filter_columns,
-            labels_expected=sqlaq.labels_expected,
+            labels_expected=labels_expected,
             prequeries=sqlaq.prequeries,
-            sql=sql,
+            sql=final_query_sql if final_query_sql else sql,
         )
 
     def get_query_str(self, query_obj: QueryObjectDict) -> str:
diff --git a/superset/constants.py b/superset/constants.py
index bf4e7717d5..9af8870e2d 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -48,6 +48,7 @@ class InstantTimeComparison(StrEnum):
     YEAR = "y"
     MONTH = "m"
     WEEK = "w"
+    CUSTOM = "c"
 
 
 class RouteMethod:  # pylint: disable=too-few-public-methods
diff --git a/tests/unit_tests/connectors/__init__.py b/tests/unit_tests/connectors/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/tests/unit_tests/connectors/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/tests/unit_tests/connectors/test_models.py b/tests/unit_tests/connectors/test_models.py
new file mode 100644
index 0000000000..cf179c9dfa
--- /dev/null
+++ b/tests/unit_tests/connectors/test_models.py
@@ -0,0 +1,383 @@
+# 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.
+import datetime
+
+from sqlalchemy.orm.session import Session
+
+from superset import db
+from tests.unit_tests.conftest import with_feature_flags
+
+
+class TestInstantTimeComparisonQueryGeneration:
+    @staticmethod
+    def base_setup(session: Session):
+        from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
+        from superset.models.core import Database
+
+        engine = db.session.get_bind()
+        SqlaTable.metadata.create_all(engine)  # pylint: disable=no-member
+
+        table = SqlaTable(
+            table_name="my_table",
+            schema="my_schema",
+            database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"),
+        )
+
+        # Common columns
+        columns = [
+            {"column_name": "ds", "type": "DATETIME"},
+            {"column_name": "gender", "type": "VARCHAR(255)"},
+            {"column_name": "name", "type": "VARCHAR(255)"},
+            {"column_name": "state", "type": "VARCHAR(255)"},
+        ]
+
+        # Add columns to the table
+        for col in columns:
+            TableColumn(column_name=col["column_name"], type=col["type"], table=table)
+
+        # Common metrics
+        metrics = [
+            {"metric_name": "count", "expression": "count(*)"},
+            {"metric_name": "sum_sum", "expression": "SUM"},
+        ]
+
+        # Add metrics to the table
+        for metric in metrics:
+            SqlMetric(
+                metric_name=metric["metric_name"],
+                expression=metric["expression"],
+                table=table,
+            )
+
+        db.session.add(table)
+        db.session.flush()
+
+        return table
+
+    @staticmethod
+    def generate_base_query_obj():
+        return {
+            "apply_fetch_values_predicate": False,
+            "columns": ["name"],
+            "extras": {"having": "", "where": ""},
+            "filter": [
+                {"op": "TEMPORAL_RANGE", "val": "1984-01-01 : 2024-02-14", "col": "ds"}
+            ],
+            "from_dttm": datetime.datetime(1984, 1, 1, 0, 0),
+            "granularity": None,
+            "inner_from_dttm": None,
+            "inner_to_dttm": None,
+            "is_rowcount": False,
+            "is_timeseries": False,
+            "order_desc": True,
+            "orderby": [("SUM(num_boys)", False)],
+            "row_limit": 10,
+            "row_offset": 0,
+            "series_columns": [],
+            "series_limit": 0,
+            "series_limit_metric": None,
+            "to_dttm": datetime.datetime(2024, 2, 14, 0, 0),
+            "time_shift": None,
+            "metrics": [
+                {
+                    "aggregate": "SUM",
+                    "column": {
+                        "column_name": "num_boys",
+                        "type": "BIGINT",
+                        "filterable": True,
+                        "groupby": True,
+                        "id": 334,
+                        "is_certified": False,
+                        "is_dttm": False,
+                        "type_generic": 0,
+                    },
+                    "datasourceWarning": False,
+                    "expressionType": "SIMPLE",
+                    "hasCustomLabel": False,
+                    "label": "SUM(num_boys)",
+                    "optionName": "metric_gzp6eq9g1lc_d8o0mj0mhq4",
+                    "sqlExpression": None,
+                },
+                {
+                    "aggregate": "SUM",
+                    "column": {
+                        "column_name": "num_girls",
+                        "type": "BIGINT",
+                        "filterable": True,
+                        "groupby": True,  # Note: This will need adjustment in some cases
+                        "id": 335,
+                        "is_certified": False,
+                        "is_dttm": False,
+                        "type_generic": 0,
+                    },
+                    "datasourceWarning": False,
+                    "expressionType": "SIMPLE",
+                    "hasCustomLabel": False,
+                    "label": "SUM(num_girls)",
+                    "optionName": "metric_5gyhtmyfw1t_d42py86jpco",
+                    "sqlExpression": None,
+                },
+            ],
+            "instant_time_comparison_info": {
+                "range": "y",
+            },
+        }
+
+    @with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=True)
+    def test_creates_time_comparison_query(session: Session):
+        table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
+        query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
+        str = table.get_query_str_extended(query_obj)
+        expected_str = """
+            WITH query_a_results AS
+            (SELECT name AS name,
+                    sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1984-01-01 00:00:00'
+                AND ds < '2024-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC
+            LIMIT 10
+            OFFSET 0)
+            SELECT query_a_results.name AS name,
+                query_a_results."SUM(num_boys)" AS "SUM(num_boys)",
+                query_a_results."SUM(num_girls)" AS "SUM(num_girls)",
+                anon_1."SUM(num_boys)" AS "prev_SUM(num_boys)",
+                anon_1."SUM(num_girls)" AS "prev_SUM(num_girls)"
+            FROM
+            (SELECT name AS name,
+                    sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1983-01-01 00:00:00'
+                AND ds < '2023-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC) AS anon_1
+            JOIN query_a_results ON anon_1.name = query_a_results.name
+        """
+        simplified_query1 = " ".join(str.sql.split()).lower()
+        simplified_query2 = " ".join(expected_str.split()).lower()
+        assert table.id == 1
+        assert simplified_query1 == simplified_query2
+
+    @with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=True)
+    def test_creates_time_comparison_query_no_columns(session: Session):
+        table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
+        query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
+        query_obj["columns"] = []
+        query_obj["metrics"][0]["column"]["groupby"] = False
+        query_obj["metrics"][1]["column"]["groupby"] = False
+
+        str = table.get_query_str_extended(query_obj)
+        expected_str = """
+            WITH query_a_results AS
+            (SELECT sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1984-01-01 00:00:00'
+                AND ds < '2024-02-14 00:00:00'
+            ORDER BY "SUM(num_boys)" DESC
+            LIMIT 10
+            OFFSET 0)
+            SELECT query_a_results."SUM(num_boys)" AS "SUM(num_boys)",
+                query_a_results."SUM(num_girls)" AS "SUM(num_girls)",
+                anon_1."SUM(num_boys)" AS "prev_SUM(num_boys)",
+                anon_1."SUM(num_girls)" AS "prev_SUM(num_girls)"
+            FROM
+            (SELECT sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1983-01-01 00:00:00'
+                AND ds < '2023-02-14 00:00:00'
+            ORDER BY "SUM(num_boys)" DESC) AS anon_1
+            JOIN query_a_results ON 1 = 1
+        """
+        simplified_query1 = " ".join(str.sql.split()).lower()
+        simplified_query2 = " ".join(expected_str.split()).lower()
+        assert table.id == 1
+        assert simplified_query1 == simplified_query2
+
+    @with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=True)
+    def test_creates_time_comparison_rowcount_query(session: Session):
+        table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
+        query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
+        query_obj["is_rowcount"] = True
+        str = table.get_query_str_extended(query_obj)
+        expected_str = """
+            WITH query_a_results AS
+        (SELECT COUNT(*) AS rowcount
+        FROM
+            (SELECT name AS name,
+                    sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1984-01-01 00:00:00'
+                AND ds < '2024-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC
+            LIMIT 10
+            OFFSET 0) AS rowcount_qry)
+        SELECT query_a_results.rowcount AS rowcount,
+            anon_1.rowcount AS prev_rowcount
+        FROM
+        (SELECT COUNT(*) AS rowcount
+        FROM
+            (SELECT name AS name,
+                    sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1983-01-01 00:00:00'
+                AND ds < '2023-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC) AS rowcount_qry) AS anon_1
+        JOIN query_a_results ON 1 = 1
+        """
+        simplified_query1 = " ".join(str.sql.split()).lower()
+        simplified_query2 = " ".join(expected_str.split()).lower()
+        assert table.id == 1
+        assert simplified_query1 == simplified_query2
+
+    @with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=True)
+    def test_creates_query_without_time_comparison(session: Session):
+        table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
+        query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
+        query_obj["instant_time_comparison_info"] = None
+        str = table.get_query_str_extended(query_obj)
+        expected_str = """
+            SELECT name AS name,
+                sum(num_boys) AS "SUM(num_boys)",
+                sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1984-01-01 00:00:00'
+            AND ds < '2024-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC
+            LIMIT 10
+            OFFSET 0
+        """
+        simplified_query1 = " ".join(str.sql.split()).lower()
+        simplified_query2 = " ".join(expected_str.split()).lower()
+        assert table.id == 1
+        assert simplified_query1 == simplified_query2
+
+    @with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=True)
+    def test_creates_time_comparison_query_custom_filters(session: Session):
+        table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
+        query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
+        query_obj["instant_time_comparison_info"] = {
+            "range": "c",
+            "filter": {
+                "op": "TEMPORAL_RANGE",
+                "val": "1900-01-01 : 1950-02-14",
+                "col": "ds",
+            },
+        }
+        str = table.get_query_str_extended(query_obj)
+        expected_str = """
+            WITH query_a_results AS
+            (SELECT name AS name,
+                    sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1984-01-01 00:00:00'
+                AND ds < '2024-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC
+            LIMIT 10
+            OFFSET 0)
+            SELECT query_a_results.name AS name,
+                query_a_results."SUM(num_boys)" AS "SUM(num_boys)",
+                query_a_results."SUM(num_girls)" AS "SUM(num_girls)",
+                anon_1."SUM(num_boys)" AS "prev_SUM(num_boys)",
+                anon_1."SUM(num_girls)" AS "prev_SUM(num_girls)"
+            FROM
+            (SELECT name AS name,
+                    sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1900-01-01 00:00:00'
+                AND ds < '1950-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC) AS anon_1
+            JOIN query_a_results ON anon_1.name = query_a_results.name
+        """
+        simplified_query1 = " ".join(str.sql.split()).lower()
+        simplified_query2 = " ".join(expected_str.split()).lower()
+        assert table.id == 1
+        assert simplified_query1 == simplified_query2
+
+    @with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=True)
+    def test_creates_time_comparison_query_paginated(session: Session):
+        table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
+        query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
+        query_obj["row_offset"] = 20
+        str = table.get_query_str_extended(query_obj)
+        expected_str = """
+            WITH query_a_results AS
+            (SELECT name AS name,
+                    sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1984-01-01 00:00:00'
+                AND ds < '2024-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC
+            LIMIT 10
+            OFFSET 20)
+            SELECT query_a_results.name AS name,
+                query_a_results."SUM(num_boys)" AS "SUM(num_boys)",
+                query_a_results."SUM(num_girls)" AS "SUM(num_girls)",
+                anon_1."SUM(num_boys)" AS "prev_SUM(num_boys)",
+                anon_1."SUM(num_girls)" AS "prev_SUM(num_girls)"
+            FROM
+            (SELECT name AS name,
+                    sum(num_boys) AS "SUM(num_boys)",
+                    sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1983-01-01 00:00:00'
+                AND ds < '2023-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC) AS anon_1
+            JOIN query_a_results ON anon_1.name = query_a_results.name
+        """
+        simplified_query1 = " ".join(str.sql.split()).lower()
+        simplified_query2 = " ".join(expected_str.split()).lower()
+        assert table.id == 1
+        assert simplified_query1 == simplified_query2
+
+    @with_feature_flags(CHART_PLUGINS_EXPERIMENTAL=False)
+    def test_ignore_if_ff_off(session: Session):
+        table = TestInstantTimeComparisonQueryGeneration.base_setup(session)
+        query_obj = TestInstantTimeComparisonQueryGeneration.generate_base_query_obj()
+        str = table.get_query_str_extended(query_obj)
+        expected_str = """
+            SELECT name AS name,
+                sum(num_boys) AS "SUM(num_boys)",
+                sum(num_girls) AS "SUM(num_girls)"
+            FROM my_schema.my_table
+            WHERE ds >= '1984-01-01 00:00:00'
+            AND ds < '2024-02-14 00:00:00'
+            GROUP BY name
+            ORDER BY "SUM(num_boys)" DESC
+            LIMIT 10
+            OFFSET 0
+        """
+        simplified_query1 = " ".join(str.sql.split()).lower()
+        simplified_query2 = " ".join(expected_str.split()).lower()
+        assert table.id == 1
+        assert simplified_query1 == simplified_query2
diff --git a/tests/unit_tests/queries/query_object_test.py b/tests/unit_tests/queries/query_object_test.py
index 81a654653f..f90ab8255d 100644
--- a/tests/unit_tests/queries/query_object_test.py
+++ b/tests/unit_tests/queries/query_object_test.py
@@ -47,6 +47,7 @@ def test_default_query_object_to_dict():
         "granularity": None,
         "inner_from_dttm": None,
         "inner_to_dttm": None,
+        "instant_time_comparison_info": None,
         "is_rowcount": False,
         "is_timeseries": False,
         "metrics": None,


(superset) 08/09: Table with Time Comparison:

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 739a80e41f19ee7eba82d09ee039755cc94728d0
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Wed Mar 6 15:40:20 2024 +0100

    Table with Time Comparison:
    
    - When using time compariosn, columns cannot be rearranged
---
 superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
index c7710c81df..78a6a1eeab 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
@@ -520,6 +520,9 @@ const config: ControlPanelConfig = {
               description: t(
                 "Allow end user to drag-and-drop column headers to rearrange them. Note their changes won't persist for the next time they open the chart.",
               ),
+              visibility: ({ controls }) =>
+                !controls?.enable_time_comparison?.value ||
+                !isFeatureEnabled(FeatureFlag.ChartPluginsExperimental),
             },
           },
         ],


(superset) 06/09: Table with Time Comparison:

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arivero pushed a commit to branch table-time-comparison
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 381e9b755eadc45d0c25e233205290173263ca54
Author: Antonio Rivero <an...@gmail.com>
AuthorDate: Mon Mar 4 18:28:35 2024 +0100

    Table with Time Comparison:
    
    - Align text of comparison metrics to the left
---
 superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
index a41001a446..ab75914c97 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
@@ -362,11 +362,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
 
   const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
     const { isNumeric, config = {} } = column;
-    const textAlign = config.horizontalAlign
-      ? config.horizontalAlign
-      : isNumeric
-        ? 'right'
-        : 'left';
+    const textAlign =
+      config.horizontalAlign ||
+      (isNumeric && !enableTimeComparison ? 'right' : 'left');
+
     return {
       textAlign,
     };