diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index def8628786..7c29ff14df 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -11,6 +11,8 @@ import { Code2, Copy, CopyPlus, + ChevronDown, + ChevronRight, Download, Eraser, Eye, @@ -142,6 +144,7 @@ const showDuplicateDialog = ref(false); const duplicateTarget = ref(null); const duplicateTableName = ref(""); const selectedTableIds = ref>(new Set()); +const expandedPartitionParentIds = ref>(new Set()); const showBatchDropConfirm = ref(false); const batchDropPreviewSql = ref(""); let loadId = 0; @@ -195,17 +198,17 @@ const gridTemplateColumns = computed(() => { if (hasComments.value) columns.push("minmax(160px,0.7fr)"); return columns.join(" "); }); -const searchedRows = computed(() => { - return filterObjectBrowserRows(rows.value, search.value); -}); -const filteredRows = computed(() => { - let rows = searchedRows.value; - if (objectFilter.value === "tables") rows = rows.filter((row) => row.type === "TABLE"); - if (objectFilter.value === "views") rows = rows.filter((row) => row.type === "VIEW"); - if (objectFilter.value === "procedures") rows = rows.filter((row) => row.type === "PROCEDURE"); - if (objectFilter.value === "functions") rows = rows.filter((row) => row.type === "FUNCTION"); - return sortObjectBrowserRows(rows, sortKey.value, sortDirection.value); +const partitionRowsByParentId = computed(() => { + const groups = new Map(); + for (const row of rows.value) { + if (!row.partitionParentId) continue; + const group = groups.get(row.partitionParentId) ?? []; + group.push(row); + groups.set(row.partitionParentId, group); + } + return groups; }); +const filteredRows = computed(() => groupedFilteredRows()); const selectableRows = computed(() => rows.value.filter((row) => row.type === "TABLE")); const visibleSelectableRows = computed(() => filteredRows.value.filter((row) => row.type === "TABLE")); const selectedTableRows = computed(() => { @@ -247,6 +250,46 @@ function toggleSort(key: ObjectBrowserSortKey) { sortDirection.value = initialObjectBrowserSortDirection(key); } +function rowMatchesObjectFilter(row: ObjectBrowserRow) { + if (objectFilter.value === "tables") return row.type === "TABLE"; + if (objectFilter.value === "views") return row.type === "VIEW"; + if (objectFilter.value === "procedures") return row.type === "PROCEDURE"; + if (objectFilter.value === "functions") return row.type === "FUNCTION"; + return true; +} + +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 matchingIds = new Set(matchingRows.map((row) => row.id)); + const parentIdsWithMatchingPartitions = new Set( + matchingRows.flatMap((row) => (row.partitionParentId ? [row.partitionParentId] : [])), + ); + const rootRows = candidateRows.filter((row) => { + if (row.partitionParentId) return false; + if (!query) return true; + return matchingIds.has(row.id) || parentIdsWithMatchingPartitions.has(row.id); + }); + const sortedRoots = sortObjectBrowserRows(rootRows, sortKey.value, sortDirection.value); + const result: ObjectBrowserRow[] = []; + + for (const row of sortedRoots) { + result.push(row); + const partitions = partitionRowsByParentId.value.get(row.id)?.filter((partition) => candidateIds.has(partition.id)); + if (!partitions?.length) continue; + const parentMatches = matchingIds.has(row.id); + const shouldShowPartitions = expandedPartitionParentIds.value.has(row.id) || !!query; + if (!shouldShowPartitions) continue; + const visiblePartitions = + query && !parentMatches ? partitions.filter((partition) => matchingIds.has(partition.id)) : partitions; + result.push(...sortObjectBrowserRows(visiblePartitions, sortKey.value, sortDirection.value)); + } + + return result; +} + function iconClass(type: ObjectBrowserRow["type"]) { if (type === "VIEW") return "text-purple-500"; if (type === "PROCEDURE") return "text-blue-500"; @@ -254,6 +297,18 @@ function iconClass(type: ObjectBrowserRow["type"]) { return "text-green-500"; } +function isPartitionParentExpanded(row: ObjectBrowserRow) { + return expandedPartitionParentIds.value.has(row.id); +} + +function togglePartitionParent(row: ObjectBrowserRow) { + if (!row.partitionCount) return; + const next = new Set(expandedPartitionParentIds.value); + if (next.has(row.id)) next.delete(row.id); + else next.add(row.id); + expandedPartitionParentIds.value = next; +} + function canOpenSource(row: ObjectBrowserRow) { return row.type === "VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION"; } @@ -945,6 +1000,11 @@ async function loadObjects() { }); const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id)); setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id)))); + expandedPartitionParentIds.value = new Set( + [...expandedPartitionParentIds.value].filter((id) => + rows.value.some((row) => row.id === id && row.partitionCount), + ), + ); } catch (e: any) { if (id !== loadId) return; error.value = e?.message || String(e); @@ -1305,8 +1365,25 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
+ + {{ item.name }} + + {{ t("objects.partitions", { count: item.partitionCount }) }} +
{{ typeLabel(item.type) }}
(); - return options.objects.flatMap((object) => { + const rows = options.objects.flatMap((object) => { const name = normalizeDatabaseObjectName(object.name); if (!name) return []; const objectSchema = object.schema ? normalizeDatabaseObjectName(object.schema) : undefined; @@ -50,6 +52,42 @@ export function buildObjectBrowserRows(options: { }, ]; }); + + markPartitionRows(rows, options.fallbackSchema || options.database); + return rows; +} + +function markPartitionRows(rows: ObjectBrowserRow[], fallbackSchema: string) { + const tableByKey = new Map(); + for (const row of rows) { + if (row.type !== "TABLE") continue; + tableByKey.set(objectKey(row, fallbackSchema), row); + } + + const partitionCountByParent = new Map(); + for (const row of rows) { + if (row.type !== "TABLE") continue; + const parentName = partitionParentName(row.name); + if (!parentName) continue; + const parentKey = objectKey({ ...row, name: parentName }, fallbackSchema); + const parent = tableByKey.get(parentKey); + if (!parent || parent.id === row.id) continue; + row.partitionParentId = parent.id; + partitionCountByParent.set(parent.id, (partitionCountByParent.get(parent.id) ?? 0) + 1); + } + + for (const row of rows) { + row.partitionCount = partitionCountByParent.get(row.id); + } +} + +function objectKey(row: Pick, fallbackSchema: string) { + return `${row.type}\0${(row.schema || fallbackSchema).toLowerCase()}\0${row.name.toLowerCase()}`; +} + +function partitionParentName(name: string): string | null { + const match = /^(.*)_p\d{4,}$/.exec(name); + return match?.[1] || null; } export function filterObjectBrowserRows(rows: ObjectBrowserRow[], query: string): ObjectBrowserRow[] { diff --git a/packages/app-tests/objectBrowserRows.test.ts b/packages/app-tests/objectBrowserRows.test.ts index f521fbd002..0d29ce6066 100644 --- a/packages/app-tests/objectBrowserRows.test.ts +++ b/packages/app-tests/objectBrowserRows.test.ts @@ -74,6 +74,28 @@ test("object browser rows preserve table timestamps and sort recent updates firs assert.equal(formatObjectBrowserTimestamp(rows[0].created_at), "2026-05-20 09:30:00"); }); +test("object browser rows mark partition-like tables when their parent table exists", () => { + const rows = buildObjectBrowserRows({ + objects: [ + { name: "order_data", object_type: "TABLE", schema: "public" }, + { name: "order_data_p20220802", object_type: "TABLE", schema: "public" }, + { name: "order_data_p20220803", object_type: "TABLE", schema: "public" }, + { name: "audit_p20220802", object_type: "TABLE", schema: "public" }, + ], + database: "app", + fallbackSchema: "public", + needsSchema: true, + }); + + const parent = rows.find((row) => row.name === "order_data"); + assert.equal(parent?.partitionCount, 2); + assert.deepEqual( + rows.filter((row) => row.partitionParentId === parent?.id).map((row) => row.name), + ["order_data_p20220802", "order_data_p20220803"], + ); + assert.equal(rows.find((row) => row.name === "audit_p20220802")?.partitionParentId, undefined); +}); + test("object browser timestamp display strips timezone suffixes", () => { assert.equal(formatObjectBrowserTimestamp("2026-05-22 10:18:24+08"), "2026-05-22 10:18:24"); assert.equal(formatObjectBrowserTimestamp("2026-05-22 10:18:24.123456+08:00"), "2026-05-22 10:18:24");