mirror of
https://github.com/apache/superset.git
synced 2026-07-18 04:35:40 +00:00
Compare commits
6 Commits
feat/pie-g
...
feat/repla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd03dec23d | ||
|
|
0f61d9a3fb | ||
|
|
b432f8c917 | ||
|
|
25f6c6c80b | ||
|
|
ce8d4397cd | ||
|
|
a0c960f253 |
@@ -737,6 +737,7 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
|
||||
---
|
||||
|
||||
- [38358](https://github.com/apache/superset/pull/38358): Switched CrateDB PyPI package from `crate[sqlalchemy]` to `sqlalchemy-cratedb`.
|
||||
- [35621](https://github.com/apache/superset/pull/35621): The default hash algorithm has changed from MD5 to SHA-256 for improved security and FedRAMP compliance. This affects cache keys for thumbnails, dashboard digests, chart digests, and filter option names. Existing cached data will be invalidated upon upgrade. To opt out of this change and maintain backward compatibility, set `HASH_ALGORITHM = "md5"` in your `superset_config.py`.
|
||||
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
|
||||
|
||||
|
||||
@@ -3672,7 +3672,7 @@
|
||||
"drivers": [
|
||||
{
|
||||
"name": "crate",
|
||||
"pypi_package": "crate[sqlalchemy]",
|
||||
"pypi_package": "sqlalchemy-cratedb",
|
||||
"connection_string": "crate://{host}:{port}",
|
||||
"is_recommended": true
|
||||
}
|
||||
|
||||
130
superset-frontend/package-lock.json
generated
130
superset-frontend/package-lock.json
generated
@@ -247,7 +247,6 @@
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
"history": "^5.3.0",
|
||||
"html-webpack-plugin": "^5.6.7",
|
||||
"http-server": "^14.1.1",
|
||||
"imports-loader": "^5.0.0",
|
||||
"jest": "^30.4.2",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
@@ -15247,26 +15246,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/basic-auth/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/batch": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
|
||||
@@ -17248,16 +17227,6 @@
|
||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/corser": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz",
|
||||
"integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||
@@ -23617,61 +23586,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/http-server": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz",
|
||||
"integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "^2.0.1",
|
||||
"chalk": "^4.1.2",
|
||||
"corser": "^2.0.1",
|
||||
"he": "^1.2.0",
|
||||
"html-encoding-sniffer": "^3.0.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"mime": "^1.6.0",
|
||||
"minimist": "^1.2.6",
|
||||
"opener": "^1.5.1",
|
||||
"portfinder": "^1.0.28",
|
||||
"secure-compare": "3.0.1",
|
||||
"union": "~0.5.0",
|
||||
"url-join": "^4.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"http-server": "bin/http-server"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/http-server/node_modules/html-encoding-sniffer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
"integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/http-server/node_modules/whatwg-encoding": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
|
||||
"integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
@@ -33085,20 +32999,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/portfinder": {
|
||||
"version": "1.0.38",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
|
||||
"integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"debug": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.12"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||
@@ -37024,13 +36924,6 @@
|
||||
"compute-scroll-into-view": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/secure-compare": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz",
|
||||
"integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seedrandom": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||
@@ -40835,18 +40728,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/union": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz",
|
||||
"integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"qs": "^6.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/union-value": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
|
||||
@@ -41168,13 +41049,6 @@
|
||||
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/url-join": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
|
||||
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/url-template": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||
@@ -44218,6 +44092,10 @@
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.7",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.4.2"
|
||||
},
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
|
||||
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
|
||||
"test-storybook": "test-storybook",
|
||||
"test-storybook:ci": "concurrently --kill-others --success first --names \"SB,TEST\" --prefix-colors \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
|
||||
"test-storybook:ci": "concurrently --kill-others --success first --names \"SB,TEST\" --prefix-colors \"magenta,blue\" \"python3 -m http.server 6006 --directory storybook-static\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
|
||||
"tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --watch",
|
||||
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
|
||||
"test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%",
|
||||
@@ -332,7 +332,6 @@
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
"history": "^5.3.0",
|
||||
"html-webpack-plugin": "^5.6.7",
|
||||
"http-server": "^14.1.1",
|
||||
"imports-loader": "^5.0.0",
|
||||
"jest": "^30.4.2",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.7",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.4.2"
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ export const Styles = styled.div<{ isDashboardEditMode: boolean }>`
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
table.pvtTable tbody tr th.pvtRowLabel.pvtRowLabelLast {
|
||||
border-bottom: 1px solid ${theme.colorSplit};
|
||||
}
|
||||
|
||||
.pvtTotal,
|
||||
.pvtGrandTotal {
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
|
||||
@@ -133,6 +133,7 @@ interface PivotSettings {
|
||||
maxColVisible?: number;
|
||||
rowAttrSpans?: number[][];
|
||||
colAttrSpans?: number[][];
|
||||
visibleRowCount?: number;
|
||||
}
|
||||
|
||||
const parseLabel = (value: unknown): string | number => {
|
||||
@@ -804,6 +805,7 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
maxColVisible: Math.max(...visibleColKeys.map((k: string[]) => k.length)),
|
||||
rowAttrSpans: calcAttrSpans(visibleRowKeys, rowAttrs.length),
|
||||
colAttrSpans: calcAttrSpans(visibleColKeys, colAttrs.length),
|
||||
visibleRowCount: visibleRowKeys.length + (colTotals ? 1 : 0),
|
||||
allowRenderHtml,
|
||||
...basePivotSettings,
|
||||
};
|
||||
@@ -1228,6 +1230,7 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
rowTotalCallbacks,
|
||||
namesMapping,
|
||||
allowRenderHtml: settingsAllowRenderHtml,
|
||||
visibleRowCount,
|
||||
} = settings;
|
||||
|
||||
const {
|
||||
@@ -1264,6 +1267,11 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
}
|
||||
const rowSpan = rowAttrSpans![rowIdx][i];
|
||||
if (rowSpan > 0) {
|
||||
const isLastRow = rowIdx + rowSpan === visibleRowCount;
|
||||
let cellClassName = valueCellClassName;
|
||||
if (isLastRow) {
|
||||
cellClassName += ' pvtRowLabelLast';
|
||||
}
|
||||
const flatRowKeySlice = flatKey(rowKey.slice(0, i + 1));
|
||||
const colSpan =
|
||||
1 + (i === settingsRowAttrs.length - 1 ? colIncrSpan : 0);
|
||||
@@ -1298,7 +1306,7 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
return (
|
||||
<th
|
||||
key={`rowKeyLabel-${i}`}
|
||||
className={valueCellClassName}
|
||||
className={cellClassName}
|
||||
style={rowHeaderStyle}
|
||||
rowSpan={rowSpan}
|
||||
colSpan={colSpan}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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 '@testing-library/jest-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import PivotTableChart from '../src/PivotTableChart';
|
||||
import transformProps from '../src/plugin/transformProps';
|
||||
import testData from './testData';
|
||||
import { ProviderWrapper } from './testHelpers';
|
||||
|
||||
test('applies pvtRowLabelLast class to last data row when colTotals is disabled', () => {
|
||||
const transformedProps = {
|
||||
...transformProps(testData.withoutColTotals),
|
||||
margin: 32,
|
||||
legacy_order_by: null,
|
||||
order_desc: false,
|
||||
};
|
||||
const { container } = render(
|
||||
ProviderWrapper({
|
||||
children: <PivotTableChart {...transformedProps} />,
|
||||
}),
|
||||
);
|
||||
|
||||
const tableBody = container.querySelector('tbody');
|
||||
const dataRows = Array.from(tableBody?.querySelectorAll('tr') ?? []).filter(
|
||||
row => !row.classList.contains('pvtRowTotals'),
|
||||
);
|
||||
|
||||
// Get the last data row
|
||||
const lastDataRow = dataRows[dataRows.length - 1];
|
||||
expect(lastDataRow).toBeInTheDocument();
|
||||
|
||||
// Check if any cell in the last data row has pvtRowLabelLast class
|
||||
const lastRowCells = lastDataRow.querySelectorAll('th.pvtRowLabel');
|
||||
const hasLastClass = Array.from(lastRowCells).some(cell =>
|
||||
cell.classList.contains('pvtRowLabelLast'),
|
||||
);
|
||||
|
||||
expect(hasLastClass).toBe(true);
|
||||
});
|
||||
|
||||
test('does not apply pvtRowLabelLast class to last data row when colTotals is enabled', () => {
|
||||
const transformedProps = {
|
||||
...transformProps(testData.withColTotals),
|
||||
margin: 32,
|
||||
legacy_order_by: null,
|
||||
order_desc: false,
|
||||
};
|
||||
const { container } = render(
|
||||
ProviderWrapper({
|
||||
children: <PivotTableChart {...transformedProps} />,
|
||||
}),
|
||||
);
|
||||
|
||||
const tableBody = container.querySelector('tbody');
|
||||
const dataRows = Array.from(tableBody?.querySelectorAll('tr') ?? []).filter(
|
||||
row => !row.classList.contains('pvtRowTotals'),
|
||||
);
|
||||
|
||||
// Get the last data row (before totals row)
|
||||
const lastDataRow = dataRows[dataRows.length - 1];
|
||||
expect(lastDataRow).toBeInTheDocument();
|
||||
|
||||
// Check if any cell in the last data row has pvtRowLabelLast class
|
||||
const lastRowCells = lastDataRow.querySelectorAll('th.pvtRowLabel');
|
||||
const hasLastClass = Array.from(lastRowCells).some(cell =>
|
||||
cell.classList.contains('pvtRowLabelLast'),
|
||||
);
|
||||
|
||||
// Should NOT have the class because totals row will have the border
|
||||
expect(hasLastClass).toBe(false);
|
||||
|
||||
// Verify totals row exists
|
||||
const totalsRow = container.querySelector('tr.pvtRowTotals');
|
||||
expect(totalsRow).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('applies pvtRowLabelLast to the spanning outer <th> that bottoms out the table', () => {
|
||||
// Two row dimensions ([country, city]) so the "Spain" outer <th> spans two
|
||||
// data rows (rowSpan 2). This is the merged-cell case #36031 is about; the
|
||||
// existing flat fixtures (every rowSpan 1) never exercise it.
|
||||
const transformedProps = {
|
||||
...transformProps(testData.groupedRowsWithoutColTotals),
|
||||
margin: 32,
|
||||
legacy_order_by: null,
|
||||
order_desc: false,
|
||||
};
|
||||
const { container } = render(
|
||||
ProviderWrapper({
|
||||
children: <PivotTableChart {...transformedProps} />,
|
||||
}),
|
||||
);
|
||||
|
||||
// Find the outer row-label <th> that spans multiple data rows.
|
||||
const spanningLabels = Array.from(
|
||||
container.querySelectorAll('tbody th.pvtRowLabel'),
|
||||
).filter(cell => Number(cell.getAttribute('rowspan')) >= 2);
|
||||
|
||||
// Sanity check: the fixture actually produced a merged cell.
|
||||
expect(spanningLabels.length).toBeGreaterThan(0);
|
||||
|
||||
const spanningCell = spanningLabels[spanningLabels.length - 1];
|
||||
expect(spanningCell).toHaveTextContent('Spain');
|
||||
expect(spanningCell.getAttribute('rowspan')).toBe('2');
|
||||
|
||||
// The spanning cell bottoms out the table (rowIdx + rowSpan === visibleRowCount)
|
||||
// so it must carry the border class.
|
||||
expect(spanningCell).toHaveClass('pvtRowLabelLast');
|
||||
});
|
||||
|
||||
test('withholds pvtRowLabelLast from the spanning <th> when colTotals owns the bottom', () => {
|
||||
const transformedProps = {
|
||||
...transformProps(testData.groupedRowsWithColTotals),
|
||||
margin: 32,
|
||||
legacy_order_by: null,
|
||||
order_desc: false,
|
||||
};
|
||||
const { container } = render(
|
||||
ProviderWrapper({
|
||||
children: <PivotTableChart {...transformedProps} />,
|
||||
}),
|
||||
);
|
||||
|
||||
const spanningLabels = Array.from(
|
||||
container.querySelectorAll('tbody th.pvtRowLabel'),
|
||||
).filter(cell => Number(cell.getAttribute('rowspan')) >= 2);
|
||||
|
||||
expect(spanningLabels.length).toBeGreaterThan(0);
|
||||
|
||||
const spanningCell = spanningLabels[spanningLabels.length - 1];
|
||||
expect(spanningCell).toHaveTextContent('Spain');
|
||||
|
||||
// The totals row now bottoms out the table, so no row-label <th> should get
|
||||
// the border class.
|
||||
const anyLabelHasLastClass = Array.from(
|
||||
container.querySelectorAll('tbody th.pvtRowLabel'),
|
||||
).some(cell => cell.classList.contains('pvtRowLabelLast'));
|
||||
expect(anyLabelHasLastClass).toBe(false);
|
||||
|
||||
// The totals row owns the bottom edge instead.
|
||||
const totalsRow = container.querySelector('tr.pvtRowTotals');
|
||||
expect(totalsRow).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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 {
|
||||
ChartDataResponseResult,
|
||||
ChartProps,
|
||||
DatasourceType,
|
||||
VizType,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
|
||||
const basicFormData: QueryFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: VizType.PivotTable,
|
||||
groupbyRows: ['country'],
|
||||
groupbyColumns: ['city'],
|
||||
metrics: ['SUM(sales)'],
|
||||
metricsLayout: 'COLUMNS',
|
||||
rowOrder: 'key_a_to_z',
|
||||
colOrder: 'key_a_to_z',
|
||||
aggregateFunction: 'Sum',
|
||||
rowSubTotals: false,
|
||||
colTotals: true,
|
||||
colSubTotals: false,
|
||||
rowTotals: true,
|
||||
valueFormat: 'SMART_NUMBER',
|
||||
dateFormat: 'smart_date',
|
||||
transposePivot: false,
|
||||
combineMetric: false,
|
||||
rowSubtotalPosition: false,
|
||||
colSubtotalPosition: false,
|
||||
};
|
||||
|
||||
const basicChartProps = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
datasource: {
|
||||
id: 1,
|
||||
name: 'test_dataset',
|
||||
type: DatasourceType.Table,
|
||||
columns: [],
|
||||
metrics: [],
|
||||
columnFormats: {},
|
||||
verboseMap: {},
|
||||
},
|
||||
hooks: {},
|
||||
initialValues: {},
|
||||
queriesData: [
|
||||
{
|
||||
data: {
|
||||
columns: [],
|
||||
records: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
formData: basicFormData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
const basicQueryResult: ChartDataResponseResult = {
|
||||
annotation_data: null,
|
||||
cache_key: null,
|
||||
cached_dttm: null,
|
||||
cache_timeout: null,
|
||||
data: [],
|
||||
colnames: [],
|
||||
coltypes: [],
|
||||
error: null,
|
||||
is_cached: false,
|
||||
query: 'SELECT ...',
|
||||
rowcount: 100,
|
||||
sql_rowcount: 100,
|
||||
stacktrace: null,
|
||||
status: 'success',
|
||||
from_dttm: null,
|
||||
to_dttm: null,
|
||||
queried_dttm: null,
|
||||
};
|
||||
|
||||
// Shared test data
|
||||
const pivotData = [
|
||||
{ country: 'France', city: 'Paris', 'SUM(sales)': 1000 },
|
||||
{ country: 'Germany', city: 'Berlin', 'SUM(sales)': 2000 },
|
||||
{ country: 'Spain', city: 'Madrid', 'SUM(sales)': 1500 },
|
||||
{ country: 'Italy', city: 'Rome', 'SUM(sales)': 3000 },
|
||||
{ country: 'UK', city: 'London', 'SUM(sales)': 2500 },
|
||||
];
|
||||
|
||||
// Shared query result structure
|
||||
const basicQueriesData = [
|
||||
{
|
||||
...basicQueryResult,
|
||||
colnames: ['country', 'city', 'SUM(sales)'],
|
||||
coltypes: [
|
||||
GenericDataType.String,
|
||||
GenericDataType.String,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
data: pivotData,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Pivot table data with colTotals enabled
|
||||
*/
|
||||
const withColTotals = {
|
||||
...new ChartProps({
|
||||
...basicChartProps,
|
||||
formData: {
|
||||
...basicFormData,
|
||||
colTotals: true,
|
||||
rowTotals: true,
|
||||
rowSubTotals: false,
|
||||
colSubTotals: false,
|
||||
},
|
||||
}),
|
||||
queriesData: basicQueriesData,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pivot table data without colTotals
|
||||
*/
|
||||
const withoutColTotals = {
|
||||
...new ChartProps({
|
||||
...basicChartProps,
|
||||
formData: {
|
||||
...basicFormData,
|
||||
colTotals: false,
|
||||
rowTotals: false,
|
||||
rowSubTotals: false,
|
||||
colSubTotals: false,
|
||||
},
|
||||
}),
|
||||
queriesData: basicQueriesData,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two-dimension rows data so an outer row-label <th> spans multiple data rows.
|
||||
* Grouped by [country, city] with "Spain" (last alphabetically) owning two
|
||||
* cities, so the "Spain" <th> has rowSpan >= 2 and bottoms out the table.
|
||||
*
|
||||
* visibleRowKeys order (rowOrder key_a_to_z):
|
||||
* 0: [France, Paris]
|
||||
* 1: [Germany, Berlin]
|
||||
* 2: [Spain, Barcelona]
|
||||
* 3: [Spain, Madrid]
|
||||
*/
|
||||
const groupedRowsData = [
|
||||
{ country: 'France', city: 'Paris', 'SUM(sales)': 1000 },
|
||||
{ country: 'Germany', city: 'Berlin', 'SUM(sales)': 2000 },
|
||||
{ country: 'Spain', city: 'Barcelona', 'SUM(sales)': 1200 },
|
||||
{ country: 'Spain', city: 'Madrid', 'SUM(sales)': 1500 },
|
||||
];
|
||||
|
||||
const groupedRowsQueriesData = [
|
||||
{
|
||||
...basicQueryResult,
|
||||
colnames: ['country', 'city', 'SUM(sales)'],
|
||||
coltypes: [
|
||||
GenericDataType.String,
|
||||
GenericDataType.String,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
data: groupedRowsData,
|
||||
},
|
||||
];
|
||||
|
||||
const groupedRowsFormData: QueryFormData = {
|
||||
...basicFormData,
|
||||
groupbyRows: ['country', 'city'],
|
||||
groupbyColumns: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Two row dimensions, colTotals disabled: the bottom-most spanning <th>
|
||||
* ("Spain", rowSpan 2) should own the bottom edge.
|
||||
*/
|
||||
const groupedRowsWithoutColTotals = {
|
||||
...new ChartProps({
|
||||
...basicChartProps,
|
||||
formData: {
|
||||
...groupedRowsFormData,
|
||||
colTotals: false,
|
||||
rowTotals: false,
|
||||
rowSubTotals: false,
|
||||
colSubTotals: false,
|
||||
},
|
||||
}),
|
||||
queriesData: groupedRowsQueriesData,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two row dimensions, colTotals enabled: the totals row owns the bottom edge,
|
||||
* so no row-label <th> (spanning or otherwise) gets pvtRowLabelLast.
|
||||
*/
|
||||
const groupedRowsWithColTotals = {
|
||||
...new ChartProps({
|
||||
...basicChartProps,
|
||||
formData: {
|
||||
...groupedRowsFormData,
|
||||
colTotals: true,
|
||||
rowTotals: false,
|
||||
rowSubTotals: false,
|
||||
colSubTotals: false,
|
||||
},
|
||||
}),
|
||||
queriesData: groupedRowsQueriesData,
|
||||
};
|
||||
|
||||
export default {
|
||||
withColTotals,
|
||||
withoutColTotals,
|
||||
groupedRowsWithoutColTotals,
|
||||
groupedRowsWithColTotals,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 {
|
||||
supersetTheme,
|
||||
ThemeProvider,
|
||||
EmotionCacheProvider,
|
||||
createEmotionCache,
|
||||
} from '@apache-superset/core/theme';
|
||||
|
||||
const emotionCache = createEmotionCache({
|
||||
key: 'test',
|
||||
});
|
||||
|
||||
export function ProviderWrapper(props: any) {
|
||||
const { children, theme = supersetTheme } = props;
|
||||
return (
|
||||
<EmotionCacheProvider value={emotionCache}>
|
||||
<ThemeProvider theme={theme}>{children}</ThemeProvider>
|
||||
</EmotionCacheProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"emitDeclarationOnly": false,
|
||||
"rootDir": "../../../"
|
||||
},
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
|
||||
}
|
||||
@@ -728,15 +728,17 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
|
||||
"Could not import libraries needed to connect to BigQuery."
|
||||
)
|
||||
|
||||
project: str | None = engine.url.host or None
|
||||
|
||||
if credentials_info := engine.dialect.credentials_info:
|
||||
credentials = service_account.Credentials.from_service_account_info(
|
||||
credentials_info
|
||||
)
|
||||
return bigquery.Client(credentials=credentials)
|
||||
return bigquery.Client(credentials=credentials, project=project)
|
||||
|
||||
try:
|
||||
credentials = google.auth.default()[0]
|
||||
return bigquery.Client(credentials=credentials)
|
||||
return bigquery.Client(credentials=credentials, project=project)
|
||||
except google.auth.exceptions.DefaultCredentialsError as ex:
|
||||
raise SupersetDBAPIConnectionError(
|
||||
"The database credentials could not be found."
|
||||
|
||||
@@ -49,7 +49,7 @@ class CrateEngineSpec(BaseEngineSpec):
|
||||
"drivers": [
|
||||
{
|
||||
"name": "crate",
|
||||
"pypi_package": "crate[sqlalchemy]",
|
||||
"pypi_package": "sqlalchemy-cratedb",
|
||||
"connection_string": "crate://{host}:{port}",
|
||||
"is_recommended": True,
|
||||
},
|
||||
|
||||
@@ -430,6 +430,78 @@ def test_get_default_catalog(mocker: MockerFixture) -> None:
|
||||
assert BigQueryEngineSpec.get_default_catalog(database) == "project"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sqlalchemy_uri", "schema", "expected_project"),
|
||||
[
|
||||
("bigquery://uri-project", None, "uri-project"),
|
||||
("bigquery:///uri-project", None, "uri-project"),
|
||||
("bigquery://", "dataset_name", None),
|
||||
],
|
||||
)
|
||||
def test_get_client_resolves_uri_project_with_service_account_credentials(
|
||||
mocker: MockerFixture,
|
||||
sqlalchemy_uri: str,
|
||||
schema: str | None,
|
||||
expected_project: str | None,
|
||||
) -> None:
|
||||
"""Test that service-account clients use the project from the engine URI."""
|
||||
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
|
||||
|
||||
credentials_info = {"project_id": "credential-project"}
|
||||
credentials = mock.Mock()
|
||||
engine = mock.MagicMock()
|
||||
engine.url = BigQueryEngineSpec.adjust_engine_params(
|
||||
make_url(sqlalchemy_uri), {}, schema=schema
|
||||
)[0]
|
||||
engine.dialect.credentials_info = credentials_info
|
||||
create_credentials = mocker.patch(
|
||||
"superset.db_engine_specs.bigquery.service_account.Credentials."
|
||||
"from_service_account_info",
|
||||
return_value=credentials,
|
||||
)
|
||||
client = mocker.patch("superset.db_engine_specs.bigquery.bigquery.Client")
|
||||
|
||||
BigQueryEngineSpec._get_client(engine, mock.Mock())
|
||||
|
||||
create_credentials.assert_called_once_with(credentials_info)
|
||||
client.assert_called_once_with(credentials=credentials, project=expected_project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sqlalchemy_uri", "schema", "expected_project"),
|
||||
[
|
||||
("bigquery://uri-project", None, "uri-project"),
|
||||
("bigquery:///uri-project", None, "uri-project"),
|
||||
("bigquery://", "dataset_name", None),
|
||||
],
|
||||
)
|
||||
def test_get_client_resolves_uri_project_with_application_default_credentials(
|
||||
mocker: MockerFixture,
|
||||
sqlalchemy_uri: str,
|
||||
schema: str | None,
|
||||
expected_project: str | None,
|
||||
) -> None:
|
||||
"""Test that ADC clients use the project from the engine URI."""
|
||||
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
|
||||
|
||||
credentials = mock.Mock()
|
||||
engine = mock.MagicMock()
|
||||
engine.url = BigQueryEngineSpec.adjust_engine_params(
|
||||
make_url(sqlalchemy_uri), {}, schema=schema
|
||||
)[0]
|
||||
engine.dialect.credentials_info = None
|
||||
get_default_credentials = mocker.patch(
|
||||
"superset.db_engine_specs.bigquery.google.auth.default",
|
||||
return_value=(credentials, "credential-project"),
|
||||
)
|
||||
client = mocker.patch("superset.db_engine_specs.bigquery.bigquery.Client")
|
||||
|
||||
BigQueryEngineSpec._get_client(engine, mock.Mock())
|
||||
|
||||
get_default_credentials.assert_called_once_with()
|
||||
client.assert_called_once_with(credentials=credentials, project=expected_project)
|
||||
|
||||
|
||||
def test_get_time_partition_column_uses_catalog_in_table_reference(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
|
||||
@@ -684,6 +684,136 @@ def test_user_macros_without_user_info(mocker: MockerFixture):
|
||||
assert cache.current_user_rls_rules() is None
|
||||
|
||||
|
||||
def _user_metadata_cache_keys(
|
||||
mocker: MockerFixture,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str | None,
|
||||
email: str | None,
|
||||
roles: list[str],
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Render the user-metadata macros for a given user and return the values they
|
||||
contributed to the query cache key.
|
||||
"""
|
||||
mock_g = mocker.patch("superset.utils.core.g")
|
||||
if user_id is None:
|
||||
mock_g.user = None
|
||||
else:
|
||||
mock_g.user.id = user_id
|
||||
mock_g.user.username = username
|
||||
mock_g.user.email = email
|
||||
mocker.patch(
|
||||
"superset.security_manager.get_user_roles",
|
||||
return_value=[Role(name=name) for name in roles],
|
||||
)
|
||||
keys: list[Any] = []
|
||||
cache = ExtraCache(extra_cache_keys=keys, table=mocker.MagicMock())
|
||||
cache.current_user_id()
|
||||
cache.current_username()
|
||||
cache.current_user_email()
|
||||
cache.current_user_roles()
|
||||
return keys
|
||||
|
||||
|
||||
def test_user_metadata_cache_keys_isolate_distinct_users(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Two different users contribute disjoint values to the cache key, so neither
|
||||
can be served the other's cached result. This is the property that keeps the
|
||||
``current_user_*`` macro family safe for per-user (and multi-tenant) queries.
|
||||
"""
|
||||
alice = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
bob = _user_metadata_cache_keys(
|
||||
mocker, user_id=2, username="bob", email="bob@example.com", roles=["Gamma"]
|
||||
)
|
||||
assert alice
|
||||
assert bob
|
||||
assert set(alice).isdisjoint(set(bob))
|
||||
|
||||
|
||||
def test_user_metadata_cache_keys_match_for_identical_users(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
The same user always contributes the same values, so identical renders
|
||||
correctly share a cache entry (no needless fragmentation).
|
||||
"""
|
||||
first = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
second = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
# The real cache key is built from ``set(extra_cache_keys)``, so compare on
|
||||
# the normalized set rather than the raw ordered list.
|
||||
assert set(first) == set(second)
|
||||
|
||||
|
||||
def test_anonymous_user_never_collides_with_a_logged_in_user(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Refutes the "skip cache key when the value is absent" collision concern. A
|
||||
real anonymous request has no user object, so ``current_user_id`` /
|
||||
``current_username`` / ``current_user_email`` return ``None`` and add nothing
|
||||
to the key, while ``current_user_roles`` still contributes the Public role.
|
||||
Two anonymous requests therefore render identically and correctly share one
|
||||
cache entry, and neither can be served a logged-in user's cached result.
|
||||
"""
|
||||
logged_in = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
anon_first = _user_metadata_cache_keys(
|
||||
mocker, user_id=None, username=None, email=None, roles=["Public"]
|
||||
)
|
||||
anon_second = _user_metadata_cache_keys(
|
||||
mocker, user_id=None, username=None, email=None, roles=["Public"]
|
||||
)
|
||||
# The absent id/username/email contribute nothing, so an anonymous request's
|
||||
# key carries only its role, never a stray value for the missing fields.
|
||||
assert set(anon_first) == {json.dumps(["Public"])}
|
||||
# Two anonymous requests share a cache entry; neither collides with a user.
|
||||
assert set(anon_first) == set(anon_second)
|
||||
assert set(anon_first).isdisjoint(set(logged_in))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, other_value",
|
||||
[
|
||||
("user_id", 2),
|
||||
("username", "bob"),
|
||||
("email", "bob@example.com"),
|
||||
("roles", ["Gamma"]),
|
||||
],
|
||||
)
|
||||
def test_user_metadata_cache_keys_guard_each_field_independently(
|
||||
mocker: MockerFixture,
|
||||
field: str,
|
||||
other_value: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Two users who differ in exactly one metadata field get distinct cache keys.
|
||||
Guarding each field on its own means a regression that stops any single
|
||||
macro (``current_user_id`` / ``current_username`` / ``current_user_email`` /
|
||||
``current_user_roles``) from contributing to the key would fail its case here,
|
||||
rather than hiding behind the other fields.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
"user_id": 1,
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"roles": ["Admin"],
|
||||
}
|
||||
variant: dict[str, Any] = {**base, field: other_value}
|
||||
base_keys = _user_metadata_cache_keys(mocker, **base)
|
||||
variant_keys = _user_metadata_cache_keys(mocker, **variant)
|
||||
assert set(base_keys) != set(variant_keys)
|
||||
|
||||
|
||||
def test_current_user_rls_rules_with_no_table(mocker: MockerFixture):
|
||||
"""
|
||||
Test the ``current_user_rls_rules`` macro when no table is provided.
|
||||
|
||||
Reference in New Issue
Block a user