fix(objects): align filter counts with search
This commit is contained in:
parent
3300a66677
commit
2dad288907
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue