EnxDev commented on code in PR #44739:
URL: https://github.com/apache/superset/pull/44739#discussion_r4144268474


##########
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 = True,

Review Comment:
   With this defaulting to `True`, any new caller of `df_to_records` gets 
decimal strings without asking for them. That already bit once: the master 
merge sent chart JSON through here and it needed the `convert_decimals=False` 
opt-out.
   
   Could we flip the default to `False` and pass `True` at the three SQL Lab 
call sites (`sql_lab.py:426`, `celery_task.py:326`, `views/utils.py:668`)? The 
existing `sql_lab_test.py` and `test_celery_task.py` assertions would keep 
those honest.



##########
superset-frontend/src/components/FilterableTable/sortResults.test.ts:
##########
@@ -0,0 +1,237 @@
+/**
+ * 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, 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' }];
+  const parse = jest.spyOn(global, 'BigInt');

Review Comment:
   Nit, take it or leave it. Spying on the global `BigInt` and asserting exact 
multiples of the call count ties this test to how many times the parser calls 
`BigInt`, so a harmless parser change breaks it.
   
   Counting cache hits through a small exported seam would hold up better.



##########
UPDATING.md:
##########
@@ -89,6 +89,23 @@ cleartext.
 Connections using the separate MariaDB engine (mariadb:// URIs) and other
 MySQL-compatible engines 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

Review Comment:
   There's a second extension surface here: a component registered as 
`sqleditor.extension.resultTable` replaces `FilterableTable` in 
`ResultSet/index.tsx:210` and `TablePreview/index.tsx:192`. It gets the string 
rows but not the new comparator, so it'll sort `"10.50"` as text.
   
   Worth one more clause naming that override next to `onDidQuerySuccess`?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to