diff --git a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts index 72d1346c0..549ec6aa4 100644 --- a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { appendTableTreeLoadMoreNode, buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, mergeTableTreePageChildren, tablePartitionGroups, withoutTableTreeLoadMoreNodes } from "@/lib/table/tableTree"; +import { appendTableTreeLoadMoreNode, buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, mergeTableInfosIntoObjects, mergeTableTreePageChildren, tablePartitionGroups, withoutTableTreeLoadMoreNodes } from "@/lib/table/tableTree"; import type { ObjectInfo, TableInfo, TreeNode } from "@/types/database"; const context = { @@ -8,6 +8,54 @@ const context = { database: "db", }; +describe("case-sensitive database objects", () => { + const views: ObjectInfo[] = [ + { name: "dbx_issue4529_case_V1", object_type: "VIEW", schema: "dbx_test" }, + { name: "dbx_issue4529_case_v1", object_type: "VIEW", schema: "dbx_test" }, + ]; + + it("keeps table metadata entries whose names differ only by case", () => { + const tables: TableInfo[] = views.map((view) => ({ name: view.name, table_type: "VIEW", comment: null })); + + const merged = mergeTableInfosIntoObjects([], tables, "dbx_test"); + + expect(merged.map((object) => object.name)).toEqual(["dbx_issue4529_case_V1", "dbx_issue4529_case_v1"]); + }); + + it("keeps simple tree nodes whose names differ only by case", () => { + const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "dbx_test", objects: views }); + + expect(nodes.map((node) => node.label)).toEqual(["dbx_issue4529_case_V1", "dbx_issue4529_case_v1"]); + expect(new Set(nodes.map((node) => node.id)).size).toBe(2); + }); + + it("keeps grouped tree nodes whose names differ only by case", () => { + const groups = buildGroupedObjectTreeNodes({ ...context, schema: "dbx_test", objects: views }); + const viewGroup = groups.find((node) => node.type === "group-views"); + + expect(viewGroup?.objectCount).toBe(2); + expect(viewGroup?.children?.map((node) => node.label)).toEqual(["dbx_issue4529_case_V1", "dbx_issue4529_case_v1"]); + expect(new Set(viewGroup?.children?.map((node) => node.id) ?? []).size).toBe(2); + }); + + it("keeps table nodes whose names differ only by case across pages", () => { + const firstPage = buildTableTreeNodes({ + ...context, + schema: "dbx_test", + tables: [{ name: "dbx_issue4529_case_T1", table_type: "BASE TABLE", comment: null }], + }); + const secondPage = buildTableTreeNodes({ + ...context, + schema: "dbx_test", + tables: [{ name: "dbx_issue4529_case_t1", table_type: "BASE TABLE", comment: null }], + }); + + const merged = mergeTableTreePageChildren(firstPage, secondPage, context.connectionId, context.database); + + expect(merged.map((node) => node.label)).toEqual(["dbx_issue4529_case_T1", "dbx_issue4529_case_t1"]); + }); +}); + describe("PostgreSQL overloaded routines", () => { it("keeps routines with the same name distinct by identity arguments", () => { const objects: ObjectInfo[] = [ @@ -64,6 +112,61 @@ describe("programmable database objects", () => { }); describe("PostgreSQL table hierarchy", () => { + it("matches partition parents case-insensitively", () => { + const nodes = buildTableTreeNodes({ + ...context, + schema: "public", + tables: [ + { name: "Orders", table_type: "PARTITIONED TABLE", comment: null }, + { name: "orders_2026", table_type: "TABLE", comment: null, parent_schema: "PUBLIC", parent_name: "orders" }, + ], + }); + + expect(nodes.map((node) => node.label)).toEqual(["Orders"]); + expect(tablePartitionGroups(nodes[0])[0].children?.map((node) => node.label)).toEqual(["orders_2026"]); + }); + + it("prefers the exact partition parent when folded names are ambiguous", () => { + const nodes = buildTableTreeNodes({ + ...context, + schema: "public", + tables: [ + { name: "Orders", table_type: "PARTITIONED TABLE", comment: null }, + { name: "orders", table_type: "PARTITIONED TABLE", comment: null }, + { name: "Orders_2026", table_type: "TABLE", comment: null, parent_schema: "public", parent_name: "Orders" }, + ], + }); + + const upperParent = nodes.find((node) => node.label === "Orders"); + const lowerParent = nodes.find((node) => node.label === "orders"); + + expect(tablePartitionGroups(upperParent!)[0].children?.map((node) => node.label)).toEqual(["Orders_2026"]); + expect(tablePartitionGroups(lowerParent!)).toHaveLength(0); + }); + + it("prefers the exact partition parent when merging later pages", () => { + const firstPage = buildTableTreeNodes({ + ...context, + schema: "public", + tables: [ + { name: "Orders", table_type: "PARTITIONED TABLE", comment: null }, + { name: "orders", table_type: "PARTITIONED TABLE", comment: null }, + ], + }); + const secondPage = buildTableTreeNodes({ + ...context, + schema: "public", + tables: [{ name: "Orders_2026", table_type: "TABLE", comment: null, parent_schema: "public", parent_name: "Orders" }], + }); + + const merged = mergeTableTreePageChildren(firstPage, secondPage, context.connectionId, context.database); + const upperParent = merged.find((node) => node.label === "Orders"); + const lowerParent = merged.find((node) => node.label === "orders"); + + expect(tablePartitionGroups(upperParent!)[0].children?.map((node) => node.label)).toEqual(["Orders_2026"]); + expect(tablePartitionGroups(lowerParent!)).toHaveLength(0); + }); + it("keeps schema pagination visible at the table-group root when a page ends inside nested partitions", () => { const nodes = buildTableTreeNodes({ ...context, diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index 6ff3eba6c..9fbab239d 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -90,7 +90,7 @@ function makeTableTreeEntry({ if (normalizedParentName) node.partitionParentName = normalizedParentName; return { - key: objectIdentityKey(objectType, schema, name), + key: exactObjectIdentityKey(objectType, schema, name), objectType, schema, parentSchema: normalizedParentSchema, @@ -99,7 +99,11 @@ function makeTableTreeEntry({ }; } -function objectIdentityKey(objectType: string, schema: string | undefined, name: string) { +function exactObjectIdentityKey(objectType: string, schema: string | undefined, name: string, signature = "") { + return `${objectType}\0${schema || ""}\0${name}\0${signature}`; +} + +function foldedPartitionObjectLookupKey(objectType: string, schema: string | undefined, name: string) { return `${objectType}\0${(schema || "").toLowerCase()}\0${name.toLowerCase()}`; } @@ -223,7 +227,7 @@ export function mergeTableInfosIntoObjects(objects: readonly ObjectInfo[], table merged.map((obj) => { const name = normalizeDatabaseObjectName(obj.name); const objectSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema || ""; - return `${normalizeObjectType(obj.object_type)}\0${objectSchema.toLowerCase()}\0${name.toLowerCase()}`; + return exactObjectIdentityKey(normalizeObjectType(obj.object_type), objectSchema, name); }), ); @@ -234,11 +238,11 @@ export function mergeTableInfosIntoObjects(objects: readonly ObjectInfo[], table if (!name) continue; const matchingObject = objects.find((obj) => { const objName = normalizeDatabaseObjectName(obj.name); - if (objName.toLowerCase() !== name.toLowerCase()) return false; + if (objName !== name) return false; return normalizeObjectType(obj.object_type) === objectType; }); const tableSchema = schema ?? (matchingObject?.schema ? normalizeDatabaseObjectName(matchingObject.schema) : undefined); - const key = `${objectType}\0${(tableSchema || "").toLowerCase()}\0${name.toLowerCase()}`; + const key = exactObjectIdentityKey(objectType, tableSchema, name); if (seen.has(key)) { // Table already in objects — merge comment if missing if (matchingObject && table.comment && !matchingObject.comment) { @@ -274,8 +278,15 @@ export function filterSimpleSidebarSupplementalObjects(objects: readonly ObjectI function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, database: string): TreeNode[] { const orderedEntries = sortDatabaseObjectsByName(entries, (entry) => entry.node.label); const byKey = new Map(); + const uniqueByFoldedKey = new Map(); for (const entry of orderedEntries) { byKey.set(entry.key, entry); + const foldedKey = foldedPartitionObjectLookupKey(entry.objectType, entry.schema, entry.node.label); + if (!uniqueByFoldedKey.has(foldedKey)) { + uniqueByFoldedKey.set(foldedKey, entry); + } else if (uniqueByFoldedKey.get(foldedKey)?.key !== entry.key) { + uniqueByFoldedKey.set(foldedKey, null); + } } const childrenByParent = new Map(); @@ -283,8 +294,8 @@ function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, dat for (const entry of orderedEntries) { if (entry.objectType !== "TABLE" || !entry.parentName) continue; const parentSchema = entry.parentSchema || entry.schema; - const parentKey = objectIdentityKey("TABLE", parentSchema, entry.parentName); - const parent = byKey.get(parentKey); + const parentKey = exactObjectIdentityKey("TABLE", parentSchema, entry.parentName); + const parent = byKey.get(parentKey) ?? uniqueByFoldedKey.get(foldedPartitionObjectLookupKey("TABLE", parentSchema, entry.parentName)); if (!parent || parent.key === entry.key) continue; const children = childrenByParent.get(parent.key) ?? []; children.push(entry); @@ -345,16 +356,32 @@ export type TableTreeLoadMoreParent = { }; function findTableTreeNode(nodes: readonly TreeNode[], parent: TableTreeLoadMoreParent): TreeNode | undefined { + const candidates: TreeNode[] = []; for (const node of nodes) { - const sameSchema = !parent.schema || (node.schema || "").toLowerCase() === parent.schema.toLowerCase(); - if (node.type === "table" && sameSchema && node.label.toLowerCase() === parent.name.toLowerCase()) return node; - - const child = findTableTreeNode(node.children ?? [], parent); - if (child) return child; - const hiddenChild = findTableTreeNode(node.hiddenChildren?.filter((item) => !(node.children ?? []).includes(item)) ?? [], parent); - if (hiddenChild) return hiddenChild; + if (node.type === "table") candidates.push(node); + candidates.push(...collectTableTreeNodes(node.children ?? [])); + candidates.push(...collectTableTreeNodes(node.hiddenChildren?.filter((item) => !(node.children ?? []).includes(item)) ?? [])); } - return undefined; + + const exactMatches = candidates.filter((node) => (!parent.schema || (node.schema || "") === parent.schema) && node.label === parent.name); + if (exactMatches.length === 1) return exactMatches[0]; + if (exactMatches.length > 1) return undefined; + + const foldedMatches = candidates.filter((node) => { + const sameSchema = !parent.schema || (node.schema || "").toLowerCase() === parent.schema.toLowerCase(); + return sameSchema && node.label.toLowerCase() === parent.name.toLowerCase(); + }); + return foldedMatches.length === 1 ? foldedMatches[0] : undefined; +} + +function collectTableTreeNodes(nodes: readonly TreeNode[]): TreeNode[] { + const tables: TreeNode[] = []; + for (const node of nodes) { + if (node.type === "table") tables.push(node); + tables.push(...collectTableTreeNodes(node.children ?? [])); + tables.push(...collectTableTreeNodes(node.hiddenChildren?.filter((item) => !(node.children ?? []).includes(item)) ?? [])); + } + return tables; } export function appendTableTreeLoadMoreNode(children: TreeNode[], loadMoreNode: TreeNode, parent?: TableTreeLoadMoreParent): TreeNode[] { @@ -384,7 +411,7 @@ export function mergeTableTreePageChildren(currentChildren: TreeNode[], pageChil const nodesByKey = new Map(); const rootKeys = new Set(); - const nodeKey = (node: TreeNode) => objectIdentityKey("TABLE", node.schema, node.label); + const nodeKey = (node: TreeNode) => exactObjectIdentityKey("TABLE", node.schema, node.label); const collect = (nodes: readonly TreeNode[]) => { for (const node of nodes) { if (node.type === "table") { @@ -400,6 +427,32 @@ export function mergeTableTreePageChildren(currentChildren: TreeNode[], pageChil if (node.type === "table") rootKeys.add(nodeKey(node)); } + const flattenIncomingTables = (node: TreeNode): TreeNode[] => { + if (node.type !== "table") return [node]; + const descendants = partitionGroupChildren(node) + .flatMap((group) => group.children ?? []) + .flatMap(flattenIncomingTables); + node.children = (node.children ?? []).filter((child) => child.type !== "group-partitions"); + node.hiddenChildren = (node.hiddenChildren ?? []).filter((child) => child.type !== "group-partitions"); + return [node, ...descendants]; + }; + const incomingNodes = pageChildren.flatMap(flattenIncomingTables); + const allNodesByKey = new Map(nodesByKey); + for (const node of incomingNodes) { + if (node.type === "table" && !allNodesByKey.has(nodeKey(node))) { + allNodesByKey.set(nodeKey(node), node); + } + } + const uniqueByFoldedKey = new Map(); + for (const node of allNodesByKey.values()) { + const foldedKey = foldedPartitionObjectLookupKey("TABLE", node.schema, node.label); + if (!uniqueByFoldedKey.has(foldedKey)) { + uniqueByFoldedKey.set(foldedKey, node); + } else if (uniqueByFoldedKey.get(foldedKey) !== node) { + uniqueByFoldedKey.set(foldedKey, null); + } + } + const ensurePartitionGroup = (parent: TreeNode): TreeNode => { const existing = partitionGroupChildren(parent)[0]; if (existing) return existing; @@ -440,8 +493,11 @@ export function mergeTableTreePageChildren(currentChildren: TreeNode[], pageChil const parentName = node.partitionParentName; const parentSchema = node.partitionParentSchema || node.schema; - const parentKey = parentName ? objectIdentityKey("TABLE", parentSchema, parentName) : ""; - const parent = parentKey ? nodesByKey.get(parentKey) : undefined; + let parent: TreeNode | null | undefined; + if (parentName) { + const parentKey = exactObjectIdentityKey("TABLE", parentSchema, parentName); + parent = allNodesByKey.get(parentKey) ?? uniqueByFoldedKey.get(foldedPartitionObjectLookupKey("TABLE", parentSchema, parentName)); + } nodesByKey.set(key, node); if (parent && parent !== node) { addToParent(parent, node); @@ -454,17 +510,7 @@ export function mergeTableTreePageChildren(currentChildren: TreeNode[], pageChil } }; - const flattenIncomingTables = (node: TreeNode): TreeNode[] => { - if (node.type !== "table") return [node]; - const descendants = partitionGroupChildren(node) - .flatMap((group) => group.children ?? []) - .flatMap(flattenIncomingTables); - node.children = (node.children ?? []).filter((child) => child.type !== "group-partitions"); - node.hiddenChildren = (node.hiddenChildren ?? []).filter((child) => child.type !== "group-partitions"); - return [node, ...descendants]; - }; - - for (const node of pageChildren.flatMap(flattenIncomingTables)) { + for (const node of incomingNodes) { addNode(node); } @@ -513,7 +559,7 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema; const signature = obj.signature?.trim() || ""; - const dedupeKey = `${objectType}\0${(childSchema || "").toLowerCase()}\0${name.toLowerCase()}\0${signature.toLowerCase()}`; + const dedupeKey = exactObjectIdentityKey(objectType, childSchema, name, signature); if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); @@ -669,7 +715,7 @@ export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, sc const t = normalizeObjectType(obj.object_type); const objectSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema || ""; const signature = (obj.signature ?? "").trim(); - const key = `${t}\0${objectSchema.toLowerCase()}\0${name.toLowerCase()}\0${signature.toLowerCase()}`; + const key = exactObjectIdentityKey(t, objectSchema, name, signature); if (seen.has(key)) continue; seen.add(key); const arr = buckets.get(t) ?? []; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 867de18a4..3b7a40dc4 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -322,7 +322,7 @@ describe("connectionStore metadata loading", () => { { id: "oracle-1:XE:DIP:__views:DIP:V_TWO", label: "V_TWO", type: "view", connectionId: "oracle-1", database: "XE", schema: "DIP", isExpanded: false }, ]; const loadSchemaCache = vi.fn(async (key: string) => - key.endsWith(":objects-v5") + key.endsWith(":objects-v6") ? { version: 2, cachedAt: new Date().toISOString(), @@ -385,7 +385,7 @@ describe("connectionStore metadata loading", () => { expect(storedViewGroup?.type).toBe("group-views"); await store.loadObjectGroupChildren(storedViewGroup!); - expect(loadSchemaCache).toHaveBeenCalledWith("oracle-1:XE:DIP:group-views:objects-v6"); + expect(loadSchemaCache).toHaveBeenCalledWith("oracle-1:XE:DIP:group-views:objects-v7"); expect(listTables).toHaveBeenCalledWith(connection.id, "XE", "DIP", undefined, 201, 0, ["VIEW"]); expect(storedViewGroup?.children?.map((node) => node.label)).toEqual(["V_ONE", "V_THREE", "V_TWO"]); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 26a23c9fd..6f362ba91 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1279,7 +1279,7 @@ export const useConnectionStore = defineStore("connection", () => { function objectGroupCacheKey(node: TreeNode): string { const config = node.connectionId ? getConfig(node.connectionId) : undefined; - const cacheVersion = config?.db_type === "oracle" ? "objects-v6" : "objects-v5"; + const cacheVersion = config?.db_type === "oracle" ? "objects-v7" : "objects-v6"; return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, cacheVersion); } @@ -1460,7 +1460,7 @@ export const useConnectionStore = defineStore("connection", () => { } function treeNodeObjectIdentity(node: TreeNode): string { - return `${node.type}\0${(node.schema || "").toLowerCase()}\0${node.label.toLowerCase()}`; + return `${node.type}\0${node.schema || ""}\0${node.label}`; } function mergeLocatedTreeChildren(parent: TreeNode, currentChildren: TreeNode[], pageChildren: TreeNode[], connectionId: string, database: string): TreeNode[] { @@ -3558,7 +3558,7 @@ export const useConnectionStore = defineStore("connection", () => { nodeKind: "simple-tables", catalog, }); - const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v4"); + const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v5"); if (!options?.force && !searchFilter && !tableNameFilter) { const cached = await loadPersistedTreeChildren(node, cacheKey); if (cached.hit) { @@ -3631,7 +3631,7 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); if (useCachedChildren(node, options)) return; const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; - const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v5" : "objects-grouped-v6"); + const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7"); const searchFilter = activeTreeLoadSearchFilter(options); const config = getConfig(connectionId); const querySchema = connectionObjectTreeQuerySchema(config, database, schema); @@ -3865,7 +3865,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!canApplyTreeMetadataResult(parent)) return; parent.objectCount = mergedChildren.length; setChildren(parent, nextChildren); - await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", "objects-simple-v5"), nextChildren); + await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", "objects-simple-v6"), nextChildren); parent.isExpanded = true; return; }