fix(grid): count typed BSON numbers in Mongo grid summaries

This commit is contained in:
Xcrong
2026-10-03 13:52:42 +08:00
committed by GitHub
parent 03c02018af
commit e5b1a93359
5 changed files with 158 additions and 10 deletions
@@ -113,7 +113,7 @@ import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructur
import { buildTableSelectSql, qualifyTableReferencesInSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
import { uuid } from "@/lib/common/utils";
import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration";
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridClipboardText, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridInputValue, mongoDocumentRelaxedExtendedJson } from "@/lib/mongo/mongoDocumentValues";
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridClipboardText, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridInputValue, mongoDocumentGridNumericValue, mongoDocumentRelaxedExtendedJson } from "@/lib/mongo/mongoDocumentValues";
import { compactHeaderColumnType, formatMetadataColumnTypeLabel, isNumericColumnType, resolveDataGridTypeVisualKind, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType";
import { dataGridCellTextClass, dataGridTypeVisualClass } from "@/lib/dataGrid/dataGridCellTextVisual";
import { DATA_GRID_TYPE_COLOR_KEYS, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
@@ -289,7 +289,7 @@ import { allNullColumnIndexes } from "@/lib/dataGrid/dataGridColumnVisibility";
import { buildDataGridColumnLookupItems, dataGridColumnCommentFor, filterDataGridColumnLookupItems } from "@/lib/dataGrid/dataGridColumnLookup";
import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { createPendingSelectionSummary, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { createPendingSelectionSummary, formatSelectionAggregate, formatSelectionAverage, summarizeSelection, type SelectionSummaryOptions } from "@/lib/dataGrid/gridSelection";
import { captureDataGridSelection, restoreDataGridSelection, type CaptureDataGridSelectionOptions, type PersistedDataGridSelection } from "@/lib/dataGrid/dataGridSelectionPersistence";
import { buildDataGridViewProbe, clampDataGridViewSelection, DATA_GRID_VIEW_SNAPSHOT_RESTORE, consumeDataGridViewSnapshot, peekDataGridViewSnapshot, saveDataGridViewSnapshot, shouldNotifyOverBudgetSelection, type DataGridViewSelectionSnapshot } from "@/lib/dataGrid/dataGridViewStateCache";
import { dataGridFrameCoversRow, dataGridSelectionEdgeMask, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame, resolveDataGridSelectionFrames } from "@/lib/dataGrid/dataGridSelectionFrames";
@@ -5917,13 +5917,18 @@ const multiRowCount = computed(() => {
return 1;
});
// The MongoDB collection grid keeps int32/int64/decimal128 scalars as typed text
// (`NumberLong("-7")`, `{"$numberDecimal":"-12.5"}`). Resolving those here keeps
// SUM/AVG over a whole column equal to every number in that column.
const selectionSummaryOptions = computed<SelectionSummaryOptions>(() => (usesMongoDocumentGridValues.value ? { numericValue: mongoDocumentGridNumericValue } : {}));
// 框选拖拽中不物化 selectedCells / 不做数值汇总(大选区下这是 DOM 卡顿主因)
const selectionSummary = computed(() => {
if (!hasCellSelection.value) return null;
if (isSelectingCells.value) {
return createPendingSelectionSummary(selectedCellCount.value, multiRowCount.value);
}
return summarizeSelection(selectedCells.value);
return summarizeSelection(selectedCells.value, selectionSummaryOptions.value);
});
const selectionSummarySumText = computed(() => {
if (isSelectingCells.value) return "…";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createPendingSelectionSummary, dedupeColumnIndexes, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { createPendingSelectionSummary, dedupeColumnIndexes, formatSelectionAggregate, formatSelectionAverage, plainSelectionNumericValue, summarizeSelection } from "@/lib/dataGrid/gridSelection";
describe("gridSelection", () => {
it("summarizes empty selections", () => {
@@ -89,6 +89,20 @@ describe("gridSelection", () => {
});
});
it("summarizes with a grid-specific numeric resolver", () => {
// A grid can hand cells over in a shape plain coercion rejects; the resolver
// decides what takes part, so a column total never silently drops values.
const numericValue = (value: string | number | boolean | null): number | undefined => {
const prefixed = typeof value === "string" ? /^#(-?[\d.]+)$/.exec(value) : null;
if (prefixed) return Number(prefixed[1]);
return plainSelectionNumericValue(value);
};
const selection = { columns: ["value"], rows: [[10], ["-4"], ["#6"], ["#-0.5"], ["x"]] };
expect(summarizeSelection(selection)).toMatchObject({ numericCount: 2, sum: 6, average: 3 });
expect(summarizeSelection(selection, { numericValue })).toMatchObject({ numericCount: 4, sum: 11.5, average: 2.875 });
});
it("keeps pending summaries count-only and formats sum and average identically", () => {
expect(createPendingSelectionSummary(1_000_000, 100_000)).toEqual({
cellCount: 1_000_000,
@@ -105,6 +119,17 @@ describe("gridSelection", () => {
expect(formatSelectionAverage(Number.POSITIVE_INFINITY, "en-US")).toBe("—");
});
it("ignores non-finite and non-numeric cells in the default resolver", () => {
expect(plainSelectionNumericValue(2.5)).toBe(2.5);
expect(plainSelectionNumericValue(" -4 ")).toBe(-4);
expect(plainSelectionNumericValue(Number.NaN)).toBeUndefined();
expect(plainSelectionNumericValue(Number.POSITIVE_INFINITY)).toBeUndefined();
expect(plainSelectionNumericValue(" ")).toBeUndefined();
expect(plainSelectionNumericValue('NumberLong("-6")')).toBeUndefined();
expect(plainSelectionNumericValue(true)).toBeUndefined();
expect(plainSelectionNumericValue(null)).toBeUndefined();
});
it("dedupeColumnIndexes preserves the first visible occurrence instead of sorting", () => {
// After drag-reordering, visible order is [2, 0, 1]; extraction must keep it,
// not revert to numeric [0, 1, 2].
+27 -6
View File
@@ -30,6 +30,17 @@ export interface SelectionSummary {
average: number | null;
}
export interface SelectionSummaryOptions {
/**
* Resolves a cell to the number it contributes to SUM/AVG. Defaults to plain
* numbers and numeric text. Grids whose cells hold typed scalar literals (the
* MongoDB collection grid keeps `NumberLong("7")` / `{"$numberDecimal":"-1.5"}`
* as text) pass their own resolver, otherwise every wrapped value — negatives
* included — is silently dropped from the total.
*/
numericValue?: (value: GridCellValue) => number | undefined;
}
export function parseClipboardTable(text: string): string[][] {
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n$/, "");
if (!normalized) return [[""]];
@@ -105,17 +116,27 @@ export function extractColumnsSelection(columns: readonly string[], rows: readon
};
}
export function summarizeSelection(selection: SelectionData): SelectionSummary {
/** Plain numbers and numeric text; booleans, null and everything else never take part. */
export function plainSelectionNumericValue(value: GridCellValue): number | undefined {
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
return Number.isFinite(numeric) ? numeric : undefined;
}
export function summarizeSelection(selection: SelectionData, options: SelectionSummaryOptions = {}): SelectionSummary {
const numericValue = options.numericValue ?? plainSelectionNumericValue;
let numericCount = 0;
let sum = 0;
for (const row of selection.rows) {
for (const value of row) {
const numericValue = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
if (Number.isFinite(numericValue)) {
numericCount += 1;
sum += numericValue;
}
const resolved = numericValue(value);
if (resolved === undefined || !Number.isFinite(resolved)) continue;
numericCount += 1;
sum += resolved;
}
}
@@ -406,6 +406,73 @@ function mongoShellNumberLongToExtendedJson(value: string): unknown {
return match ? { $numberLong: match[2] } : value;
}
/**
* `NumberLong("-7")`, `NumberInt(3)`, `NumberDouble("1.5")`, `NumberDecimal("-12.5")`
* — the shell-style display text the collection grid renders typed BSON scalars with.
*/
const MONGO_SHELL_NUMBER_LITERAL_PATTERN = /^(?:NumberLong|NumberInt|NumberDouble|NumberDecimal)\(\s*(?:"([^"]*)"|'([^']*)'|([^()]*))\s*\)$/;
const MONGO_NUMERIC_WRAPPER_KEYS: ReadonlySet<string> = new Set<string>(MONGO_EXTENDED_JSON_NUMERIC_TYPES.keys());
function mongoNumericText(text: string): number | undefined {
const trimmed = text.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
return Number.isFinite(numeric) ? numeric : undefined;
}
function mongoShellNumericText(text: string): number | undefined {
const literal = MONGO_SHELL_NUMBER_LITERAL_PATTERN.exec(text.trim());
if (!literal) return mongoNumericText(text);
return mongoNumericText(literal[1] ?? literal[2] ?? literal[3] ?? "");
}
function mongoJsonNumericValue(json: string): number | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return undefined;
}
if (typeof parsed === "number") return Number.isFinite(parsed) ? parsed : undefined;
if (typeof parsed === "string") return mongoShellNumericText(parsed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
const entries = Object.entries(parsed as Record<string, unknown>);
if (entries.length !== 1) return undefined;
const [key, inner] = entries[0]!;
if (!MONGO_NUMERIC_WRAPPER_KEYS.has(key)) return undefined;
if (typeof inner === "number") return Number.isFinite(inner) ? inner : undefined;
return typeof inner === "string" ? mongoNumericText(inner) : undefined;
}
/**
* The number a collection-grid cell contributes to a selection total, or
* undefined when it is not a number.
*
* BSON keeps numeric types the JSON grid cannot express: `int32` / `int64` /
* `decimal128` values arrive as extended-JSON wrappers or shell literals
* (`NumberLong("-7")`), so plain `Number(...)` coercion rejects them. Without
* this, a column whose plain doubles are counted while its wrapped values are
* skipped reports a total built from only part of the column — the dropped part
* being whichever type the rest of the column happens not to use, commonly the
* negative entries.
*
* Shell literals are read exactly as the grid displays them, so a BSON string
* whose text happens to be `NumberLong("-7")` contributes -7; that string is
* indistinguishable from a typed Int64 once it reaches a cell.
*/
export function mongoDocumentGridNumericValue(value: unknown): number | undefined {
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
if (typeof value !== "string") return undefined;
if (value === MONGO_DOCUMENT_GRID_NULL) return undefined;
// Values in the reserved namespace are escaped BSON strings: only plain
// numeric text counts, never a shell literal that was stored as text.
const escaped = mongoDocumentGridEscapedString(value);
if (escaped !== undefined) return mongoNumericText(escaped);
const json = mongoDocumentGridJson(value);
if (json !== undefined) return mongoJsonNumericValue(json);
return mongoShellNumericText(value);
}
export function buildMongoUpdateDocument(changes: Map<number, MongoInputValue>, columns: string[], originalDocument?: unknown): Record<string, unknown> {
const setFields: Record<string, unknown> = {};
const unsetFields: Record<string, unknown> = {};
+30
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { summarizeSelection } from "../../apps/desktop/src/lib/dataGrid/gridSelection.ts";
import {
applyMongoGridChangesToDocument,
applyMongoGridChangesToDocumentBaseline,
@@ -17,6 +18,7 @@ import {
mongoDocumentGridExternalValue,
mongoDocumentGridInputValue,
mongoDocumentGridValue,
mongoDocumentGridNumericValue,
mongoDocumentGridColumnTypes,
mongoDocumentIdForGrid,
mongoDocumentRelaxedExtendedJson,
@@ -443,3 +445,31 @@ test("restores internal Mongo collection-grid values for external output", () =>
assert.equal(mongoDocumentGridExternalValue(MONGO_DOCUMENT_GRID_NULL), null);
assert.equal(mongoDocumentGridExternalValue("NULL"), "NULL");
});
test("counts typed BSON scalars as numbers so a column total keeps its negative values", () => {
const documents = [{ charactersUsed: 2528.125 }, { charactersUsed: { $numberDecimal: "-12.5" } }, { charactersUsed: -100.5 }, { charactersUsed: { $numberInt: "-5" } }, { charactersUsed: { $numberLong: "9" } }];
const rows = documents.map((document) => [mongoDocumentGridValue(document.charactersUsed)]);
const summary = summarizeSelection({ columns: ["charactersUsed"], rows }, { numericValue: mongoDocumentGridNumericValue });
assert.equal(summary.numericCount, 5);
assert.equal(summary.sum, 2528.125 - 12.5 - 100.5 - 5 + 9);
assert.equal(summary.average, (2528.125 - 12.5 - 100.5 - 5 + 9) / 5);
});
test("keeps non-numeric Mongo cells out of a column total", () => {
assert.equal(mongoDocumentGridNumericValue(mongoDocumentGridValue(null)), undefined);
assert.equal(mongoDocumentGridNumericValue(true), undefined);
assert.equal(mongoDocumentGridNumericValue(""), undefined);
assert.equal(mongoDocumentGridNumericValue("not a number"), undefined);
assert.equal(mongoDocumentGridNumericValue(Number.NaN), undefined);
assert.equal(mongoDocumentGridNumericValue(Number.POSITIVE_INFINITY), undefined);
assert.equal(mongoDocumentGridNumericValue(mongoDocumentGridValue({ $date: "2026-06-10T13:59:31.287Z" })), undefined);
assert.equal(mongoDocumentGridNumericValue(mongoDocumentGridValue({ $oid: "6743e4bfa3f6f84bc3fff6c8" })), undefined);
assert.equal(mongoDocumentGridNumericValue(mongoDocumentGridValue({ nested: { amount: -12.5 } })), undefined);
// A BSON string that spells a shell literal is read exactly as the grid renders it.
assert.equal(mongoDocumentGridNumericValue(mongoDocumentGridValue('NumberLong("-7")')), -7);
// Reserved-namespace text is never unwrapped as a literal.
assert.equal(mongoDocumentGridNumericValue(mongoDocumentGridValue(MONGO_DOCUMENT_GRID_NULL)), undefined);
// Numeric text stored as a BSON string still counts, as it does in every other grid.
assert.equal(mongoDocumentGridNumericValue(mongoDocumentGridValue("-12.5")), -12.5);
});