mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:28:17 +00:00
fix(grid): count typed BSON numbers in Mongo grid summaries
This commit is contained in:
@@ -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].
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user