From 2dad2889072c65743375da0ccb2904c47851b436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Mon, 20 Jul 2026 15:36:34 +0800 Subject: [PATCH] fix(objects): align filter counts with search --- .../src/components/objects/ObjectBrowser.vue | 77 ++++++++--------- .../src/lib/table/objectBrowserRows.ts | 36 ++++++++ packages/app-tests/objectBrowserRows.test.ts | 83 ++++++++++++++++++- 3 files changed, 152 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 8ec5c936e..6df2a7e69 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -84,12 +84,14 @@ import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { buildObjectBrowserRows, - filterObjectBrowserRows, + countObjectBrowserRowsByFilter, formatObjectBrowserBytes, formatObjectBrowserCount, formatObjectBrowserTimestamp, initialObjectBrowserSortDirection, sortObjectBrowserRows, + summarizeObjectBrowserSearch, + type ObjectBrowserFilter, type ObjectBrowserRow, type ObjectBrowserSortDirection, type ObjectBrowserSortKey, @@ -100,7 +102,7 @@ import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard"; import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation"; -type ObjectFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "sequences" | "packages" | "types"; +type ObjectFilter = ObjectBrowserFilter; type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment"; const props = defineProps<{ @@ -236,15 +238,9 @@ const { addTask: addExportTask } = useExportTracker(); const needsSchema = computed(() => isSchemaAware(props.connection.db_type) && !connectionUsesDatabaseObjectTreeMode(props.connection)); const canDropTargetCascade = computed(() => dropTarget.value?.type === "TABLE" && supportsDropTableCascade(effectiveDatabaseType.value)); const canTruncateTargetCascade = computed(() => !!truncateTarget.value && supportsTruncateTableCascade(effectiveDatabaseType.value)); -const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE").length); -const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length); -const materializedViewCount = computed(() => rows.value.filter((row) => row.type === "MATERIALIZED_VIEW").length); -const procedureCount = computed(() => rows.value.filter((row) => row.type === "PROCEDURE").length); -const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length); -const triggerCount = computed(() => rows.value.filter((row) => row.type === "TRIGGER").length); -const sequenceCount = computed(() => rows.value.filter((row) => row.type === "SEQUENCE").length); -const packageCount = computed(() => rows.value.filter((row) => row.type === "PACKAGE" || row.type === "PACKAGE_BODY").length); -const typeCount = computed(() => rows.value.filter((row) => row.type === "TYPE" || row.type === "TYPE_BODY").length); +const objectCounts = computed(() => countObjectBrowserRowsByFilter(rows.value)); +// Count direct search matches once; partition parents rendered only for context must not inflate badges. +const objectSearchSummary = computed(() => summarizeObjectBrowserSearch(rows.value, search.value)); const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tableStructureDatabaseType.value)); const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value)); const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value)); @@ -254,16 +250,16 @@ const sourceFormatDialect = computed(() => sqlFormatDialectFor const objectFilters = computed(() => ( [ - ["all", rows.value.length], - ["tables", tableCount.value], - ["views", viewCount.value], - ["materializedViews", materializedViewCount.value], - ["procedures", procedureCount.value], - ["functions", functionCount.value], - ["triggers", triggerCount.value], - ["sequences", sequenceCount.value], - ["packages", packageCount.value], - ["types", typeCount.value], + ["all", objectCounts.value.all], + ["tables", objectCounts.value.tables], + ["views", objectCounts.value.views], + ["materializedViews", objectCounts.value.materializedViews], + ["procedures", objectCounts.value.procedures], + ["functions", objectCounts.value.functions], + ["triggers", objectCounts.value.triggers], + ["sequences", objectCounts.value.sequences], + ["packages", objectCounts.value.packages], + ["types", objectCounts.value.types], ] as Array<[ObjectFilter, number]> ) .filter(([filter, count]) => filter === "all" || count > 0) @@ -643,24 +639,28 @@ function resetObjectColumnWidth(key: ObjectBrowserColumnKey, width: number, even }; } -function rowMatchesObjectFilter(row: ObjectBrowserRow) { - if (objectFilter.value === "tables") return row.type === "TABLE"; - if (objectFilter.value === "views") return row.type === "VIEW"; - if (objectFilter.value === "materializedViews") return row.type === "MATERIALIZED_VIEW"; - if (objectFilter.value === "procedures") return row.type === "PROCEDURE"; - if (objectFilter.value === "functions") return row.type === "FUNCTION"; - if (objectFilter.value === "triggers") return row.type === "TRIGGER"; - if (objectFilter.value === "sequences") return row.type === "SEQUENCE"; - if (objectFilter.value === "packages") return row.type === "PACKAGE" || row.type === "PACKAGE_BODY"; - if (objectFilter.value === "types") return row.type === "TYPE" || row.type === "TYPE_BODY"; +function rowMatchesFilter(row: ObjectBrowserRow, filter: ObjectFilter) { + if (filter === "tables") return row.type === "TABLE"; + if (filter === "views") return row.type === "VIEW"; + if (filter === "materializedViews") return row.type === "MATERIALIZED_VIEW"; + if (filter === "procedures") return row.type === "PROCEDURE"; + if (filter === "functions") return row.type === "FUNCTION"; + if (filter === "triggers") return row.type === "TRIGGER"; + if (filter === "sequences") return row.type === "SEQUENCE"; + if (filter === "packages") return row.type === "PACKAGE" || row.type === "PACKAGE_BODY"; + if (filter === "types") return row.type === "TYPE" || row.type === "TYPE_BODY"; return true; } +function rowMatchesObjectFilter(row: ObjectBrowserRow) { + return rowMatchesFilter(row, objectFilter.value); +} + function groupedFilteredRows() { const query = search.value.trim(); const candidateRows = rows.value.filter(rowMatchesObjectFilter); const candidateIds = new Set(candidateRows.map((row) => row.id)); - const matchingRows = filterObjectBrowserRows(candidateRows, query); + const matchingRows = objectSearchSummary.value.matchingRows.filter(rowMatchesObjectFilter); const matchingIds = new Set(matchingRows.map((row) => row.id)); const parentIdsWithMatchingPartitions = new Set(matchingRows.flatMap((row) => (row.partitionParentId ? [row.partitionParentId] : []))); const rootRows = candidateRows.filter((row) => { @@ -2088,7 +2088,7 @@ async function loadObjects() { } finally { if (id === loadId) { loadingObjects.value = false; - if (!userHasSelectedFilter.value && tableCount.value > 0) { + if (!userHasSelectedFilter.value && objectCounts.value.tables > 0) { // The default table filter is a presentation choice, not a user query // change, so preserve the tab's saved scroll offset across remounts. preserveObjectFilterScrollOnce = objectFilter.value !== "tables"; @@ -2151,16 +2151,7 @@ function onSchemaChange(value: any) { } function filterCount(filter: ObjectFilter) { - if (filter === "tables") return tableCount.value; - if (filter === "views") return viewCount.value; - if (filter === "materializedViews") return materializedViewCount.value; - if (filter === "procedures") return procedureCount.value; - if (filter === "functions") return functionCount.value; - if (filter === "triggers") return triggerCount.value; - if (filter === "sequences") return sequenceCount.value; - if (filter === "packages") return packageCount.value; - if (filter === "types") return typeCount.value; - return rows.value.length; + return objectSearchSummary.value.counts[filter]; } function filterLabel(filter: ObjectFilter) { diff --git a/apps/desktop/src/lib/table/objectBrowserRows.ts b/apps/desktop/src/lib/table/objectBrowserRows.ts index 3feb1136a..1a722448e 100644 --- a/apps/desktop/src/lib/table/objectBrowserRows.ts +++ b/apps/desktop/src/lib/table/objectBrowserRows.ts @@ -23,6 +23,8 @@ export type ObjectBrowserRow = { export type ObjectBrowserSortKey = "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment"; export type ObjectBrowserSortDirection = "asc" | "desc"; +export type ObjectBrowserFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "sequences" | "packages" | "types"; +export type ObjectBrowserFilterCounts = Record; export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type"] { const value = type.toUpperCase(); @@ -125,6 +127,40 @@ export function filterObjectBrowserRows(rows: ObjectBrowserRow[], query: string) return rows.filter((row) => [row.displayName, row.name, row.type, row.comment].filter(Boolean).some((value) => String(value).toLowerCase().includes(q))); } +export function countObjectBrowserRowsByFilter(rows: ObjectBrowserRow[]): ObjectBrowserFilterCounts { + const counts: ObjectBrowserFilterCounts = { + all: rows.length, + tables: 0, + views: 0, + materializedViews: 0, + procedures: 0, + functions: 0, + triggers: 0, + sequences: 0, + packages: 0, + types: 0, + }; + + for (const row of rows) { + if (row.type === "TABLE") counts.tables++; + else if (row.type === "VIEW") counts.views++; + else if (row.type === "MATERIALIZED_VIEW") counts.materializedViews++; + else if (row.type === "PROCEDURE") counts.procedures++; + else if (row.type === "FUNCTION") counts.functions++; + else if (row.type === "TRIGGER") counts.triggers++; + else if (row.type === "SEQUENCE") counts.sequences++; + else if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") counts.packages++; + else if (row.type === "TYPE" || row.type === "TYPE_BODY") counts.types++; + } + + return counts; +} + +export function summarizeObjectBrowserSearch(rows: ObjectBrowserRow[], query: string): { matchingRows: ObjectBrowserRow[]; counts: ObjectBrowserFilterCounts } { + const matchingRows = filterObjectBrowserRows(rows, query); + return { matchingRows, counts: countObjectBrowserRowsByFilter(matchingRows) }; +} + export function sortObjectBrowserRows(rows: ObjectBrowserRow[], key: ObjectBrowserSortKey, direction: ObjectBrowserSortDirection): ObjectBrowserRow[] { const multiplier = direction === "asc" ? 1 : -1; // Sort by natural visible name, matching the sidebar tree ordering diff --git a/packages/app-tests/objectBrowserRows.test.ts b/packages/app-tests/objectBrowserRows.test.ts index 21080c926..fe00aec7a 100644 --- a/packages/app-tests/objectBrowserRows.test.ts +++ b/packages/app-tests/objectBrowserRows.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { buildObjectBrowserRows, filterObjectBrowserRows, formatObjectBrowserBytes, formatObjectBrowserCount, formatObjectBrowserTimestamp, sortObjectBrowserRows } from "../../apps/desktop/src/lib/table/objectBrowserRows.ts"; +import { buildObjectBrowserRows, filterObjectBrowserRows, formatObjectBrowserBytes, formatObjectBrowserCount, formatObjectBrowserTimestamp, sortObjectBrowserRows, summarizeObjectBrowserSearch } from "../../apps/desktop/src/lib/table/objectBrowserRows.ts"; test("builds unique row ids for overloaded routines with the same visible name", () => { const rows = buildObjectBrowserRows({ @@ -130,6 +130,79 @@ test("object browser search supports slash-delimited regular expression queries" ); }); +test("object browser search summary aligns all and type counts with matches", () => { + const rows = buildObjectBrowserRows({ + objects: [ + { name: "sales_report", object_type: "TABLE", schema: "public" }, + { name: "sales_report_view", object_type: "VIEW", schema: "public" }, + { name: "sales_report_refresh", object_type: "PROCEDURE", schema: "public" }, + { name: "users", object_type: "TABLE", schema: "public" }, + ], + database: "app", + fallbackSchema: "public", + }); + + const summary = summarizeObjectBrowserSearch(rows, "sales_report"); + + assert.equal(summary.counts.all, 3); + assert.equal(summary.counts.tables, 1); + assert.equal(summary.counts.views, 1); + assert.equal(summary.counts.procedures, 1); + assert.equal(summary.counts.functions, 0); +}); + +test("object browser search summary preserves empty, case-insensitive, and regex searches", () => { + const rows = buildObjectBrowserRows({ + objects: [ + { name: "Sales_Report", object_type: "TABLE", schema: "public" }, + { name: "sales_archive", object_type: "VIEW", schema: "public" }, + { name: "users", object_type: "TABLE", schema: "public" }, + ], + database: "app", + fallbackSchema: "public", + }); + + assert.equal(summarizeObjectBrowserSearch(rows, " ").counts.all, 3); + assert.deepEqual( + summarizeObjectBrowserSearch(rows, "SALES_REPORT").matchingRows.map((row) => row.name), + ["Sales_Report"], + ); + assert.deepEqual( + summarizeObjectBrowserSearch(rows, "/^sales_(report|archive)$/i").matchingRows.map((row) => row.name), + ["Sales_Report", "sales_archive"], + ); +}); + +test("object browser search summary scans large collections only once for every filter count", () => { + const rowCount = 2_000; + let nameReads = 0; + const rows = buildObjectBrowserRows({ + objects: Array.from({ length: rowCount }, (_, index) => ({ + name: index % 2 === 0 ? `target_${index}` : `other_${index}`, + object_type: index % 3 === 0 ? "VIEW" : "TABLE", + schema: "public", + })), + database: "app", + fallbackSchema: "public", + }); + for (const row of rows) { + const name = row.name; + Object.defineProperty(row, "name", { + configurable: true, + get() { + nameReads++; + return name; + }, + }); + } + + const summary = summarizeObjectBrowserSearch(rows, "target_"); + + assert.equal(summary.counts.all, rowCount / 2); + assert.equal(summary.counts.tables + summary.counts.views, rowCount / 2); + assert.equal(nameReads, rowCount); +}); + test("object browser rows preserve table timestamps and sort recent updates first", () => { const rows = buildObjectBrowserRows({ objects: [ @@ -252,6 +325,14 @@ test("object browser rows mark partition-like tables when their parent table exi ["order_data_p20220802", "order_data_p20220803"], ); assert.equal(rows.find((row) => row.name === "audit_p20220802")?.partitionParentId, undefined); + + const partitionSummary = summarizeObjectBrowserSearch(rows, "p20220803"); + assert.deepEqual( + partitionSummary.matchingRows.map((row) => row.name), + ["order_data_p20220803"], + ); + assert.equal(partitionSummary.counts.all, 1); + assert.equal(partitionSummary.matchingRows[0].partitionParentId, parent?.id); }); test("object browser rows use explicit partition metadata before name heuristics", () => {