From e5b1a93359f5c8a567cc17c4e3559e35f783f6ed Mon Sep 17 00:00:00 2001 From: Xcrong <46434477+xcrong@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:52:42 +0800 Subject: [PATCH] fix(grid): count typed BSON numbers in Mongo grid summaries --- apps/desktop/src/components/grid/DataGrid.vue | 11 ++- .../__tests__/dataGrid/gridSelection.spec.ts | 27 +++++++- .../desktop/src/lib/dataGrid/gridSelection.ts | 33 +++++++-- .../src/lib/mongo/mongoDocumentValues.ts | 67 +++++++++++++++++++ .../app-tests/mongoDocumentValues.test.ts | 30 +++++++++ 5 files changed, 158 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index b3e7c2142a..72824f0079 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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(() => (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 "…"; diff --git a/apps/desktop/src/lib/__tests__/dataGrid/gridSelection.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/gridSelection.spec.ts index f1c4caf961..07a8d6dfe7 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/gridSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/gridSelection.spec.ts @@ -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]. diff --git a/apps/desktop/src/lib/dataGrid/gridSelection.ts b/apps/desktop/src/lib/dataGrid/gridSelection.ts index 76da725b8c..bb271b0c57 100644 --- a/apps/desktop/src/lib/dataGrid/gridSelection.ts +++ b/apps/desktop/src/lib/dataGrid/gridSelection.ts @@ -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; } } diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index 28cee05abd..b738186697 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -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 = new Set(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); + 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, columns: string[], originalDocument?: unknown): Record { const setFields: Record = {}; const unsetFields: Record = {}; diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index 7e7b609d45..2edcf8a7da 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -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); +});