This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new fd524f4b217 fix(sqllab): preserve exact decimals in results and
exports (#44739)
fd524f4b217 is described below
commit fd524f4b217f2dfd5d5f75ca9109413edf9515d7
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Oct 1 12:48:43 2026 +1000
fix(sqllab): preserve exact decimals in results and exports (#44739)
---
UPDATING.md | 20 ++
docs/docs/using-superset/number-formatting.mdx | 16 ++
.../FilterableTable/exactDecimals.test.tsx | 92 ++++++++
.../src/components/FilterableTable/index.tsx | 41 +---
.../components/FilterableTable/sortResults.test.ts | 238 +++++++++++++++++++++
.../src/components/FilterableTable/sortResults.ts | 180 ++++++++++++++++
superset/commands/sql_lab/export.py | 14 ++
superset/commands/streaming_export/base.py | 11 +-
superset/common/query_context_processor.py | 1 +
superset/dataframe.py | 38 +++-
superset/result_set.py | 6 +
superset/sql/execution/celery_task.py | 2 +-
superset/sql_lab.py | 2 +-
superset/utils/csv.py | 10 +
superset/views/utils.py | 2 +-
tests/unit_tests/commands/sql_lab/export_test.py | 113 ++++++++++
.../sql_lab/streaming_export_command_test.py | 74 +++++++
.../common/test_query_context_processor.py | 12 ++
tests/unit_tests/dataframe_test.py | 83 ++++++-
tests/unit_tests/result_set_test.py | 71 ++++++
tests/unit_tests/sql/execution/test_celery_task.py | 2 +-
tests/unit_tests/sql_lab_test.py | 2 +-
22 files changed, 979 insertions(+), 51 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index da66b164f96..54c33402ad3 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -126,6 +126,26 @@ get the same handling. With MariaDB Connector/Python
ssl_verify_cert=True. Other MySQL-compatible engines such as OceanBase and
StarRocks keep their existing SSL handling.
+### SQL Lab decimal results use exact strings
+
+SQL Lab represents database `DECIMAL`/`NUMERIC` values as JSON strings instead
+of JSON numbers, preserving precision and trailing zeros in the results grid
+and exports. The strings use fixed-point notation, and non-finite `NaN` and
+`Infinity` decimals are returned as `null`. Numeric sorting still compares
+their exact values. API consumers performing arithmetic should parse these
+strings with a decimal library, not JavaScript `Number`. This includes SQL Lab
+extensions: the `data` rows passed to `sqlLab.onDidQuerySuccess` listeners
+carry strings for `numeric` columns (for example PostgreSQL `SUM(bigint)`,
+`AVG` or `ROUND` results), so adding them with `+` concatenates instead of
+summing. The `sqleditor.extension.resultTable` override, which replaces
+`FilterableTable` in both SQL Lab results and table previews, also receives
+string rows but not the built-in exact-decimal comparator; extensions must
+implement their own decimal sorting. Chart and dataset queries also use
fixed-point strings for Decimal
+columns that fall back to string conversion (for example, Decimals mixed with
+floats), and non-finite Decimals in those columns become null. Other chart
+result serialization and chart number formatting are unchanged. Re-run queries
+whose cached JSON results were produced before upgrading all workers.
+
### Version history retention setting
Use `VERSION_HISTORY_RETENTION_DAYS` for both the application setting and
diff --git a/docs/docs/using-superset/number-formatting.mdx
b/docs/docs/using-superset/number-formatting.mdx
index a3be77b05d5..d6e05634eeb 100644
--- a/docs/docs/using-superset/number-formatting.mdx
+++ b/docs/docs/using-superset/number-formatting.mdx
@@ -90,3 +90,19 @@ Use these when a metric's underlying values are stored in
meters or centimeters
## Currency
Some chart types also expose currency-specific formatting, including a dynamic
mode that reads the currency from a column value. See [Dynamic Currency
Formatting](./creating-your-first-dashboard#dynamic-currency-formatting) for
details.
+
+## Exact decimals in SQL Lab
+
+SQL Lab displays database `DECIMAL` and `NUMERIC` results as exact fixed-point
+text, including trailing zeros and never in scientific notation. Its JSON
+responses use strings so a browser does not round values such as
+`12345678901234567890.123456789012345678`. Sorting remains numeric and
+compares all digits; CSV exports, including streaming exports, retain those
+digits and use fixed-point notation for finite decimals too. Extensions
overriding
+`sqleditor.extension.resultTable` replace the built-in table in SQL Lab results
+and table previews. They receive decimal strings but not the built-in
comparator,
+so they must implement their own exact-decimal sorting. Chart and dataset
+queries share the result-set loader: Decimal columns that fall back to strings
+(for example, Decimals mixed with floats) also use fixed-point notation, with
+non-finite Decimals converted to null. Other chart result serialization and
+chart number formatting are unchanged.
diff --git
a/superset-frontend/src/components/FilterableTable/exactDecimals.test.tsx
b/superset-frontend/src/components/FilterableTable/exactDecimals.test.tsx
new file mode 100644
index 00000000000..1a091f9c7a6
--- /dev/null
+++ b/superset-frontend/src/components/FilterableTable/exactDecimals.test.tsx
@@ -0,0 +1,92 @@
+/**
+ * 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 {
+ render,
+ screen,
+ userEvent,
+ within,
+} from 'spec/helpers/testing-library';
+import { setupAGGridModules } from
'@superset-ui/core/components/ThemedAgGridReact';
+import { FilterableTable } from '.';
+import { getCachedSortKey } from './sortResults';
+
+test('displays and sorts exact decimal strings without rounding', async () => {
+ setupAGGridModules();
+ const smaller = '12345678901234567890.123456789012345678';
+ const larger = '12345678901234567890.123456789012345679';
+ const ascending = ['-10.50', '0.00', '2.00', '10.50', smaller, larger];
+ render(
+ <FilterableTable
+ orderedColumnKeys={['amount']}
+ data={[larger, smaller, '10.50', '2.00', '0.00', '-10.50'].map(
+ amount => ({ amount }),
+ )}
+ height={500}
+ />,
+ );
+ for (const value of ascending) {
+ expect(screen.getByText(value)).toBeInTheDocument();
+ }
+ const header = within(screen.getByRole('grid'))
+ .getByText('amount')
+ .closest('[role=button]');
+ expect(header).not.toBeNull();
+ const values = () =>
+ Array.from(
+ document.querySelectorAll('[role="gridcell"][col-id="amount"]'),
+ ).map(cell => cell.textContent);
+ await userEvent.click(header!);
+ expect(values()).toEqual(ascending);
+ await userEvent.click(header!);
+ expect(values()).toEqual([...ascending].reverse());
+});
+
+test('uses the new rows array for sort-key caching when data changes', async
() => {
+ setupAGGridModules();
+ const orderedColumnKeys = ['amount'];
+ const data = [{ amount: '10.50' }, { amount: '2.00' }];
+ const { rerender } = render(
+ <FilterableTable
+ orderedColumnKeys={orderedColumnKeys}
+ data={data}
+ height={500}
+ />,
+ );
+ const header = within(screen.getByRole('grid'))
+ .getByText('amount')
+ .closest('[role=button]');
+ expect(header).not.toBeNull();
+ await userEvent.click(header!);
+ expect(getCachedSortKey('10.50', data)).toBeDefined();
+
+ const nextData = [{ amount: '20.50' }, { amount: '3.00' }];
+ rerender(
+ <FilterableTable
+ orderedColumnKeys={orderedColumnKeys}
+ data={nextData}
+ height={500}
+ />,
+ );
+ expect(
+ Array.from(
+ document.querySelectorAll('[role="gridcell"][col-id="amount"]'),
+ ).map(cell => cell.textContent),
+ ).toEqual(['3.00', '20.50']);
+ expect(getCachedSortKey('20.50', nextData)).toBeDefined();
+});
diff --git a/superset-frontend/src/components/FilterableTable/index.tsx
b/superset-frontend/src/components/FilterableTable/index.tsx
index 492f42e4f40..a2c1bcff5ca 100644
--- a/superset-frontend/src/components/FilterableTable/index.tsx
+++ b/superset-frontend/src/components/FilterableTable/index.tsx
@@ -25,37 +25,7 @@ import { renderResultCell } from './utils';
import type { FilterableTableProps, Datum, CellDataType } from './types';
-// This regex handles all possible number formats in javascript, including
ints, floats,
-// exponential notation, NaN, and Infinity.
-// See https://stackoverflow.com/a/30987109 for more details
-const ONLY_NUMBER_REGEX = /^(NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity))$/;
-
-const parseNumberFromString = (value: string | number | null) => {
- if (typeof value === 'string' && ONLY_NUMBER_REGEX.test(value)) {
- return parseFloat(value);
- }
- return value;
-};
-
-const sortResults = (valueA: string | number, valueB: string | number) => {
- const aValue = parseNumberFromString(valueA);
- const bValue = parseNumberFromString(valueB);
-
- // equal items sort equally
- if (aValue === bValue) {
- return 0;
- }
-
- // nulls sort after anything else
- if (aValue === null) {
- return 1;
- }
- if (bValue === null) {
- return -1;
- }
-
- return aValue < bValue ? -1 : 1;
-};
+import { sortResults } from './sortResults';
export const FilterableTable = ({
orderedColumnKeys,
@@ -91,6 +61,11 @@ export const FilterableTable = ({
return values.some(v => v.includes(lowerCaseText));
};
+ const comparator = useCallback(
+ (a: CellDataType, b: CellDataType) => sortResults(a, b, data),
+ [data],
+ );
+
const columns = useMemo(
() =>
orderedColumnKeys.map(key => ({
@@ -98,7 +73,7 @@ export const FilterableTable = ({
label: key,
fieldName: key,
headerName: key,
- comparator: sortResults,
+ comparator,
render: ({ value, colDef }: { value: CellDataType; colDef: ColDef }) =>
renderResultCell({
cellData: value,
@@ -107,7 +82,7 @@ export const FilterableTable = ({
getCellContent,
}),
})),
- [orderedColumnKeys, allowHTML, getCellContent],
+ [orderedColumnKeys, allowHTML, getCellContent, comparator],
);
const keywordFilter = useCallback(
diff --git
a/superset-frontend/src/components/FilterableTable/sortResults.test.ts
b/superset-frontend/src/components/FilterableTable/sortResults.test.ts
new file mode 100644
index 00000000000..d003e5e33b6
--- /dev/null
+++ b/superset-frontend/src/components/FilterableTable/sortResults.test.ts
@@ -0,0 +1,238 @@
+/**
+ * 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 { decimalParts, getCachedSortKey, sortResults } from './sortResults';
+
+test.each([
+ ['2', '10', -1],
+ [
+ '12345678901234567890.123456789012345678',
+ '12345678901234567890.123456789012345679',
+ -1,
+ ],
+ [
+ '-12345678901234567890.123456789012345678',
+ '-12345678901234567890.123456789012345679',
+ 1,
+ ],
+ ['1E-18', '0.000000000000000002', -1],
+ ['1E+100000', '9E+99999', 1],
+ ['1E-100000', '9E-100001', 1],
+ ['0.00000000000000000001', '0', 1],
+ ['0010.500', 10.5, 0],
+ ['-0.00', 0, 0],
+ ['.5', '0.50', 0],
+ ['9.99', 10, -1],
+ [null, '1.23', 1],
+ ['1.23', null, -1],
+ [null, null, 0],
+ ['apple', 'pear', -1],
+ ['2024-01-01', '2025-01-01', -1],
+ ['-Infinity', '-1.2', -1],
+ ['Infinity', '1.2', 1],
+] as const)('sorts %s vs %s exactly', (a, b, expected) => {
+ expect(sortResults(a, b)).toBe(expected);
+});
+
+test.each([
+ ['apple', 5],
+ ['apple', 'NaN'],
+ [NaN, 1.5],
+ ['2024-01-01', 7],
+] as const)('orders %s against %s in one direction only', (a, b) => {
+ expect(sortResults(a, b)).toBe(-sortResults(b, a));
+});
+
+test('treats two NaNs as equal rather than unordered', () => {
+ expect(sortResults(NaN, NaN)).toBe(0);
+ expect(sortResults('NaN', 'NaN')).toBe(0);
+});
+
+test('sorts a mixed text and number column transitively', () => {
+ const column = [5, 'apple', 'NaN', 10, 'banana'];
+ const ascending = [...column].sort(sortResults);
+ const descending = [...column].reverse().sort(sortResults);
+ expect(descending).toEqual(ascending);
+});
+
+test.each([
+ [[9, '5x', 10]],
+ [[10, 9, '5x']],
+ [['5x', 10, 9]],
+ [['9', '5x', '10']],
+ [['10', '9', '5x']],
+ [['5x', '10', '9']],
+])('sorts numbers before text whatever the input order: %p', column => {
+ const sorted = [...column].sort(sortResults).map(String);
+ expect(sorted).toEqual(['9', '10', '5x']);
+});
+
+test('orders numbers, then text, then NaN, then nulls', () => {
+ const column = [
+ 'NaN',
+ null,
+ 'apple',
+ 3,
+ NaN,
+ '2.5',
+ 'Infinity',
+ '-Infinity',
+ -Infinity,
+ '10',
+ ];
+ expect([...column].sort(sortResults)).toEqual([
+ '-Infinity',
+ -Infinity,
+ '2.5',
+ 3,
+ '10',
+ 'Infinity',
+ 'apple',
+ 'NaN',
+ NaN,
+ null,
+ ]);
+});
+
+test('is a consistent total order over mixed values', () => {
+ const values = [
+ null,
+ NaN,
+ 'NaN',
+ -Infinity,
+ '-Infinity',
+ Infinity,
+ 'Infinity',
+ -1.5,
+ '-1.50',
+ 0,
+ -0,
+ '0.000',
+ '1E-18',
+ 0.1,
+ '0.1',
+ 9,
+ '10',
+ 1e21,
+ '1E+21',
+ '',
+ '5x',
+ 'apple',
+ '2024-01-01',
+ ];
+ const sign = (n: number) => Math.sign(n) || 0;
+ values.forEach(a => {
+ expect(sortResults(a, a)).toBe(0);
+ values.forEach(b => {
+ const ab = sign(sortResults(a, b));
+ expect(sign(sortResults(b, a))).toBe(-ab || 0);
+ values.forEach(c => {
+ const bc = sign(sortResults(b, c));
+ if (ab <= 0 && bc <= 0) {
+ expect(sign(sortResults(a, c))).toBeLessThanOrEqual(0);
+ }
+ });
+ });
+ });
+});
+
+test.each([
+ [1, 2, -1],
+ [2, 1, 1],
+ [-0, 0, 0],
+ [-Infinity, -1e308, -1],
+ [Infinity, 1e308, 1],
+ [9007199254740993, 9007199254740992, 0],
+ [0.1 + 0.2, 0.3, 1],
+] as const)('compares plain numbers %p vs %p directly', (a, b, expected) => {
+ expect(sortResults(a, b)).toBe(expected);
+});
+
+test.each([
+ [0.1, '0.1'],
+ [1e21, '1E+21'],
+ [1e-7, '0.0000001'],
+ [-2.5, '-2.50'],
+ [Infinity, 'Infinity'],
+])('orders number %p and its exact string %p equally', (number, text) => {
+ expect(sortResults(number, text)).toBe(0);
+ expect(sortResults(text, number)).toBe(0);
+});
+
+// Reference regex the linear parser must agree with.
+const REFERENCE_DECIMAL =
+ /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/;
+
+function strings(alphabet: string[], maxLength: number): string[] {
+ const all = [''];
+ let level = [''];
+ for (let length = 1; length <= maxLength; length += 1) {
+ level = level.flatMap(prefix => alphabet.map(token => prefix + token));
+ level.forEach(text => all.push(text));
+ }
+ return all;
+}
+
+test('linear parser accepts exactly what the reference regex matches', () => {
+ const alphabet = ['0', '7', '.', 'e', 'E', '+', '-', 'x', 'NaN', 'Infinity'];
+ const mismatches: string[] = [];
+ const inputs = strings(alphabet, 5);
+ expect(inputs).toHaveLength(111111);
+ expect(inputs).toEqual(
+ expect.arrayContaining(['NaN', 'Infinity', '-Infinity']),
+ );
+ inputs.forEach(text => {
+ if ((decimalParts(text) !== null) !== REFERENCE_DECIMAL.test(text)) {
+ mismatches.push(`decimal: ${text}`);
+ }
+ });
+ expect(mismatches).toEqual([]);
+});
+
+test('rejects long adversarial near-miss inputs', () => {
+ const inputs = [
+ `${'1'.repeat(200000)}x`,
+ `.${'1'.repeat(200000)}.`,
+ `1e${'1'.repeat(200000)}e`,
+ `-${'1'.repeat(100000)}.${'1'.repeat(100000)}E+`,
+ ];
+ inputs.forEach(text => {
+ expect(decimalParts(text)).toBeNull();
+ });
+});
+
+test('reuses sort keys only within the same result set', () => {
+ const rows = [{ value: '1.25' }, { value: '2.50' }];
+ expect(getCachedSortKey('1.25', rows)).toBeUndefined();
+ expect(sortResults('1.25', '2.50', rows)).toBe(-1);
+ const firstKey = getCachedSortKey('1.25', rows);
+ const secondKey = getCachedSortKey('2.50', rows);
+ expect(firstKey).toBeDefined();
+ expect(secondKey).toBeDefined();
+ expect(sortResults('2.50', '1.25', rows)).toBe(1);
+ expect(getCachedSortKey('1.25', rows)).toBe(firstKey);
+ expect(getCachedSortKey('2.50', rows)).toBe(secondKey);
+
+ const otherRows = [...rows];
+ expect(getCachedSortKey('1.25', otherRows)).toBeUndefined();
+ expect(sortResults('1.25', '2.50', otherRows)).toBe(-1);
+ expect(getCachedSortKey('1.25', otherRows)).toEqual(firstKey);
+ expect(getCachedSortKey('1.25', otherRows)).not.toBe(firstKey);
+ expect(sortResults('1.25', '2.50')).toBe(-1);
+ expect(getCachedSortKey('1.25', rows)).toBe(firstKey);
+});
diff --git a/superset-frontend/src/components/FilterableTable/sortResults.ts
b/superset-frontend/src/components/FilterableTable/sortResults.ts
new file mode 100644
index 00000000000..b93240f2844
--- /dev/null
+++ b/superset-frontend/src/components/FilterableTable/sortResults.ts
@@ -0,0 +1,180 @@
+/**
+ * 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.
+ */
+type CellValue = string | number | null;
+
+// Every pattern below is a single flat run of digits, so matching stays linear
+// in the input length; the optional parts are split off by hand instead of
+// nesting quantified groups, which could backtrack catastrophically.
+const DIGITS = /^\d*$/;
+const EXPONENT = /^[+-]?\d+$/;
+
+/** Split `text` at its first `e`/`E`; `null` when the exponent is malformed.
*/
+function splitExponent(text: string): [string, string | undefined] | null {
+ const at = text.search(/[eE]/);
+ if (at === -1) return [text, undefined];
+ const exponent = text.slice(at + 1);
+ return EXPONENT.test(exponent) ? [text.slice(0, at), exponent] : null;
+}
+
+// Compare significands and decimal orders, never coerce exact strings to
Number.
+// Keeping the exponent separate also avoids allocating 10**exponent zeroes.
+export function decimalParts(value: Exclude<CellValue, null>) {
+ const text = String(value);
+ const signed = text[0] === '+' || text[0] === '-';
+ const parts = splitExponent(signed ? text.slice(1) : text);
+ if (!parts) return null;
+ const [mantissa, exponent] = parts;
+ const dot = mantissa.indexOf('.');
+ const integer = dot === -1 ? mantissa : mantissa.slice(0, dot);
+ const fraction = dot === -1 ? '' : mantissa.slice(dot + 1);
+ if (
+ !(integer || fraction) ||
+ !DIGITS.test(integer) ||
+ !DIGITS.test(fraction)
+ ) {
+ return null;
+ }
+ const digits = `${integer}${fraction}`.replace(/^0+/, '');
+ return {
+ sign: digits ? (text[0] === '-' ? -1 : 1) : 0,
+ digits,
+ order:
+ BigInt(digits.length) + BigInt(exponent ?? '0') -
BigInt(fraction.length),
+ };
+}
+
+// Values sort by type first, so mixed columns stay transitive: numbers (exact
+// decimal strings and infinities included) before text, and NaN after both.
+const NUMERIC_RANK = 0;
+const TEXT_RANK = 1;
+const NAN_RANK = 2;
+
+type SortKey =
+ | {
+ rank: typeof NUMERIC_RANK;
+ // -1 or 1 for an infinity, 0 for a finite value.
+ infinity: number;
+ sign: number;
+ digits: string;
+ order: bigint;
+ }
+ | { rank: typeof TEXT_RANK; text: string }
+ | { rank: typeof NAN_RANK };
+
+function toSortKey(value: Exclude<CellValue, null>): SortKey {
+ if (typeof value === 'number' ? Number.isNaN(value) : value === 'NaN') {
+ return { rank: NAN_RANK };
+ }
+ if (value === Infinity || value === 'Infinity') {
+ return {
+ rank: NUMERIC_RANK,
+ infinity: 1,
+ sign: 1,
+ digits: '',
+ order: BigInt(0),
+ };
+ }
+ if (value === -Infinity || value === '-Infinity') {
+ return {
+ rank: NUMERIC_RANK,
+ infinity: -1,
+ sign: -1,
+ digits: '',
+ order: BigInt(0),
+ };
+ }
+ const parts = decimalParts(value);
+ return parts
+ ? { rank: NUMERIC_RANK, infinity: 0, ...parts }
+ : { rank: TEXT_RANK, text: String(value) };
+}
+
+// Parsing dominates the cost of sorting decimal strings, and a sort compares
+// each cell O(log n) times. Weak row-array keys let cached values be collected
+// with their result set instead of retaining them for the life of the page.
+const SORT_KEY_CACHE_LIMIT = 250_000;
+const sortKeyCaches = new WeakMap<
+ readonly unknown[],
+ Map<Exclude<CellValue, null>, SortKey>
+>();
+
+/** @internal Inspect cache reuse without coupling tests to parser details. */
+export function getCachedSortKey(
+ value: Exclude<CellValue, null>,
+ rows: readonly unknown[],
+): SortKey | undefined {
+ return sortKeyCaches.get(rows)?.get(value);
+}
+
+function sortKey(
+ value: Exclude<CellValue, null>,
+ rows?: readonly unknown[],
+): SortKey {
+ if (!rows) return toSortKey(value);
+ let sortKeyCache = sortKeyCaches.get(rows);
+ if (!sortKeyCache) {
+ sortKeyCache = new Map();
+ sortKeyCaches.set(rows, sortKeyCache);
+ }
+ let key = sortKeyCache.get(value);
+ if (key === undefined) {
+ if (sortKeyCache.size >= SORT_KEY_CACHE_LIMIT) sortKeyCache.clear();
+ key = toSortKey(value);
+ sortKeyCache.set(value, key);
+ }
+ return key;
+}
+
+function compareKeys(a: SortKey, b: SortKey): number {
+ if (a.rank !== b.rank) return a.rank < b.rank ? -1 : 1;
+ if (a.rank === TEXT_RANK && b.rank === TEXT_RANK) {
+ return a.text === b.text ? 0 : a.text < b.text ? -1 : 1;
+ }
+ if (a.rank !== NUMERIC_RANK || b.rank !== NUMERIC_RANK) return 0;
+ if (a.infinity !== b.infinity) return a.infinity < b.infinity ? -1 : 1;
+ if (a.infinity !== 0) return 0;
+ if (a.sign !== b.sign) return a.sign < b.sign ? -1 : 1;
+ if (a.sign === 0) return 0;
+ if (a.order !== b.order) return (a.order < b.order ? -1 : 1) * a.sign;
+ const width = Math.max(a.digits.length, b.digits.length);
+ const left = a.digits.padEnd(width, '0');
+ const right = b.digits.padEnd(width, '0');
+ return left === right ? 0 : (left < right ? -1 : 1) * a.sign;
+}
+
+export function sortResults(
+ valueA: CellValue,
+ valueB: CellValue,
+ rows?: readonly unknown[],
+): number {
+ // Plain JavaScript numbers need no parsing; their order agrees with the
+ // exact decimal order of their shortest representations.
+ if (
+ typeof valueA === 'number' &&
+ typeof valueB === 'number' &&
+ !Number.isNaN(valueA) &&
+ !Number.isNaN(valueB)
+ ) {
+ return valueA === valueB ? 0 : valueA < valueB ? -1 : 1;
+ }
+ if (valueA === valueB) return 0;
+ if (valueA === null) return 1;
+ if (valueB === null) return -1;
+ return compareKeys(sortKey(valueA, rows), sortKey(valueB, rows));
+}
diff --git a/superset/commands/sql_lab/export.py
b/superset/commands/sql_lab/export.py
index 93aea9d58c6..4b42cbc880d 100644
--- a/superset/commands/sql_lab/export.py
+++ b/superset/commands/sql_lab/export.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import logging
+from decimal import Decimal
from typing import Any, cast, TypedDict
import pandas as pd
@@ -37,6 +38,17 @@ from superset.views.utils import _deserialize_results_payload
logger = logging.getLogger(__name__)
+def _format_decimal_columns(df: pd.DataFrame) -> None:
+ """Format decimal object columns without changing other values or
dtypes."""
+ for name, column in df.items():
+ if pd.api.types.is_object_dtype(column.dtype) and any(
+ isinstance(value, Decimal) and value.is_finite() for value in
column
+ ):
+ # A finite decimal becomes a string, keeping map's result
object-typed
+ # so mixed floats, integers and None do not undergo dtype
inference.
+ df[name] = column.map(csv.format_decimal)
+
+
class SqlExportResult(TypedDict):
query: Query
count: int
@@ -140,6 +152,8 @@ class SqlResultExportCommand(BaseCommand):
self._query.schema,
)[:limit]
+ _format_decimal_columns(df)
+
# Manual encoding using the specified encoding (default to utf-8 if
not set)
csv_string = csv.df_to_escaped_csv(df, index=False,
**app.config["CSV_EXPORT"])
csv_data = csv_string.encode(app.config["CSV_EXPORT"].get("encoding",
"utf-8"))
diff --git a/superset/commands/streaming_export/base.py
b/superset/commands/streaming_export/base.py
index fbed887ecbc..d5814b1908f 100644
--- a/superset/commands/streaming_export/base.py
+++ b/superset/commands/streaming_export/base.py
@@ -34,7 +34,7 @@ from werkzeug.local import LocalProxy
from superset import db
from superset.commands.base import BaseCommand
-from superset.utils.csv import escape_value
+from superset.utils.csv import escape_value, format_decimal
logger = logging.getLogger(__name__)
@@ -192,10 +192,17 @@ class BaseStreamingCSVExportCommand(BaseCommand):
# (float, decimal.Decimal, numpy numeric types, ...). Booleans are
# technically a numeric type in Python but should never be
rewritten
# as numbers in CSV output.
+ elif isinstance(value, Decimal):
+ decimal_value = format_decimal(value)
+ if active_decimal_separator is not None:
+ decimal_value = str(decimal_value).replace(
+ ".", active_decimal_separator
+ )
+ formatted.append(decimal_value)
elif isinstance(value, bool):
formatted.append(value)
elif active_decimal_separator is not None and isinstance(
- value, (float, Decimal, Real)
+ value, (float, Real)
):
# Format numeric values with custom decimal separator
formatted.append(str(value).replace(".",
active_decimal_separator))
diff --git a/superset/common/query_context_processor.py
b/superset/common/query_context_processor.py
index 5f98c82ad0b..cc3ee36640b 100644
--- a/superset/common/query_context_processor.py
+++ b/superset/common/query_context_processor.py
@@ -592,6 +592,7 @@ class QueryContextProcessor:
# QueryObject post-processing has completed before this materialization
# boundary. Canonicalize its trusted missing/non-finite scalar outputs,
# while downstream envelope validation still rejects injected infinity.
+ # Chart consumers do arithmetic on decimals, so keep them JSON numbers.
return df_to_records(df)
@staticmethod
diff --git a/superset/dataframe.py b/superset/dataframe.py
index 12e24a69985..16dfe35abdd 100644
--- a/superset/dataframe.py
+++ b/superset/dataframe.py
@@ -85,8 +85,27 @@ def _is_trusted_missing_or_nonfinite(value: Any) -> bool:
return False
+def _convert_decimals(value: Any) -> Any:
+ """Keep SQL Lab decimals exact across JSON, MessagePack and browser
parsing."""
+ # Match exact types only, as below, so object-column values never get to
run
+ # overridden ``items``/``__iter__`` hooks during conversion.
+ value_type = type(value)
+ if value_type is Decimal:
+ return format(value, "f") if Decimal.is_finite(value) else None
+ if value_type is dict:
+ return {key: _convert_decimals(item) for key, item in
dict.items(value)}
+ if value_type is list:
+ return [_convert_decimals(item) for item in value]
+ if value_type is tuple:
+ return tuple(_convert_decimals(item) for item in value)
+ return value
+
+
def df_to_records(
- dframe: pd.DataFrame, *, convert_big_integers: bool = True
+ dframe: pd.DataFrame,
+ *,
+ convert_big_integers: bool = True,
+ convert_decimals: bool = False,
) -> list[dict[str, Any]]:
"""
Convert a DataFrame to a set of records.
@@ -97,6 +116,8 @@ def df_to_records(
:param dframe: the DataFrame to convert
:param convert_big_integers: whether integers outside JavaScript's safe
range
should be represented as strings
+ :param convert_decimals: whether ``Decimal`` values should be represented
as
+ exact strings rather than JSON numbers
:returns: a list of dictionaries reflecting each single row of the
DataFrame
"""
if not dframe.columns.is_unique:
@@ -118,12 +139,13 @@ def df_to_records(
# converting it to float can overflow or lose precision.
if type(value) in _NUMPY_NATIVE_TYPES:
value = value.item()
- dict.__setitem__(
- record,
- key,
- None
- if _is_trusted_missing_or_nonfinite(value)
- else (_convert_big_integers(value) if convert_big_integers
else value),
- )
+ if _is_trusted_missing_or_nonfinite(value):
+ value = None
+ else:
+ if convert_big_integers:
+ value = _convert_big_integers(value)
+ if convert_decimals:
+ value = _convert_decimals(value)
+ dict.__setitem__(record, key, value)
return records
diff --git a/superset/result_set.py b/superset/result_set.py
index e7eb527df30..b39473bfe33 100644
--- a/superset/result_set.py
+++ b/superset/result_set.py
@@ -18,6 +18,7 @@
import datetime
import logging
+from decimal import Decimal
from typing import Any, Optional
import numpy as np
@@ -88,6 +89,11 @@ def stringify_values(array: NDArray[Any]) -> NDArray[Any]:
# Non-JSON-serializable value (e.g. bytes, custom
# objects): fall back to str() to avoid crashing.
obj[...] = str(val)
+ elif isinstance(val, Decimal):
+ # str() switches to scientific notation for small or
+ # large exponents (-1.0E-7), which CSV export would
+ # formula-escape. NaN and Infinity have no exact value.
+ obj[...] = format(val, "f") if val.is_finite() else
None
else:
# for simple string conversions
# this handles odd character types better
diff --git a/superset/sql/execution/celery_task.py
b/superset/sql/execution/celery_task.py
index 7b56db8e4cc..bab85e025e1 100644
--- a/superset/sql/execution/celery_task.py
+++ b/superset/sql/execution/celery_task.py
@@ -323,7 +323,7 @@ def _serialize_result_set(
data = write_ipc_buffer(result_set.pa_table).to_pybytes()
else:
df = result_set.to_pandas_df()
- data = df_to_records(df) or []
+ data = df_to_records(df, convert_decimals=True) or []
return (data, result_set.columns)
diff --git a/superset/sql_lab.py b/superset/sql_lab.py
index 4fe37ccb983..ad64b27b7e0 100644
--- a/superset/sql_lab.py
+++ b/superset/sql_lab.py
@@ -424,7 +424,7 @@ def _serialize_and_expand_data(
all_columns, expanded_columns = (selected_columns, [])
else:
df = result_set.to_pandas_df()
- data = df_to_records(df) or []
+ data = df_to_records(df, convert_decimals=True) or []
if expand_data:
all_columns, data, expanded_columns = db_engine_spec.expand_data(
diff --git a/superset/utils/csv.py b/superset/utils/csv.py
index c0c9b10dcc8..bcb79a2c699 100644
--- a/superset/utils/csv.py
+++ b/superset/utils/csv.py
@@ -17,6 +17,7 @@
import logging
import urllib.request
from contextlib import closing
+from decimal import Decimal
from typing import Any, Optional, Union
from urllib.error import URLError
@@ -85,6 +86,15 @@ def escape_value(value: str) -> str:
return value
+def format_decimal(value: Any) -> Any:
+ """Return finite decimals as exact fixed-point text; preserve other
values."""
+ return (
+ format(value, "f")
+ if isinstance(value, Decimal) and value.is_finite()
+ else value
+ )
+
+
def df_to_escaped_csv(df: pd.DataFrame, **kwargs: Any) -> Any:
def escape_values(v: Any) -> Union[str, Any]:
return escape_value(v) if isinstance(v, str) else v
diff --git a/superset/views/utils.py b/superset/views/utils.py
index 63bb7037586..efaf14abf66 100644
--- a/superset/views/utils.py
+++ b/superset/views/utils.py
@@ -665,7 +665,7 @@ def _deserialize_results_payload(
raise SerializationError("Unable to deserialize table") from ex
df = result_set.SupersetResultSet.convert_table_to_df(pa_table)
- ds_payload["data"] = dataframe.df_to_records(df) or []
+ ds_payload["data"] = dataframe.df_to_records(df,
convert_decimals=True) or []
for column in ds_payload["selected_columns"]:
if "name" in column:
diff --git a/tests/unit_tests/commands/sql_lab/export_test.py
b/tests/unit_tests/commands/sql_lab/export_test.py
new file mode 100644
index 00000000000..22537969efb
--- /dev/null
+++ b/tests/unit_tests/commands/sql_lab/export_test.py
@@ -0,0 +1,113 @@
+# 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.
+"""Unit tests for SQL Lab CSV export."""
+
+from decimal import Decimal
+from unittest.mock import MagicMock
+
+import pandas as pd
+import pytest
+from pytest_mock import MockerFixture
+
+from superset.commands.sql_lab.export import SqlResultExportCommand
+from superset.utils import csv
+
+
[email protected](
+ ("value", "expected"),
+ [
+ ("0E-18", "0.000000000000000000"),
+ ("-1E-7", "-0.0000001"),
+ ("1E+30", "1000000000000000000000000000000"),
+ ("12.3400", "12.3400"),
+ ("NaN", ""),
+ ("Infinity", "Infinity"),
+ ("-Infinity", "-Infinity"),
+ ],
+)
+def test_csv_export_without_results_backend_preserves_decimals(
+ mocker: MockerFixture, value: str, expected: str
+) -> None:
+ """Re-executed query results use exact fixed-point decimal text."""
+ mocker.patch("superset.commands.sql_lab.export.results_backend", None)
+ mocker.patch.dict("superset.commands.sql_lab.export.app.config",
{"CSV_EXPORT": {}})
+ csv_spy = mocker.spy(csv, "df_to_escaped_csv")
+ decimal_value = Decimal(value)
+ query = MagicMock()
+ query.select_sql = "SELECT amount FROM numbers"
+ query.database.get_df.return_value = pd.DataFrame(
+ {"amount": [decimal_value], "other": ["unchanged"]}, dtype=object
+ )
+ mock_db = mocker.patch("superset.commands.sql_lab.export.db")
+ query_result = mock_db.session.query.return_value.filter_by.return_value
+ query_result.one_or_none.return_value = query
+
+ result = SqlResultExportCommand("client_id").run()
+
+ assert result["data"] == f"amount,other\n{expected},unchanged\n".encode()
+ assert result["count"] == 1
+ query.database.get_df.assert_called_once_with(
+ query.select_sql, query.catalog, query.schema
+ )
+ if not decimal_value.is_finite():
+ assert csv_spy.call_args.args[0].at[0, "amount"] is decimal_value
+
+
[email protected]("decimal_separator", [".", ","])
+def test_csv_export_mixed_frame_is_byte_identical(
+ mocker: MockerFixture, decimal_separator: str
+) -> None:
+ """Column mapping preserves mixed values and untouched numeric columns."""
+ mocker.patch("superset.commands.sql_lab.export.results_backend", None)
+ mocker.patch.dict(
+ "superset.commands.sql_lab.export.app.config",
+ {"CSV_EXPORT": {"sep": ";", "decimal": decimal_separator}},
+ )
+ query: MagicMock = MagicMock()
+ query.select_sql = "SELECT * FROM numbers"
+ frame: pd.DataFrame = pd.DataFrame(
+ {
+ "mixed": [Decimal("12.3400"), 1.25, "=formula", None,
Decimal("NaN")],
+ "float": [1.25, 2.5, 3.75, 4.0, 5.0],
+ "nullable": pd.Series([1, None, 3, 4, 5], dtype=object),
+ },
+ )
+ frame.index = [10, 20, 30, 40, 50]
+ query.database.get_df.return_value = frame
+ mock_db: MagicMock = mocker.patch("superset.commands.sql_lab.export.db")
+ query_result: MagicMock =
mock_db.session.query.return_value.filter_by.return_value
+ query_result.one_or_none.return_value = query
+ formatter: MagicMock = mocker.spy(csv, "format_decimal")
+
+ result = SqlResultExportCommand("client_id").run()
+
+ expected: str = (
+ "mixed;float;nullable\n"
+ "12.3400;1.25;1\n"
+ "1.25;2.5;\n"
+ "'=formula;3.75;3\n"
+ ";4.0;4\n"
+ ";5.0;5\n"
+ )
+ if decimal_separator == ",":
+ # Pandas applies the separator to float columns, not mixed object
cells.
+ expected = expected.replace(";1.25;", ";1,25;").replace(";2.5;",
";2,5;")
+ expected = expected.replace(";3.75;", ";3,75;").replace(";4.0;",
";4,0;")
+ expected = expected.replace(";5.0;", ";5,0;")
+ assert result["data"] == expected.encode()
+ assert result["count"] == 5
+ assert formatter.call_count == 5
diff --git a/tests/unit_tests/commands/sql_lab/streaming_export_command_test.py
b/tests/unit_tests/commands/sql_lab/streaming_export_command_test.py
index 83c5b2e9dce..c6aeb4ab0b0 100644
--- a/tests/unit_tests/commands/sql_lab/streaming_export_command_test.py
+++ b/tests/unit_tests/commands/sql_lab/streaming_export_command_test.py
@@ -16,7 +16,10 @@
# under the License.
"""Unit tests for SQL Lab Streaming CSV Export Command."""
+import csv
+import io
from decimal import Decimal
+from typing import Any
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -910,3 +913,74 @@ def
test_streaming_export_preserves_impersonated_user_context(
list(csv_generator_callable())
assert seen_usernames == ["alice"]
+
+
[email protected]("decimal_separator", [None, ".", ","])
+def test_streaming_csv_preserves_fixed_point_decimals(
+ decimal_separator: str | None,
+) -> None:
+ """Streaming CSV retains decimal digits and honors the decimal
separator."""
+ command: StreamingSqlResultExportCommand = StreamingSqlResultExportCommand(
+ "client_id"
+ )
+ result: MagicMock = MagicMock()
+ result.fetchmany.side_effect = [
+ [(Decimal("0E-18"), Decimal("-1E-7"), Decimal("1E+30"),
Decimal("12.3400"))],
+ [],
+ ]
+ buffer: io.StringIO = io.StringIO()
+ writer: Any = csv.writer(buffer, delimiter=";")
+
+ chunks: list[tuple[str, int, int]] = list(
+ command._process_rows(result, writer, buffer, None, decimal_separator)
+ )
+
+ expected: str = (
+
"0.000000000000000000;-0.0000001;1000000000000000000000000000000;12.3400"
+ )
+ if decimal_separator == ",":
+ expected = expected.replace(".", ",")
+ assert "".join(chunk[0] for chunk in chunks) == expected + "\r\n"
+
+
+def test_streaming_decimal_formatting_leaves_other_values_unchanged() -> None:
+ """Non-finite decimals and non-decimal values retain their
representations."""
+ command: StreamingSqlResultExportCommand = StreamingSqlResultExportCommand(
+ "client_id"
+ )
+ values: tuple[Any, ...] = (
+ Decimal("NaN"),
+ Decimal("Infinity"),
+ Decimal("-Infinity"),
+ 1,
+ 1e-7,
+ None,
+ True,
+ )
+
+ formatted: list[Any] = command._format_row_values(values, None)
+
+ assert all(
+ actual is original for actual, original in zip(formatted, values,
strict=True)
+ )
+
+
[email protected]("decimal_separator", [None, ".", ",", "::"])
+def test_streaming_non_finite_decimal_csv(decimal_separator: str | None) ->
None:
+ """Non-finite decimal CSV stays unchanged with custom decimal
separators."""
+ command: StreamingSqlResultExportCommand = StreamingSqlResultExportCommand(
+ "client_id"
+ )
+ values: tuple[Decimal, ...] = (
+ Decimal("NaN"),
+ Decimal("Infinity"),
+ Decimal("-Infinity"),
+ Decimal("12.3400"),
+ )
+ buffer: io.StringIO = io.StringIO()
+ writer: Any = csv.writer(buffer, delimiter=";")
+ writer.writerow(command._format_row_values(values, decimal_separator))
+ expected: str = "NaN;Infinity;-Infinity;12.3400\r\n"
+ if decimal_separator:
+ expected = expected.replace(".", decimal_separator)
+ assert buffer.getvalue() == expected
diff --git a/tests/unit_tests/common/test_query_context_processor.py
b/tests/unit_tests/common/test_query_context_processor.py
index d6fea922b55..27c273155db 100644
--- a/tests/unit_tests/common/test_query_context_processor.py
+++ b/tests/unit_tests/common/test_query_context_processor.py
@@ -16,6 +16,7 @@
# under the License.
from datetime import datetime, timedelta
+from decimal import Decimal
from typing import Any
from unittest.mock import MagicMock, patch
@@ -232,6 +233,17 @@ def test_get_data_json_preserves_browser_numeric_contract(
}
+def test_get_data_json_keeps_decimals_numeric(processor, mock_query_context)
-> None:
+ """Chart JSON keeps decimals as numbers; only SQL Lab quotes them."""
+ frame = pd.DataFrame({"amount": [Decimal("10.50")]})
+ mock_query_context.result_format = ChartDataResultFormat.JSON
+
+ result = processor.get_data(frame, [GenericDataType.NUMERIC])
+
+ assert type(result[0]["amount"]) is Decimal
+ assert superset_json.loads(superset_json.dumps(result)) == [{"amount":
10.5}]
+
+
def test_get_data_invalid_dataframe(processor, mock_query_context):
df = pd.DataFrame({"col1": [1, 2, 3], "col2": ["a", "b", "c"]})
coltypes = [GenericDataType.NUMERIC, GenericDataType.STRING]
diff --git a/tests/unit_tests/dataframe_test.py
b/tests/unit_tests/dataframe_test.py
index 5aa16c483be..02d974a061d 100644
--- a/tests/unit_tests/dataframe_test.py
+++ b/tests/unit_tests/dataframe_test.py
@@ -188,10 +188,11 @@ def
test_df_to_records_normalizes_only_exact_nonfinite_decimals() -> None:
]
frame = pd.DataFrame({"value": pd.Series(values, dtype=object)})
- records = df_to_records(frame)
+ records = df_to_records(frame, convert_decimals=True)
assert [records[index]["value"] for index in range(4)] == [None] * 4
- assert records[4]["value"] is finite
+ # SQL Lab quotes finite decimals so the browser keeps every digit.
+ assert records[4]["value"] == str(finite)
assert records[5]["value"] is hostile
strict_json = superset_json.dumps(records[:5], ignore_nan=False)
assert superset_json.loads(strict_json) == [
@@ -199,7 +200,7 @@ def
test_df_to_records_normalizes_only_exact_nonfinite_decimals() -> None:
{"value": None},
{"value": None},
{"value": None},
- {"value": 0.10000000000000000001},
+ {"value": "0.10000000000000000001"},
]
@@ -538,3 +539,79 @@ def
test_df_to_records_with_json_serialization_like_sql_lab() -> None:
)
parsed_no_flag = superset_json.loads(json_str_no_flag)
assert parsed_no_flag == parsed # Same result
+
+
[email protected](
+ "value",
+ [
+ "12345678901234567890.123456789012345678",
+ "-12345678901234567890.123456789012345678",
+ "0.000000000000000001",
+ "10.50",
+ "0.00",
+ "1E+30",
+ "0.000000000000000000",
+ "-0.000000100000000000",
+ ],
+)
+def test_decimal_records_keep_all_digits(value: str) -> None:
+ """Decimals become exact strings that survive a JSON round trip
unchanged."""
+ decimal_value: Decimal = Decimal(value)
+ frame = pd.DataFrame({"value": [decimal_value, None]})
+ records = df_to_records(frame, convert_decimals=True)
+ assert records == [{"value": format(decimal_value, "f")}, {"value": None}]
+ # Both the HTTP JSON and JSON cache must quote decimals for JavaScript.
+ assert superset_json.loads(superset_json.dumps(records)) == records
+ # SQL Lab conversion must not modify DataFrames used for chart arithmetic.
+ assert frame.iloc[0, 0] == decimal_value
+ assert isinstance(frame.iloc[0, 0], Decimal)
+
+
+def test_nested_decimal_records() -> None:
+ """Decimals nested inside dicts, lists and tuples also become exact
strings."""
+ value = Decimal("12345678901234567890.123456789012345678")
+ frame = pd.DataFrame({"value": [{"a": [value, None]}, (value,)]})
+ assert df_to_records(frame, convert_decimals=True) == [
+ {"value": {"a": [str(value), None]}},
+ {"value": (str(value),)},
+ ]
+
+
+def test_decimal_conversion_does_not_change_other_numbers() -> None:
+ """Ints, floats and bools pass through, and chart JSON still emits
numbers."""
+ frame = pd.DataFrame({"i": [2], "f": [0.5], "b": [True]})
+ assert df_to_records(frame, convert_decimals=True) == [
+ {"i": 2, "f": 0.5, "b": True}
+ ]
+ # Do not change the shared chart JSON serializer to emit decimal strings.
+ assert superset_json.loads(superset_json.dumps({"x": Decimal("10.50")}))
== {
+ "x": 10.5
+ }
+
+
+def test_decimal_conversion_is_disabled_by_default_for_chart_records() -> None:
+ """Chart records keep Decimal objects so the chart encoder emits
numbers."""
+ value = Decimal("10.50")
+ frame = pd.DataFrame({"value": [value, {"a": [value]}]})
+ records = df_to_records(frame)
+ assert records == [{"value": value}, {"value": {"a": [value]}}]
+ assert type(records[0]["value"]) is Decimal
+ assert superset_json.loads(superset_json.dumps(records)) == [
+ {"value": 10.5},
+ {"value": {"a": [10.5]}},
+ ]
+
+
+def test_high_scale_negative_decimal_csv() -> None:
+ """Small negative decimals export in fixed notation without formula
escaping."""
+ from superset.utils.csv import df_to_escaped_csv
+
+ records = df_to_records(
+ pd.DataFrame({"value": [Decimal("-0.000000100000000000")]}),
+ convert_decimals=True,
+ )
+ assert records == [{"value": "-0.000000100000000000"}]
+ assert (
+ df_to_escaped_csv(pd.DataFrame(records), index=False,
lineterminator="\n")
+ == "value\n-0.000000100000000000\n"
+ )
diff --git a/tests/unit_tests/result_set_test.py
b/tests/unit_tests/result_set_test.py
index 236fb376fa9..c178d84577a 100644
--- a/tests/unit_tests/result_set_test.py
+++ b/tests/unit_tests/result_set_test.py
@@ -18,9 +18,11 @@
# pylint: disable=import-outside-toplevel, unused-argument
from datetime import datetime, timezone
+from decimal import Decimal
import numpy as np
import pandas as pd
+import pytest
from numpy.core.multiarray import array
from pytest_mock import MockerFixture
@@ -643,3 +645,72 @@ def test_integers_outside_int64_are_stringified() -> None:
assert df["id"].tolist() == [1, 2, 3]
assert df["ubig"].iloc[:2].tolist() == ["18446744073709551615", "0"]
assert pd.isna(df["ubig"].iloc[2])
+
+
[email protected](
+ "values, expected",
+ [
+ (
+ [Decimal("Infinity"), Decimal("-0.00000010")],
+ [None, "-0.00000010"],
+ ),
+ (
+ [Decimal("-Infinity"), Decimal("NaN"), Decimal("1E+3")],
+ [None, None, "1000"],
+ ),
+ (
+ [Decimal("-0.00000010"), 1.5, Decimal("0E-18")],
+ ["-0.00000010", "1.5", "0.000000000000000000"],
+ ),
+ ],
+ ids=["infinity", "nan", "decimal-and-float"],
+)
+def test_stringified_decimals_use_fixed_point(
+ values: list[Decimal | float], expected: list[str | None]
+) -> None:
+ """
+ Decimal columns PyArrow cannot type (non-finite values, or Decimals mixed
+ with floats) are stringified without scientific notation, so CSV export
+ does not formula-escape small negatives. NaN and Infinity become null.
+ """
+ from superset.dataframe import df_to_records
+ from superset.utils.csv import df_to_escaped_csv
+
+ description = [("value", "numeric", None, None, None, None, True)]
+ data = [(value,) for value in values]
+ result_set = SupersetResultSet(data, description, BaseEngineSpec) # type:
ignore
+
+ records = df_to_records(result_set.to_pandas_df())
+ assert [record["value"] for record in records] == expected
+ csv = df_to_escaped_csv(pd.DataFrame(records), index=False,
lineterminator="\n")
+ assert "'" not in csv
+ assert "E-" not in csv
+ assert "E+" not in csv
+
+
[email protected](
+ "values, expected",
+ [
+ (
+ [Decimal("Infinity"), Decimal("-Infinity"), Decimal("NaN")],
+ [None, None, None],
+ ),
+ (
+ [Decimal("-0.00000010"), 1.5, Decimal("0E-18")],
+ ["-0.00000010", "1.5", "0.000000000000000000"],
+ ),
+ ],
+)
+def test_chart_dataframe_decimal_fallback(
+ mocker: MockerFixture,
+ values: list[Decimal | float],
+ expected: list[str | None],
+) -> None:
+ """Chart/dataset loading uses fixed-point strings and null non-finite
Decimals."""
+ from superset.models.core import Database
+
+ database = mocker.Mock(db_engine_spec=BaseEngineSpec)
+ description = [("value", "numeric", None, None, None, None, True)]
+ df = Database.load_into_dataframe(database, description, [(v,) for v in
values])
+
+ assert df["value"].tolist() == expected
diff --git a/tests/unit_tests/sql/execution/test_celery_task.py
b/tests/unit_tests/sql/execution/test_celery_task.py
index 1df4cf7755d..91a220f632d 100644
--- a/tests/unit_tests/sql/execution/test_celery_task.py
+++ b/tests/unit_tests/sql/execution/test_celery_task.py
@@ -896,7 +896,7 @@ def
test_serialize_result_set_json_normalizes_decimal_nonfinite(
data, _ = _serialize_result_set(mock_result_set)
assert isinstance(data, list)
- assert [row["value"] for row in data] == [None, None, None, None, finite]
+ assert [row["value"] for row in data] == [None, None, None, None,
str(finite)]
payload = _serialize_payload({"data": data})
payload_text = payload.decode() if isinstance(payload, bytes) else payload
diff --git a/tests/unit_tests/sql_lab_test.py b/tests/unit_tests/sql_lab_test.py
index f76ff4c1af8..77817152bf4 100644
--- a/tests/unit_tests/sql_lab_test.py
+++ b/tests/unit_tests/sql_lab_test.py
@@ -80,7 +80,7 @@ def test_sql_lab_and_view_json_normalize_decimal_nonfinite()
-> None:
result_set, BaseEngineSpec()
)
assert isinstance(data, list)
- assert [row["value"] for row in data] == [None, None, None, None, finite]
+ assert [row["value"] for row in data] == [None, None, None, None,
str(finite)]
payload = {
"data": data,