You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2025/08/03 01:57:37 UTC
(superset) 08/08: no-verify-checkpoint
This is an automated email from the ASF dual-hosted git repository.
maximebeauchemin pushed a commit to branch matrixify
in repository https://gitbox.apache.org/repos/asf/superset.git
commit ed45606b24a2a468577fc7323243a02ecde245ed
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Sat Aug 2 18:56:40 2025 -0700
no-verify-checkpoint
---
superset-frontend/package-lock.json | 1 +
superset-frontend/package.json | 1 +
.../src/shared-controls/sharedControls.tsx | 74 ++++
.../superset-ui-core/src/utils/featureFlags.ts | 1 +
.../explore/components/ControlPanelsContainer.tsx | 77 +++--
.../explore/components/ExploreChartPanel/index.jsx | 58 +++-
.../components/MatrixifyGrid/MatrixifyGridCell.tsx | 135 ++++++++
.../MatrixifyGrid/MatrixifyGridGenerator.ts | 37 +-
.../MatrixifyGrid/MatrixifyGridRenderer.tsx | 295 ++++++++--------
.../components/MatrixifyGrid/templateUtils.ts | 59 ++++
.../src/explore/controlPanels/sections.tsx | 50 ++-
.../explore/controlUtils/getSectionsToRender.ts | 12 +-
superset-frontend/src/explore/types/matrixify.ts | 15 +
superset/config.py | 2 +
superset/models/helpers.py | 380 ++++++++++++++++-----
15 files changed, 908 insertions(+), 289 deletions(-)
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index e52430bcfc..91332ee77d 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -77,6 +77,7 @@
"geostyler-style": "7.5.0",
"geostyler-wfs-parser": "^2.0.3",
"googleapis": "^130.0.0",
+ "handlebars": "^4.7.8",
"immer": "^10.1.1",
"interweave": "^13.1.0",
"jquery": "^3.7.1",
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index 71397bcf14..5663532228 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -139,6 +139,7 @@
"fuse.js": "^7.0.0",
"geolib": "^2.0.24",
"geostyler": "^14.1.3",
+ "handlebars": "^4.7.8",
"geostyler-data": "^1.0.0",
"geostyler-openlayers-parser": "^4.3.0",
"geostyler-qgis-parser": "2.0.1",
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
index 379b0ca45c..d6b84fe0ff 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
@@ -566,5 +566,79 @@ const sharedControls: Record<string, SharedControlConfig<any>> = {
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
'topn',
};
+ // Add grid layout controls
+ sharedControls.matrixify_row_height = {
+ type: 'TextControl',
+ label: t('Row Height'),
+ description: t('Height of each row in pixels'),
+ default: 300,
+ isInt: true,
+ validators: [],
+ };
+
+ sharedControls.matrixify_fit_columns_dynamically = {
+ type: 'CheckboxControl',
+ label: t('Fit Columns Dynamically'),
+ description: t(
+ 'Automatically adjust column width based on available space',
+ ),
+ default: true,
+ };
+
+ sharedControls.matrixify_charts_per_row = {
+ type: 'SelectControl',
+ label: t('Charts Per Row'),
+ description: t('Number of charts to display per row'),
+ default: 4,
+ choices: [
+ [1, '1'],
+ [2, '2'],
+ [3, '3'],
+ [4, '4'],
+ [6, '6'],
+ [12, '12'],
+ ],
+ clearable: false,
+ visibility: ({ controls }) =>
+ !controls?.matrixify_fit_columns_dynamically?.value,
+ };
});
+
+// Cell title control for Matrixify
+sharedControls.matrixify_cell_title_template = {
+ type: 'TextControl',
+ label: t('Title'),
+ description: t(
+ 'Customize cell titles using Handlebars template syntax. Available variables: {{rowLabel}}, {{colLabel}}',
+ ),
+ default: '',
+ renderTrigger: true,
+ visibility: ({ controls }) =>
+ (controls?.matrixify_mode_rows?.value ||
+ controls?.matrixify_mode_columns?.value) !== undefined,
+};
+
+// Matrix display controls
+sharedControls.matrixify_show_row_labels = {
+ type: 'CheckboxControl',
+ label: t('Show row labels'),
+ description: t('Display labels for each row on the left side of the matrix'),
+ default: true,
+ renderTrigger: true,
+ visibility: ({ controls }) =>
+ (controls?.matrixify_mode_rows?.value ||
+ controls?.matrixify_mode_columns?.value) !== undefined,
+};
+
+sharedControls.matrixify_show_column_headers = {
+ type: 'CheckboxControl',
+ label: t('Show column headers'),
+ description: t('Display headers for each column at the top of the matrix'),
+ default: true,
+ renderTrigger: true,
+ visibility: ({ controls }) =>
+ (controls?.matrixify_mode_rows?.value ||
+ controls?.matrixify_mode_columns?.value) !== undefined,
+};
+
export default sharedControls;
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index de2c66204f..2ac0540424 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -49,6 +49,7 @@ export enum FeatureFlag {
FilterBarClosedByDefault = 'FILTERBAR_CLOSED_BY_DEFAULT',
GlobalAsyncQueries = 'GLOBAL_ASYNC_QUERIES',
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
+ Matrixify = 'MATRIXIFY',
ScheduledQueries = 'SCHEDULED_QUERIES',
SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE',
SqlValidatorsByEngine = 'SQL_VALIDATORS_BY_ENGINE',
diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
index 90c1ff77d2..7431718fa3 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
@@ -41,6 +41,8 @@ import {
JsonValue,
NO_TIME_RANGE,
usePrevious,
+ isFeatureEnabled,
+ FeatureFlag,
} from '@superset-ui/core';
import {
ControlPanelSectionConfig,
@@ -60,7 +62,7 @@ import {
Collapse,
Modal,
Loading,
- Tag,
+ Label,
Tooltip,
} from '@superset-ui/core/components';
import Tabs from '@superset-ui/core/components/Tabs';
@@ -737,6 +739,30 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
}
const showCustomizeTab = customizeSections.length > 0;
+ const showMatrixifyTab = isFeatureEnabled(FeatureFlag.Matrixify);
+
+ // Create Matrixify tab label with Beta tag
+ const matrixifyTabLabel = (
+ <span>
+ {t('Matrixify')}{' '}
+ <Tooltip
+ title={t(
+ 'This feature is experimental and may change or have limitations',
+ )}
+ placement="top"
+ >
+ <Label
+ type="info"
+ css={css`
+ margin-left: ${theme.sizeUnit}px;
+ font-size: ${theme.fontSizeSM}px;
+ `}
+ >
+ {t('beta')}
+ </Label>
+ </Tooltip>
+ </span>
+ );
return (
<Styles ref={containerRef}>
@@ -780,36 +806,25 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
},
]
: []),
- {
- key: TABS_KEYS.MATRIXIFY,
- label: t('Matrixify'),
- children: (
- <>
- {/* Beta tag with proper padding/margins */}
- <div
- css={css`
- padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px;
- `}
- >
- <Tooltip
- title={t(
- 'This feature is experimental and may change or have limitations',
- )}
- placement="right"
- >
- <Tag color="warning">{t('Beta!')}</Tag>
- </Tooltip>
- </div>
- <Collapse
- defaultActiveKey={expandedMatrixifySections}
- expandIconPosition="right"
- ghost
- bordered
- items={[...matrixifySections.map(renderControlPanelSection)]}
- />
- </>
- ),
- },
+ ...(showMatrixifyTab
+ ? [
+ {
+ key: TABS_KEYS.MATRIXIFY,
+ label: matrixifyTabLabel,
+ children: (
+ <Collapse
+ defaultActiveKey={expandedMatrixifySections}
+ expandIconPosition="right"
+ ghost
+ bordered
+ items={[
+ ...matrixifySections.map(renderControlPanelSection),
+ ]}
+ />
+ ),
+ },
+ ]
+ : []),
]}
/>
<div css={actionButtonsContainerStyles}>
diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx
index 49d9b02a63..c19a70704e 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx
@@ -242,20 +242,50 @@ const ExploreChartPanel = ({
{chartPanelWidth &&
chartPanelHeight &&
(matrixifyGrid ? (
- <MatrixifyGridRenderer
- grid={matrixifyGrid}
- chart={chart}
- actions={actions}
- datasource={datasource}
- vizType={vizType}
- onQuery={onQuery}
- timeout={timeout}
- ownState={ownState}
- triggerRender={triggerRender}
- force={force}
- chartIsStale={chartIsStale}
- errorMessage={errorMessage}
- />
+ <>
+ {/* Hidden ChartContainer to maintain Redux state flow */}
+ <div style={{ display: 'none' }}>
+ <ChartContainer
+ width={100}
+ height={100}
+ ownState={ownState}
+ annotationData={chart.annotationData}
+ chartAlert={chart.chartAlert}
+ chartStackTrace={chart.chartStackTrace}
+ chartId={chart.id}
+ chartStatus={chart.chartStatus}
+ triggerRender={triggerRender}
+ force={force}
+ datasource={datasource}
+ errorMessage={errorMessage}
+ formData={formData}
+ latestQueryFormData={chart.latestQueryFormData}
+ onQuery={onQuery}
+ queriesResponse={chart.queriesResponse}
+ chartIsStale={chartIsStale}
+ setControlValue={actions.setControlValue}
+ timeout={timeout}
+ triggerQuery={chart.triggerQuery}
+ vizType={vizType}
+ />
+ </div>
+ <MatrixifyGridRenderer
+ grid={matrixifyGrid}
+ chart={chart}
+ actions={actions}
+ datasource={datasource}
+ vizType={vizType}
+ onQuery={onQuery}
+ timeout={timeout}
+ ownState={ownState}
+ triggerRender={triggerRender}
+ force={force}
+ chartIsStale={chartIsStale}
+ errorMessage={errorMessage}
+ queriesData={chart.queriesResponse}
+ chartStatus={chart.chartStatus}
+ />
+ </>
) : (
<ChartContainer
width={Math.floor(chartPanelWidth)}
diff --git a/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridCell.tsx b/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridCell.tsx
new file mode 100644
index 0000000000..1f9161a224
--- /dev/null
+++ b/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridCell.tsx
@@ -0,0 +1,135 @@
+/**
+ * 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 { memo } from 'react';
+import { styled, SuperChart } from '@superset-ui/core';
+import { Card } from '@superset-ui/core/components';
+import { MatrixifyGridCell as GridCellData } from 'src/explore/types/matrixify';
+
+const StyledCard = styled(Card)`
+ height: 100%;
+
+ .ant-card-body {
+ padding: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+
+ /* Remove any padding/margins that might be causing title height issues */
+ & .chart-container {
+ padding-top: 0 !important;
+ }
+
+ /* Target title elements inside the chart container */
+ & .superchart-container .header-title,
+ & .superchart-container [class*='title'] {
+ display: none !important;
+ }
+`;
+
+interface MatrixifyGridCellProps {
+ cell: GridCellData;
+ rowHeight: number;
+}
+
+/**
+ * Individual grid cell component - memoized to prevent unnecessary re-renders
+ */
+const MatrixifyGridCell = memo(
+ ({ cell, rowHeight }: MatrixifyGridCellProps) => {
+ // Use computed title from template (will be empty string if no template)
+ const cellLabel = cell.title || '';
+
+ // Only show label if it has content
+ const showLabel = cellLabel && cellLabel.trim() !== '';
+
+ const cardProps: any = {
+ className: 'matrixify-cell',
+ size: 'small',
+ 'data-row': cell.row,
+ 'data-col': cell.col,
+ 'data-row-label': cell.rowLabel,
+ 'data-col-label': cell.colLabel,
+ styles: {
+ body: {
+ padding: 0,
+ height: '100%',
+ },
+ },
+ };
+
+ // Only add title and header styles if we have a label
+ if (showLabel) {
+ cardProps.title = cellLabel;
+ cardProps.styles.header = {
+ minHeight: 'auto',
+ padding: '4px 8px',
+ fontSize: '12px',
+ };
+ cardProps.styles.body.height = 'calc(100% - 32px)';
+ }
+
+ return (
+ <StyledCard {...cardProps}>
+ <SuperChart
+ chartType={cell.formData.viz_type}
+ width="100%"
+ height="100%"
+ formData={cell.formData}
+ queriesData={[]}
+ enableNoResults={false}
+ />
+ </StyledCard>
+ );
+ },
+ // Custom comparison function to prevent unnecessary re-renders
+ // Returns true to skip re-render, false to re-render
+ (prevProps, nextProps) => {
+ // Always re-render if formData changes
+ if (
+ JSON.stringify(prevProps.cell.formData) !==
+ JSON.stringify(nextProps.cell.formData)
+ ) {
+ return false;
+ }
+
+ // Re-render if rowHeight changes
+ if (prevProps.rowHeight !== nextProps.rowHeight) {
+ return false;
+ }
+
+ // Re-render if cell position changes (shouldn't happen, but just in case)
+ if (prevProps.cell.id !== nextProps.cell.id) {
+ return false;
+ }
+
+ // Re-render if title changes
+ if (prevProps.cell.title !== nextProps.cell.title) {
+ return false;
+ }
+
+ // Skip re-render if nothing important changed
+ return true;
+ },
+);
+
+MatrixifyGridCell.displayName = 'MatrixifyGridCell';
+
+export default MatrixifyGridCell;
diff --git a/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridGenerator.ts b/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridGenerator.ts
index 196d309d20..fad92cdd19 100644
--- a/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridGenerator.ts
+++ b/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridGenerator.ts
@@ -25,6 +25,7 @@ import {
getMatrixifyConfig,
MatrixifyAxisConfig,
} from 'src/explore/types/matrixify';
+import { generateCellTitle } from './templateUtils';
/**
* Extract label from a metric or dimension value
@@ -72,13 +73,35 @@ function generateCellFormData(
// Clone base form data without matrixify fields
const cellFormData: any = { ...baseFormData };
- // Remove all matrixify-specific fields
+ // Remove all matrixify-specific fields and fields that cause title rendering
+ const fieldsToRemove = [
+ 'slice_name',
+ 'slice_id',
+ 'annotation_layers',
+ 'cache_timeout',
+ 'url_params',
+ ];
Object.keys(cellFormData).forEach(key => {
- if (key.startsWith('matrixify_')) {
+ if (key.startsWith('matrixify_') || fieldsToRemove.includes(key)) {
delete cellFormData[key];
}
});
+ // Remove any title/header related settings
+ const titleRelatedFields = [
+ 'header_font_size',
+ 'subheader',
+ 'show_title',
+ 'title',
+ 'header_title_text_align',
+ 'header_text',
+ ];
+ titleRelatedFields.forEach(field => {
+ if (field in cellFormData) {
+ delete cellFormData[field];
+ }
+ });
+
// Add filters for dimension-based axes
const additionalFilters: AdhocFilter[] = [];
@@ -116,6 +139,8 @@ function generateCellFormData(
...(cellFormData.adhoc_filters || []),
...additionalFilters,
];
+
+ // Debug: Log the filters being applied
}
// Set metrics based on row/column configuration
@@ -223,12 +248,20 @@ export function generateMatrixifyGrid(
colCount > 1 ? col : null,
);
+ // Generate title using template if provided
+ const title = generateCellTitle(
+ rowLabel,
+ colLabel,
+ formData.matrixify_cell_title_template,
+ );
+
rowCells.push({
id,
row,
col,
rowLabel,
colLabel,
+ title,
formData: cellFormData,
});
}
diff --git a/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridRenderer.tsx b/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridRenderer.tsx
index 2b210200fa..6fa43996a4 100644
--- a/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridRenderer.tsx
+++ b/superset-frontend/src/explore/components/MatrixifyGrid/MatrixifyGridRenderer.tsx
@@ -19,33 +19,69 @@
import { useMemo } from 'react';
import { css, styled, t, useTheme } from '@superset-ui/core';
-import { Row, Col } from '@superset-ui/core/components';
-import ChartContainer from 'src/components/Chart/ChartContainer';
import { MatrixifyGrid } from 'src/explore/types/matrixify';
+import MatrixifyGridCell from './MatrixifyGridCell';
const GridContainer = styled.div`
width: 100%;
height: 100%;
- overflow: auto;
+ display: flex;
+ flex-direction: column;
padding: ${({ theme }) => theme.sizeUnit * 2}px;
+ box-sizing: border-box;
+ min-height: 0; /* Prevent flex item from forcing min height */
+`;
+
+const GridLayout = styled.div<{
+ columns: number;
+ hasRowHeaders: boolean;
+ rowHeight: number;
+ hasColumnHeaders: boolean;
+}>`
+ display: grid;
+ grid-template-columns: ${({ columns, hasRowHeaders }) =>
+ hasRowHeaders
+ ? `40px repeat(${columns}, minmax(0, 1fr))`
+ : `repeat(${columns}, minmax(0, 1fr))`};
+ ${({ hasColumnHeaders, rowHeight }) =>
+ hasColumnHeaders
+ ? `grid-template-rows: 40px; grid-auto-rows: ${rowHeight}px;`
+ : `grid-auto-rows: ${rowHeight}px;`}
+ gap: ${({ theme }) =>
+ theme.sizeUnit * 2}px; /* 16px to match dashboard grid gutter */
+ width: 100%;
+ overflow: auto;
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
`;
const GridHeader = styled.div`
background-color: ${({ theme }) => theme.colors.grayscale.light3};
- padding: ${({ theme }) => theme.sizeUnit * 2}px;
+ padding: ${({ theme }) => theme.sizeUnit}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
text-align: center;
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
-`;
+ font-size: ${({ theme }) => theme.fontSizeSM}px;
-const GridCell = styled.div`
- border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
- padding: ${({ theme }) => theme.sizeUnit}px;
- min-height: 300px;
- position: relative;
+ &.matrixify-row-header {
+ writing-mode: vertical-rl;
+ transform: rotate(-180deg);
+ padding: ${({ theme }) => theme.sizeUnit * 2}px
+ ${({ theme }) => theme.sizeUnit / 2}px;
+ min-width: 30px;
+ max-width: 40px;
+ }
+
+ &.matrixify-col-header {
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
`;
interface MatrixifyGridRendererProps {
@@ -61,6 +97,8 @@ interface MatrixifyGridRendererProps {
force?: boolean;
chartIsStale?: boolean;
errorMessage?: string;
+ queriesData?: any[]; // Query data from parent
+ chartStatus?: string; // Chart status from parent
}
export default function MatrixifyGridRenderer({
@@ -76,141 +114,106 @@ export default function MatrixifyGridRenderer({
force,
chartIsStale,
errorMessage,
+ queriesData,
+ chartStatus,
}: MatrixifyGridRendererProps) {
const theme = useTheme();
- // Calculate column width based on number of columns
- const colSpan = useMemo(() => {
- if (grid.colHeaders.length <= 1) return 24;
- if (grid.colHeaders.length === 2) return 12;
- if (grid.colHeaders.length === 3) return 8;
- if (grid.colHeaders.length === 4) return 6;
- return 6; // For 5+ columns, stick with span 6
- }, [grid.colHeaders.length]);
-
- // Render column headers
- const columnHeaders = useMemo(() => {
- if (grid.colHeaders.length === 1 && grid.colHeaders[0] === '') {
- return null; // Don't show empty header for single column
+ // Extract layout settings from base form data
+ const rowHeight = grid.baseFormData.matrixify_row_height || 300;
+ const fitColumnsDynamically =
+ grid.baseFormData.matrixify_fit_columns_dynamically !== false;
+ const chartsPerRow = grid.baseFormData.matrixify_charts_per_row || 4;
+ const showRowLabels = grid.baseFormData.matrixify_show_row_labels !== false;
+ const showColumnHeaders =
+ grid.baseFormData.matrixify_show_column_headers !== false;
+
+ // Determine if we should display row headers
+ const hasRowData = grid.rowHeaders.length > 1 && grid.rowHeaders[0] !== '';
+ const hasRowHeaders = hasRowData && showRowLabels;
+
+ // Calculate number of columns for CSS Grid
+ const gridColumns = useMemo(() => {
+ if (!fitColumnsDynamically) {
+ return chartsPerRow;
}
+ return grid.colHeaders.length || 1;
+ }, [grid.colHeaders.length, fitColumnsDynamically, chartsPerRow]);
+
+ // Render all grid cells in a single flat array for CSS Grid
+ const gridContent = useMemo(() => {
+ const cells: React.ReactNode[] = [];
+ // Add column headers if enabled
+ if (showColumnHeaders && grid.colHeaders.length > 0) {
+ // Add empty cell for row header column if needed
+ if (hasRowHeaders) {
+ cells.push(<div key="empty-corner" />);
+ }
+
+ // Add column headers
+ grid.colHeaders.forEach((header, colIdx) => {
+ cells.push(
+ <GridHeader
+ key={`col-header-${colIdx}`}
+ className="matrixify-col-header"
+ title={header}
+ >
+ {header}
+ </GridHeader>,
+ );
+ });
+ }
+
+ // Add rows with cells
+ grid.cells.forEach((row, rowIdx) => {
+ // Add row header if present
+ if (hasRowHeaders) {
+ cells.push(
+ <GridHeader
+ key={`row-header-${rowIdx}`}
+ className="matrixify-row-header"
+ title={grid.rowHeaders[rowIdx]}
+ css={css`
+ height: ${rowHeight}px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ `}
+ >
+ {grid.rowHeaders[rowIdx]}
+ </GridHeader>,
+ );
+ }
+
+ // Add cells
+ row.forEach(cell => {
+ if (!cell) return;
+ cells.push(
+ <MatrixifyGridCell key={cell.id} cell={cell} rowHeight={rowHeight} />,
+ );
+ });
+ });
+
+ return cells;
+ }, [grid, hasRowHeaders, rowHeight, showColumnHeaders]);
+
+ // Don't render the grid if we have no query data at all
+ if (!queriesData || queriesData.length === 0) {
return (
- <Row gutter={0}>
- {/* Empty cell for row header column */}
- {grid.rowHeaders.length > 1 && grid.rowHeaders[0] !== '' && (
- <Col span={4}>
- <div style={{ height: theme.sizeUnit * 8 }} />
- </Col>
- )}
- {/* Column headers */}
- {grid.colHeaders.map((header, idx) => (
- <Col key={`col-header-${idx}`} span={colSpan}>
- <GridHeader title={header}>{header}</GridHeader>
- </Col>
- ))}
- </Row>
+ <div
+ css={css`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: ${theme.colors.grayscale.base};
+ `}
+ >
+ {t('No data to display')}
+ </div>
);
- }, [grid.colHeaders, grid.rowHeaders, colSpan, theme.sizeUnit]);
-
- // Render grid rows
- const gridRows = useMemo(
- () =>
- grid.cells.map((row, rowIdx) => (
- <Row key={`row-${rowIdx}`} gutter={0}>
- {/* Row header */}
- {grid.rowHeaders.length > 1 && grid.rowHeaders[0] !== '' && (
- <Col span={4}>
- <GridHeader
- title={grid.rowHeaders[rowIdx]}
- css={css`
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- `}
- >
- {grid.rowHeaders[rowIdx]}
- </GridHeader>
- </Col>
- )}
- {/* Grid cells */}
- {row.map((cell, colIdx) => {
- if (!cell) return null;
-
- return (
- <Col key={cell.id} span={colSpan}>
- <GridCell>
- <div
- css={css`
- position: absolute;
- top: ${theme.sizeUnit}px;
- left: ${theme.sizeUnit}px;
- right: ${theme.sizeUnit}px;
- font-size: ${theme.fontSize}px;
- color: ${theme.colors.grayscale.base};
- text-align: center;
- z-index: 1;
- `}
- >
- {cell.rowLabel &&
- cell.colLabel &&
- cell.rowLabel !== cell.colLabel
- ? `${cell.rowLabel} × ${cell.colLabel}`
- : cell.rowLabel || cell.colLabel}
- </div>
- <div
- css={css`
- width: 100%;
- height: 300px;
- margin-top: ${theme.sizeUnit * 4}px;
- `}
- >
- <ChartContainer
- width={300} // Will be responsive based on container
- height={250}
- ownState={ownState}
- annotationData={chart.annotationData}
- chartAlert={chart.chartAlert}
- chartStackTrace={chart.chartStackTrace}
- chartId={`${chart.id}-${cell.id}`}
- chartStatus={chart.chartStatus}
- triggerRender={triggerRender}
- force={force}
- datasource={datasource}
- errorMessage={errorMessage}
- formData={cell.formData}
- onQuery={onQuery}
- queriesResponse={chart.queriesResponse}
- chartIsStale={chartIsStale}
- setControlValue={actions.setControlValue}
- timeout={timeout}
- triggerQuery={chart.triggerQuery}
- vizType={vizType}
- />
- </div>
- </GridCell>
- </Col>
- );
- })}
- </Row>
- )),
- [
- grid,
- chart,
- actions,
- datasource,
- vizType,
- onQuery,
- timeout,
- ownState,
- triggerRender,
- force,
- chartIsStale,
- errorMessage,
- colSpan,
- theme,
- ],
- );
+ }
if (grid.cells.length === 0) {
return (
@@ -229,9 +232,19 @@ export default function MatrixifyGridRenderer({
}
return (
- <GridContainer>
- {columnHeaders}
- {gridRows}
+ <GridContainer className="matrixify-container">
+ <GridLayout
+ className="matrixify-grid"
+ columns={gridColumns}
+ hasRowHeaders={hasRowHeaders}
+ hasColumnHeaders={showColumnHeaders && grid.colHeaders.length > 0}
+ rowHeight={rowHeight}
+ data-columns={gridColumns}
+ data-has-row-headers={hasRowHeaders}
+ data-total-cells={grid.cells.flat().length}
+ >
+ {gridContent}
+ </GridLayout>
</GridContainer>
);
}
diff --git a/superset-frontend/src/explore/components/MatrixifyGrid/templateUtils.ts b/superset-frontend/src/explore/components/MatrixifyGrid/templateUtils.ts
new file mode 100644
index 0000000000..ec7743dbd2
--- /dev/null
+++ b/superset-frontend/src/explore/components/MatrixifyGrid/templateUtils.ts
@@ -0,0 +1,59 @@
+/**
+ * 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 Handlebars from 'handlebars';
+
+/**
+ * Process a Handlebars template with the given context
+ * @param template The Handlebars template string
+ * @param context The context object with variables
+ * @returns The processed string or error message
+ */
+export function processHandlebarsTemplate(
+ template: string | undefined,
+ context: { rowLabel: string; colLabel: string },
+): string {
+ // If no template provided, return empty string (no title)
+ if (!template || template.trim() === '') {
+ return '';
+ }
+
+ try {
+ const compiledTemplate = Handlebars.compile(template);
+ return compiledTemplate(context);
+ } catch (error) {
+ // If template compilation fails, return empty string
+ return '';
+ }
+}
+
+/**
+ * Generate a cell title using the template or default formatting
+ * @param rowLabel The row label
+ * @param colLabel The column label
+ * @param template Optional Handlebars template
+ * @returns The formatted title
+ */
+export function generateCellTitle(
+ rowLabel: string,
+ colLabel: string,
+ template?: string,
+): string {
+ return processHandlebarsTemplate(template, { rowLabel, colLabel });
+}
diff --git a/superset-frontend/src/explore/controlPanels/sections.tsx b/superset-frontend/src/explore/controlPanels/sections.tsx
index c05f76fe7b..d426de8d31 100644
--- a/superset-frontend/src/explore/controlPanels/sections.tsx
+++ b/superset-frontend/src/explore/controlPanels/sections.tsx
@@ -277,21 +277,51 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [
function buildMatrixifySection(
axis: 'columns' | 'rows',
): ControlPanelSectionConfig {
+ const baseControls = [
+ [`matrixify_mode_${axis}`],
+ [`matrixify_${axis}`],
+ [`matrixify_dimension_selection_mode_${axis}`],
+ [`matrixify_dimension_${axis}`],
+ [`matrixify_topn_value_${axis}`],
+ [`matrixify_topn_metric_${axis}`],
+ [`matrixify_topn_order_${axis}`],
+ ];
+
+ // Add specific controls for each axis
+ if (axis === 'rows') {
+ // Add row height control at the beginning
+ baseControls.unshift(['matrixify_row_height']);
+ } else if (axis === 'columns') {
+ // Add fit columns control at the beginning
+ baseControls.unshift(['matrixify_fit_columns_dynamically']);
+ // Add charts per row after fit columns control (it will be second)
+ baseControls.splice(1, 0, ['matrixify_charts_per_row']);
+ }
+
return {
- label: axis === 'columns' ? t('Matrix Columns') : t('Matrix Rows'),
+ label: axis === 'columns' ? t('Columns') : t('Rows'),
expanded: true,
tabOverride: 'matrixify',
- controlSetRows: [
- [`matrixify_mode_${axis}`],
- [`matrixify_${axis}`],
- [`matrixify_dimension_selection_mode_${axis}`],
- [`matrixify_dimension_${axis}`],
- [`matrixify_topn_value_${axis}`],
- [`matrixify_topn_metric_${axis}`],
- [`matrixify_topn_order_${axis}`],
- ],
+ controlSetRows: baseControls,
};
}
export const matrixifyRows = buildMatrixifySection('rows');
export const matrixifyColumns = buildMatrixifySection('columns');
+
+export const matrixifyCells: ControlPanelSectionConfig = {
+ label: t('Cells'),
+ expanded: true,
+ tabOverride: 'matrixify',
+ controlSetRows: [['matrixify_cell_title_template']],
+};
+
+export const matrixifyMatrix: ControlPanelSectionConfig = {
+ label: t('Matrix'),
+ expanded: true,
+ tabOverride: 'matrixify',
+ controlSetRows: [
+ ['matrixify_show_row_labels'],
+ ['matrixify_show_column_headers'],
+ ],
+};
diff --git a/superset-frontend/src/explore/controlUtils/getSectionsToRender.ts b/superset-frontend/src/explore/controlUtils/getSectionsToRender.ts
index 77221fc66d..2d7a5c3b44 100644
--- a/superset-frontend/src/explore/controlUtils/getSectionsToRender.ts
+++ b/superset-frontend/src/explore/controlUtils/getSectionsToRender.ts
@@ -58,7 +58,13 @@ const getMemoizedSectionsToRender = memoizeOne(
}
});
- const { datasourceAndVizType, matrixifyRows, matrixifyColumns } = sections;
+ const {
+ datasourceAndVizType,
+ matrixifyRows,
+ matrixifyColumns,
+ matrixifyCells,
+ matrixifyMatrix,
+ } = sections;
// list of datasource-specific controls that should be removed if the datasource is a specific type
const filterControlsForTypes = [DatasourceType.Query, DatasourceType.Table];
@@ -68,8 +74,10 @@ const getMemoizedSectionsToRender = memoizeOne(
return [
datasourceAndVizType as ControlPanelSectionConfig,
- matrixifyRows as ControlPanelSectionConfig,
+ matrixifyCells as ControlPanelSectionConfig,
+ matrixifyMatrix as ControlPanelSectionConfig,
matrixifyColumns as ControlPanelSectionConfig,
+ matrixifyRows as ControlPanelSectionConfig,
]
.concat(controlPanelSections.filter(isControlPanelSectionConfig))
.map(section => {
diff --git a/superset-frontend/src/explore/types/matrixify.ts b/superset-frontend/src/explore/types/matrixify.ts
index d1bab8f1fa..20c3929636 100644
--- a/superset-frontend/src/explore/types/matrixify.ts
+++ b/superset-frontend/src/explore/types/matrixify.ts
@@ -89,6 +89,18 @@ export interface MatrixifyFormData {
matrixify_topn_value_columns?: number;
matrixify_topn_metric_columns?: AdhocMetric;
matrixify_topn_order_columns?: MatrixifySortOrder;
+
+ // Grid layout configuration
+ matrixify_row_height?: number;
+ matrixify_fit_columns_dynamically?: boolean;
+ matrixify_charts_per_row?: number;
+
+ // Cell configuration
+ matrixify_cell_title_template?: string;
+
+ // Matrix display configuration
+ matrixify_show_row_labels?: boolean;
+ matrixify_show_column_headers?: boolean;
}
/**
@@ -179,6 +191,9 @@ export interface MatrixifyGridCell {
/** Column label (metric name or dimension value) */
colLabel: string;
+ /** Computed title for the cell (from template or default) */
+ title?: string;
+
/** Form data for this specific cell's chart */
formData: any; // This will be QueryFormData with appropriate filters/metrics
}
diff --git a/superset/config.py b/superset/config.py
index 273f2232da..a83c6d5d98 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -615,6 +615,8 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
"AG_GRID_TABLE_ENABLED": False,
# Enable Table v2 time comparison feature
"TABLE_V2_TIME_COMPARISON_ENABLED": False,
+ # Enable Matrixify feature for matrix-style chart layouts
+ "MATRIXIFY": True,
}
# ------------------------------
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 400255dd07..6b3aeb18b8 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -1488,6 +1488,263 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
col = self.make_sqla_column_compatible(col, label)
return col
+ def _build_metric_expression(
+ self,
+ metric: Metric,
+ columns_by_name: dict[str, "TableColumn"],
+ metrics_by_name: dict[str, "SqlMetric"],
+ template_processor: Optional[BaseTemplateProcessor] = None,
+ ) -> ColumnElement:
+ """Convert a single metric (adhoc or predefined) to SQLAlchemy expression."""
+ if utils.is_adhoc_metric(metric):
+ assert isinstance(metric, dict)
+ return self.adhoc_metric_to_sqla(
+ metric=metric,
+ columns_by_name=columns_by_name,
+ template_processor=template_processor,
+ )
+ elif isinstance(metric, str) and metric in metrics_by_name:
+ return metrics_by_name[metric].get_sqla_col(
+ template_processor=template_processor
+ )
+ else:
+ raise QueryObjectValidationError(
+ _("Metric '%(metric)s' does not exist", metric=metric)
+ )
+
+ def _process_adhoc_sql_expression(
+ self,
+ expression: str,
+ template_processor: Optional[BaseTemplateProcessor] = None,
+ ) -> str:
+ """Process and validate an adhoc SQL expression with template processing."""
+ return self._process_sql_expression(
+ expression=expression,
+ database_id=self.database_id,
+ engine=self.database.backend,
+ schema=self.schema,
+ template_processor=template_processor,
+ )
+
+ def _normalize_column_labels(
+ self,
+ columns: Optional[list[Column]],
+ series_columns: Optional[list[Column]],
+ ) -> list[str]:
+ """Extract and normalize column labels for series columns."""
+ return [
+ self.db_engine_spec.make_label_compatible(column)
+ for column in utils.get_column_names(
+ columns=series_columns or [],
+ )
+ ]
+
+ def _build_top_groups_filter(
+ self,
+ df: pd.DataFrame,
+ groupby_series_columns: dict[str, Any],
+ columns_by_name: dict[str, "TableColumn"],
+ ) -> ColumnElement:
+ """Build SQL filter for top groups based on prequery results."""
+ dimensions = [
+ c
+ for c in df.columns
+ if c in groupby_series_columns
+ ]
+ return self._get_top_groups(
+ df, dimensions, groupby_series_columns, columns_by_name
+ )
+
+ def _get_series_orderby_expression(
+ self,
+ series_limit_metric: Optional[Metric],
+ main_metric_expr: ColumnElement,
+ metrics_by_name: dict[str, "SqlMetric"],
+ columns_by_name: dict[str, "TableColumn"],
+ template_processor: Optional[BaseTemplateProcessor] = None,
+ ) -> ColumnElement:
+ """Get the ORDER BY expression for series limit queries."""
+ if not series_limit_metric:
+ return main_metric_expr
+
+ return self._get_series_orderby(
+ series_limit_metric=series_limit_metric,
+ metrics_by_name=metrics_by_name,
+ columns_by_name=columns_by_name,
+ template_processor=template_processor,
+ )
+
+ def _normalize_filter_value(
+ self,
+ value: FilterValue,
+ target_generic_type: GenericDataType,
+ operator: utils.FilterOperator,
+ ) -> Optional[FilterValue]:
+ """Normalize a single filter value based on type and operator."""
+ if operator == utils.FilterOperator.TEMPORAL_RANGE:
+ return value
+ if (
+ isinstance(value, (float, int))
+ and target_generic_type == utils.GenericDataType.TEMPORAL
+ ):
+ # Note: This would need db_engine_spec and target_native_type
+ # passed in for full implementation
+ return value
+ if isinstance(value, str):
+ value = value.strip("\t\n")
+ if (
+ target_generic_type == utils.GenericDataType.NUMERIC
+ and operator
+ not in {
+ utils.FilterOperator.ILIKE,
+ utils.FilterOperator.LIKE,
+ }
+ ):
+ return utils.cast_to_num(value)
+ if value == NULL_STRING:
+ return None
+ if value == EMPTY_STRING:
+ return ""
+ if target_generic_type == utils.GenericDataType.BOOLEAN:
+ return utils.cast_to_boolean(value)
+ return value
+
+ def _build_time_filter_expression(
+ self,
+ col: ColumnElement,
+ start_dttm: Optional[datetime],
+ end_dttm: Optional[datetime],
+ time_col: "TableColumn",
+ ) -> ColumnElement:
+ """Build time range filter expression."""
+ filters = []
+ if start_dttm:
+ filters.append(
+ col >= self.db_engine_spec.get_text_clause(
+ self.dttm_sql_literal(start_dttm, time_col)
+ )
+ )
+ if end_dttm:
+ filters.append(
+ col < self.db_engine_spec.get_text_clause(
+ self.dttm_sql_literal(end_dttm, time_col)
+ )
+ )
+ return and_(*filters) if filters else sa.true()
+
+ def _wrap_query_for_rowcount(
+ self,
+ qry: Select,
+ ) -> tuple[Select, list[str]]:
+ """Wrap a query in COUNT(*) for row count queries."""
+ if not self.db_engine_spec.allows_subqueries:
+ raise QueryObjectValidationError(
+ _("Database does not support subqueries")
+ )
+ label = "rowcount"
+ col = self.make_sqla_column_compatible(literal_column("COUNT(*)"), label)
+ wrapped_qry = sa.select([col]).select_from(qry.alias("rowcount_qry"))
+ labels_expected = [label]
+ return wrapped_qry, labels_expected
+
+ def _create_others_case_expression(
+ self,
+ expr: ColumnElement,
+ condition: Any,
+ label: Optional[str] = None,
+ ) -> ColumnElement:
+ """Create CASE expression for 'Others' grouping."""
+ case_expr = sa.case([(condition, expr)], else_=sa.literal("Others"))
+ if label:
+ case_expr = self.make_sqla_column_compatible(case_expr, label)
+ return case_expr
+
+ def _apply_advanced_data_type_filter(
+ self,
+ sqla_col: Column,
+ col_advanced_data_type: str,
+ operator: utils.FilterOperator,
+ values: FilterValues,
+ ) -> ColumnElement:
+ """Apply advanced data type filtering if enabled."""
+ if (
+ col_advanced_data_type != ""
+ and feature_flag_manager.is_feature_enabled(
+ "ENABLE_ADVANCED_DATA_TYPES"
+ )
+ and col_advanced_data_type in ADVANCED_DATA_TYPES
+ ):
+ is_list_target = operator in (
+ utils.FilterOperator.IN,
+ utils.FilterOperator.NOT_IN,
+ )
+ values_list = values if is_list_target else [values] # type: ignore
+ bus_resp: AdvancedDataTypeResponse = ADVANCED_DATA_TYPES[
+ col_advanced_data_type
+ ].translate_type(
+ {
+ "type": col_advanced_data_type,
+ "values": values_list,
+ }
+ )
+ if bus_resp["error_message"]:
+ raise AdvancedDataTypeResponseError(
+ _(bus_resp["error_message"])
+ )
+
+ return ADVANCED_DATA_TYPES[col_advanced_data_type].translate_filter(
+ sqla_col, operator, bus_resp["values"]
+ )
+ else:
+ raise ValueError("Advanced data type not supported or not enabled")
+
+ def _apply_orderby_direction(
+ self,
+ qry: Select,
+ orderby_exprs: list[ColumnElement],
+ orderby: list[OrderBy],
+ select_exprs: list[Union[Column, Label]],
+ ) -> Select:
+ """Apply ORDER BY with proper direction and column handling."""
+ for col, (_orig_col, ascending) in zip(orderby_exprs, orderby, strict=False): # noqa: B007
+ if not self.db_engine_spec.allows_alias_in_orderby and isinstance(col, Label):
+ # if engine does not allow using SELECT alias in ORDER BY
+ # revert to the underlying column
+ col = col.element
+
+ if (
+ self.db_engine_spec.get_allows_alias_in_select(self.database)
+ and self.db_engine_spec.allows_hidden_cc_in_orderby
+ and col.name in [select_col.name for select_col in select_exprs]
+ ):
+ with self.database.get_sqla_engine() as engine:
+ quote = engine.dialect.identifier_preparer.quote
+ col = literal_column(quote(col.name))
+ direction = sa.asc if ascending else sa.desc
+ qry = qry.order_by(direction(col))
+ return qry
+
+ def _deduplicate_select_columns(
+ self,
+ select_exprs: list[Union[Column, Label]],
+ metrics_exprs: list[ColumnElement],
+ orderby_exprs: list[ColumnElement],
+ ) -> list[Union[Column, Label]]:
+ """Remove duplicate columns from SELECT clause."""
+ # Always remove duplicates by column name, as sometimes `metrics_exprs`
+ # can have the same name as a groupby column (e.g. when users use
+ # raw columns as custom SQL adhoc metric).
+ deduped = remove_duplicates(
+ select_exprs + metrics_exprs, key=lambda x: x.name
+ )
+
+ # Order by columns are "hidden" columns, some databases require them
+ # always be present in SELECT if an aggregation function is used
+ if not self.db_engine_spec.allows_hidden_orderby_agg:
+ deduped = remove_duplicates(deduped + orderby_exprs)
+
+ return deduped
+
def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-many-branches,too-many-statements # noqa: C901
self,
apply_fetch_values_predicate: bool = False,
@@ -1546,12 +1803,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
rejected_adhoc_filters_columns: list[Union[str, ColumnTyping]] = []
applied_adhoc_filters_columns: list[Union[str, ColumnTyping]] = []
db_engine_spec = self.db_engine_spec
- series_column_labels = [
- db_engine_spec.make_label_compatible(column)
- for column in utils.get_column_names(
- columns=series_columns or [],
- )
- ]
+ series_column_labels = self._normalize_column_labels(columns, series_columns)
# deprecated, to be removed in 2.0
if is_timeseries and timeseries_limit:
series_limit = timeseries_limit
@@ -1594,25 +1846,14 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
metrics_exprs: list[ColumnElement] = []
for metric in metrics:
- if utils.is_adhoc_metric(metric):
- assert isinstance(metric, dict)
- metrics_exprs.append(
- self.adhoc_metric_to_sqla(
- metric=metric,
- columns_by_name=columns_by_name,
- template_processor=template_processor,
- )
- )
- elif isinstance(metric, str) and metric in metrics_by_name:
- metrics_exprs.append(
- metrics_by_name[metric].get_sqla_col(
- template_processor=template_processor
- )
- )
- else:
- raise QueryObjectValidationError(
- _("Metric '%(metric)s' does not exist", metric=metric)
+ metrics_exprs.append(
+ self._build_metric_expression(
+ metric=metric,
+ columns_by_name=columns_by_name,
+ metrics_by_name=metrics_by_name,
+ template_processor=template_processor,
)
+ )
if metrics_exprs:
main_metric_expr = metrics_exprs[0]
@@ -1633,11 +1874,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if isinstance(col, dict):
col = cast(AdhocMetric, col)
if col.get("sqlExpression"):
- col["sqlExpression"] = self._process_sql_expression(
+ col["sqlExpression"] = self._process_adhoc_sql_expression(
expression=col["sqlExpression"],
- database_id=self.database_id,
- engine=self.database.backend,
- schema=self.schema,
template_processor=template_processor,
)
if utils.is_adhoc_metric(col):
@@ -1787,21 +2025,14 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
)
time_filters.append(time_filter_column)
- # Always remove duplicates by column name, as sometimes `metrics_exprs`
- # can have the same name as a groupby column (e.g. when users use
- # raw columns as custom SQL adhoc metric).
- select_exprs = remove_duplicates(
- select_exprs + metrics_exprs, key=lambda x: x.name
+ # Deduplicate select columns
+ select_exprs = self._deduplicate_select_columns(
+ select_exprs, metrics_exprs, orderby_exprs
)
# Expected output columns
labels_expected = [c.key for c in select_exprs]
- # Order by columns are "hidden" columns, some databases require them
- # always be present in SELECT if an aggregation function is used
- if not db_engine_spec.allows_hidden_orderby_agg:
- select_exprs = remove_duplicates(select_exprs + orderby_exprs)
-
qry = sa.select(select_exprs)
tbl, cte = self.get_from_clause(template_processor)
@@ -1877,25 +2108,15 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
)
and col_advanced_data_type in ADVANCED_DATA_TYPES
):
- values = eq if is_list_target else [eq] # type: ignore
- bus_resp: AdvancedDataTypeResponse = ADVANCED_DATA_TYPES[
- col_advanced_data_type
- ].translate_type(
- {
- "type": col_advanced_data_type,
- "values": values,
- }
- )
- if bus_resp["error_message"]:
- raise AdvancedDataTypeResponseError(
- _(bus_resp["error_message"])
- )
-
- where_clause_and.append(
- ADVANCED_DATA_TYPES[col_advanced_data_type].translate_filter(
- sqla_col, op, bus_resp["values"]
+ try:
+ where_clause_and.append(
+ self._apply_advanced_data_type_filter(
+ sqla_col, col_advanced_data_type, op, eq
+ )
)
- )
+ except ValueError:
+ # Not an advanced data type, continue with regular processing
+ pass
elif is_list_target:
assert isinstance(eq, (tuple, list))
if len(eq) == 0:
@@ -2031,20 +2252,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
self.make_orderby_compatible(select_exprs, orderby_exprs)
- for col, (_orig_col, ascending) in zip(orderby_exprs, orderby, strict=False): # noqa: B007
- if not db_engine_spec.allows_alias_in_orderby and isinstance(col, Label):
- # if engine does not allow using SELECT alias in ORDER BY
- # revert to the underlying column
- col = col.element
-
- if (
- db_engine_spec.get_allows_alias_in_select(self.database)
- and db_engine_spec.allows_hidden_cc_in_orderby
- and col.name in [select_col.name for select_col in select_exprs]
- ):
- col = literal_column(quote(col.name))
- direction = sa.asc if ascending else sa.desc
- qry = qry.order_by(direction(col))
+ # Apply ORDER BY with proper direction
+ qry = self._apply_orderby_direction(qry, orderby_exprs, orderby, select_exprs)
if row_limit:
qry = qry.limit(row_limit)
@@ -2082,14 +2291,13 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
subq = subq.where(and_(*(where_clause_and + inner_time_filter)))
subq = subq.group_by(*inner_groupby_exprs)
- ob = inner_main_metric_expr
- if series_limit_metric:
- ob = self._get_series_orderby(
- series_limit_metric=series_limit_metric,
- metrics_by_name=metrics_by_name,
- columns_by_name=columns_by_name,
- template_processor=template_processor,
- )
+ ob = self._get_series_orderby_expression(
+ series_limit_metric=series_limit_metric,
+ main_metric_expr=inner_main_metric_expr,
+ metrics_by_name=metrics_by_name,
+ columns_by_name=columns_by_name,
+ template_processor=template_processor,
+ )
direction = sa.desc if order_desc else sa.asc
subq = subq.order_by(direction(ob))
subq = subq.limit(series_limit)
@@ -2143,8 +2351,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if series_limit_metric:
orderby = [
(
- self._get_series_orderby(
+ self._get_series_orderby_expression(
series_limit_metric=series_limit_metric,
+ main_metric_expr=main_metric_expr,
metrics_by_name=metrics_by_name,
columns_by_name=columns_by_name,
template_processor=template_processor,
@@ -2176,8 +2385,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
for c in result.df.columns
if c not in metrics and c in groupby_series_columns
]
- top_groups = self._get_top_groups(
- result.df, dimensions, groupby_series_columns, columns_by_name
+ top_groups = self._build_top_groups_filter(
+ result.df, groupby_series_columns, columns_by_name
)
if group_others_when_limit_reached:
@@ -2205,14 +2414,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
qry = qry.select_from(tbl)
if is_rowcount:
- if not db_engine_spec.allows_subqueries:
- raise QueryObjectValidationError(
- _("Database does not support subqueries")
- )
- label = "rowcount"
- col = self.make_sqla_column_compatible(literal_column("COUNT(*)"), label)
- qry = sa.select([col]).select_from(qry.alias("rowcount_qry"))
- labels_expected = [label]
+ qry, labels_expected = self._wrap_query_for_rowcount(qry)
filter_columns = [flt.get("col") for flt in filter] if filter else []
rejected_filter_columns = [