fix(objects): align filter counts with search

This commit is contained in:
二丫讲梵 2026-07-20 15:36:34 +08:00 committed by GitHub
parent 3300a66677
commit 2dad288907
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 152 additions and 44 deletions

View File

@ -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<SqlFormatDialect>(() => sqlFormatDialectFor
const objectFilters = computed<ObjectFilter[]>(() =>
(
[
["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) {

View File

@ -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<ObjectBrowserFilter, number>;
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

View File

@ -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", () => {