fix(sidebar): preserve case-sensitive object identities

This commit is contained in:
zipg 2026-07-27 16:52:35 +08:00 committed by GitHub
parent 0aced0012c
commit bd693d7b98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 188 additions and 39 deletions

View File

@ -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,

View File

@ -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<string, TableTreeEntry>();
const uniqueByFoldedKey = new Map<string, TableTreeEntry | null>();
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<string, TableTreeEntry[]>();
@ -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<string, TreeNode>();
const rootKeys = new Set<string>();
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<string, TreeNode | null>();
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) ?? [];

View File

@ -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"]);
});

View File

@ -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;
}