fix: sort sidebar metadata nodes consistently
This commit is contained in:
parent
8d69904ea1
commit
8942356eed
|
|
@ -1,6 +1,8 @@
|
|||
import type { ConnectionConfig, DatabaseInfo, TreeNode } from "@/types/database";
|
||||
import { DEFAULT_DATABASE_TREE_LABEL } from "./treeNodeContext";
|
||||
|
||||
const sidebarNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
export function shouldIncludeDefaultDatabaseNode(
|
||||
connection: Pick<ConnectionConfig, "db_type"> | undefined,
|
||||
databases: DatabaseInfo[],
|
||||
|
|
@ -8,12 +10,20 @@ export function shouldIncludeDefaultDatabaseNode(
|
|||
return connection?.db_type === "mysql" && databases.some((database) => !database.name.trim());
|
||||
}
|
||||
|
||||
export function sortSidebarNames(names: readonly string[]): string[] {
|
||||
return [...names].sort((left, right) => sidebarNameCollator.compare(left, right));
|
||||
}
|
||||
|
||||
export function sortSidebarDatabases(databases: readonly DatabaseInfo[]): DatabaseInfo[] {
|
||||
return [...databases].sort((left, right) => sidebarNameCollator.compare(left.name, right.name));
|
||||
}
|
||||
|
||||
export function buildDatabaseTreeNodes(
|
||||
connectionId: string,
|
||||
databases: DatabaseInfo[],
|
||||
options: { includeDefaultWhenEmpty?: boolean } = {},
|
||||
): TreeNode[] {
|
||||
const nodes = databases.flatMap((db) => {
|
||||
const nodes = sortSidebarDatabases(databases).flatMap((db) => {
|
||||
const name = db.name.trim();
|
||||
if (!name) return [];
|
||||
return [
|
||||
|
|
@ -49,7 +59,7 @@ export function buildDuckDbConnectionTreeNodes(
|
|||
databases: DatabaseInfo[],
|
||||
primarySchemas: string[],
|
||||
): TreeNode[] {
|
||||
const schemaNodes = primarySchemas.flatMap((schema) => {
|
||||
const schemaNodes = sortSidebarNames(primarySchemas).flatMap((schema) => {
|
||||
const name = schema.trim();
|
||||
if (!name) return [];
|
||||
return [
|
||||
|
|
@ -66,7 +76,7 @@ export function buildDuckDbConnectionTreeNodes(
|
|||
];
|
||||
});
|
||||
|
||||
const attachedCatalogNodes = databases.flatMap((db) => {
|
||||
const attachedCatalogNodes = sortSidebarDatabases(databases).flatMap((db) => {
|
||||
const name = db.name.trim();
|
||||
if (!name || name === "main") return [];
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import type { DatabaseType, TreeNode } from "@/types/database";
|
||||
|
||||
const sidebarTreeCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function sortByLabel(nodes: readonly TreeNode[]): TreeNode[] {
|
||||
return [...nodes].sort((left, right) => sidebarTreeCollator.compare(left.label, right.label));
|
||||
}
|
||||
|
||||
function sortRecursive(node: TreeNode, databaseType?: DatabaseType): TreeNode {
|
||||
const children = node.children ? sortSidebarTreeChildrenForParent(node, node.children, databaseType) : node.children;
|
||||
const hiddenChildren = node.hiddenChildren
|
||||
? sortSidebarTreeChildrenForParent(node, node.hiddenChildren, databaseType)
|
||||
: node.hiddenChildren;
|
||||
if (children === node.children && hiddenChildren === node.hiddenChildren) return node;
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
hiddenChildren,
|
||||
};
|
||||
}
|
||||
|
||||
export function sortSidebarTreeChildrenForParent(
|
||||
parent: Pick<TreeNode, "type">,
|
||||
children: readonly TreeNode[],
|
||||
databaseType?: DatabaseType,
|
||||
): TreeNode[] {
|
||||
const normalized = children.map((child) => sortRecursive(child, databaseType));
|
||||
|
||||
if (parent.type === "mongo-db") {
|
||||
return sortByLabel(normalized);
|
||||
}
|
||||
|
||||
if (parent.type === "connection") {
|
||||
if (databaseType === "mongodb" || databaseType === "elasticsearch") {
|
||||
return sortByLabel(normalized);
|
||||
}
|
||||
|
||||
if (databaseType === "duckdb") {
|
||||
const schemas = sortByLabel(normalized.filter((child) => child.type === "schema"));
|
||||
const databases = sortByLabel(normalized.filter((child) => child.type === "database"));
|
||||
const rest = normalized.filter((child) => child.type !== "schema" && child.type !== "database");
|
||||
return [...schemas, ...databases, ...rest];
|
||||
}
|
||||
|
||||
if (normalized.every((child) => child.type === "database")) {
|
||||
return sortByLabel(normalized);
|
||||
}
|
||||
|
||||
if (normalized.every((child) => child.type === "schema")) {
|
||||
return sortByLabel(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (parent.type === "database") {
|
||||
if (databaseType === "sqlserver") {
|
||||
const objectGroups = normalized.filter((child) => child.type.startsWith("group-"));
|
||||
const schemas = sortByLabel(normalized.filter((child) => child.type === "schema"));
|
||||
const rest = normalized.filter((child) => !child.type.startsWith("group-") && child.type !== "schema");
|
||||
return [...objectGroups, ...schemas, ...rest];
|
||||
}
|
||||
|
||||
if (normalized.every((child) => child.type === "schema")) {
|
||||
return sortByLabel(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ObjectInfo, TreeNode } from "@/types/database";
|
||||
import { sortSidebarNames } from "@/lib/databaseTree";
|
||||
import { buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes } from "@/lib/tableTree";
|
||||
|
||||
export const SQLSERVER_DEFAULT_SCHEMA = "dbo";
|
||||
|
|
@ -33,18 +34,16 @@ export function buildSqlServerDatabaseTreeNodes(
|
|||
objects: defaultSchemaObjects,
|
||||
});
|
||||
|
||||
const schemaNodes = schemas
|
||||
.filter((schema) => !isDefaultSchema(schema))
|
||||
.map((schema) => ({
|
||||
id: `${databaseNodeId}:${schema}`,
|
||||
label: schema,
|
||||
type: "schema" as const,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
const schemaNodes = sortSidebarNames(schemas.filter((schema) => !isDefaultSchema(schema))).map((schema) => ({
|
||||
id: `${databaseNodeId}:${schema}`,
|
||||
label: schema,
|
||||
type: "schema" as const,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return [...defaultObjectNodes, ...schemaNodes];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
import {
|
||||
buildDatabaseTreeNodes,
|
||||
buildDuckDbConnectionTreeNodes,
|
||||
sortSidebarNames,
|
||||
shouldIncludeDefaultDatabaseNode,
|
||||
} from "@/lib/databaseTree";
|
||||
import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree";
|
||||
|
|
@ -54,6 +55,7 @@ import {
|
|||
treeNodeSchemaCachePrefix,
|
||||
} from "@/lib/treeNodeContext";
|
||||
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCache";
|
||||
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebarNodeOrdering";
|
||||
import { prunePinnedTreeNodeIdsForConnection } from "@/lib/pinnedTreeNodeIds";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
|
@ -451,7 +453,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const payload = await api.loadSchemaCache<unknown>(cacheKey).catch(() => null);
|
||||
const decoded = decodeSchemaTreeCache<TreeNode[]>(payload);
|
||||
if (!decoded) return { hit: false, isStale: false };
|
||||
const normalizedChildren = normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children));
|
||||
const normalizedChildren = sortSidebarTreeChildrenForParent(
|
||||
node,
|
||||
normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children)),
|
||||
node.connectionId ? getConfig(node.connectionId)?.db_type : undefined,
|
||||
);
|
||||
setChildren(
|
||||
node,
|
||||
node.type === "connection" && node.connectionId
|
||||
|
|
@ -830,7 +836,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
}
|
||||
const schemas = await api.listSchemas(connectionId, effectiveDb);
|
||||
const visibleSchemas = filterDatabaseNamesForConnection(schemas, config);
|
||||
const schemaNodes: TreeNode[] = visibleSchemas.map((s) => ({
|
||||
const schemaNodes: TreeNode[] = sortSidebarNames(visibleSchemas).map((s) => ({
|
||||
id: `${connectionId}:${s}:${s}`,
|
||||
label: s,
|
||||
type: "schema" as const,
|
||||
|
|
@ -950,7 +956,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
node,
|
||||
withSavedSqlRoot(
|
||||
connectionId,
|
||||
visibleDbs.map((db) => ({
|
||||
sortSidebarNames(visibleDbs).map((db) => ({
|
||||
id: `${connectionId}:${db}`,
|
||||
label: db,
|
||||
type: "mongo-db" as const,
|
||||
|
|
@ -981,7 +987,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const collections = await api.mongoListCollections(connectionId, database);
|
||||
setChildren(
|
||||
node,
|
||||
collections.map((col) => ({
|
||||
sortSidebarNames(collections).map((col) => ({
|
||||
id: `${nodeId}:${col}`,
|
||||
label: col,
|
||||
type: "mongo-collection" as const,
|
||||
|
|
@ -1016,7 +1022,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
}
|
||||
}
|
||||
|
||||
const schemas = await api.listSchemas(connectionId, database);
|
||||
const schemas = sortSidebarNames(await api.listSchemas(connectionId, database));
|
||||
const children = schemas.map((s) => ({
|
||||
id: `${connectionId}:${database}:${s}`,
|
||||
label: s,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,21 @@ use crate::db::agent_driver::mongo_document_id_params;
|
|||
use crate::db::elasticsearch_driver;
|
||||
use crate::db::mongo_driver::{self, MongoDocumentResult};
|
||||
|
||||
fn sort_names(mut names: Vec<String>) -> Vec<String> {
|
||||
names.sort_by(|left, right| {
|
||||
let left_lower = left.to_lowercase();
|
||||
let right_lower = right.to_lowercase();
|
||||
left_lower.cmp(&right_lower).then_with(|| left.cmp(right))
|
||||
});
|
||||
names
|
||||
}
|
||||
|
||||
pub async fn mongo_list_databases_core(state: &AppState, connection_id: &str) -> Result<Vec<String>, String> {
|
||||
let fallback_database = configured_mongo_database(state, connection_id).await;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => match mongo_driver::list_databases(client).await {
|
||||
Ok(databases) => Ok(databases),
|
||||
Ok(databases) => Ok(sort_names(databases)),
|
||||
Err(error) if mongo_list_databases_unauthorized(&error) => {
|
||||
fallback_mongo_database(&error, fallback_database)
|
||||
}
|
||||
|
|
@ -18,7 +27,9 @@ pub async fn mongo_list_databases_core(state: &AppState, connection_id: &str) ->
|
|||
PoolKind::Agent(client) => {
|
||||
let mut client = client.lock().await;
|
||||
match client.mongo_list_databases::<Vec<serde_json::Value>>().await {
|
||||
Ok(result) => Ok(result.iter().filter_map(|v| v.get("name")?.as_str().map(String::from)).collect()),
|
||||
Ok(result) => {
|
||||
Ok(sort_names(result.iter().filter_map(|v| v.get("name")?.as_str().map(String::from)).collect()))
|
||||
}
|
||||
Err(error) if mongo_list_databases_unauthorized(&error) => {
|
||||
fallback_mongo_database(&error, fallback_database)
|
||||
}
|
||||
|
|
@ -50,11 +61,11 @@ pub async fn mongo_list_collections_core(
|
|||
) -> Result<Vec<String>, String> {
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => mongo_driver::list_collections(client, database).await,
|
||||
PoolKind::Elasticsearch(client) => elasticsearch_driver::list_indices(client).await,
|
||||
PoolKind::MongoDb(client) => mongo_driver::list_collections(client, database).await.map(sort_names),
|
||||
PoolKind::Elasticsearch(client) => elasticsearch_driver::list_indices(client).await.map(sort_names),
|
||||
PoolKind::Agent(client) => {
|
||||
let mut client = client.lock().await;
|
||||
client.mongo_list_collections(database).await
|
||||
client.mongo_list_collections(database).await.map(sort_names)
|
||||
}
|
||||
_ => Err("Not a MongoDB/Elasticsearch connection".to_string()),
|
||||
}
|
||||
|
|
@ -257,7 +268,19 @@ pub async fn mongo_delete_documents_core(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fallback_mongo_database, mongo_list_databases_unauthorized};
|
||||
use super::{fallback_mongo_database, mongo_list_databases_unauthorized, sort_names};
|
||||
|
||||
#[test]
|
||||
fn sorts_names_case_insensitively() {
|
||||
let sorted = sort_names(vec![
|
||||
"movies".to_string(),
|
||||
"Comments".to_string(),
|
||||
"users".to_string(),
|
||||
"embedded_movies".to_string(),
|
||||
]);
|
||||
|
||||
assert_eq!(sorted, vec!["Comments", "embedded_movies", "movies", "users"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_mongo_list_databases_unauthorized_errors() {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,22 @@ import test from "node:test";
|
|||
import {
|
||||
buildDatabaseTreeNodes,
|
||||
buildDuckDbConnectionTreeNodes,
|
||||
sortSidebarNames,
|
||||
shouldIncludeDefaultDatabaseNode,
|
||||
} from "../../apps/desktop/src/lib/databaseTree.ts";
|
||||
|
||||
test("设置默认库后侧边栏数据库树仍保留全部数据库", () => {
|
||||
const nodes = buildDatabaseTreeNodes("conn-1", [{ name: "campaign_data" }, { name: "cms" }, { name: "mk_campaign" }]);
|
||||
test("数据库节点按自然名称排序", () => {
|
||||
const nodes = buildDatabaseTreeNodes("conn-1", [
|
||||
{ name: "db10" },
|
||||
{ name: "db2" },
|
||||
{ name: "campaign_data" },
|
||||
{ name: "cms" },
|
||||
{ name: "mk_campaign" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((node) => node.database),
|
||||
["campaign_data", "cms", "mk_campaign"],
|
||||
["campaign_data", "cms", "db2", "db10", "mk_campaign"],
|
||||
);
|
||||
assert.equal(nodes.find((node) => node.database === "mk_campaign")?.id, "conn-1:mk_campaign");
|
||||
});
|
||||
|
|
@ -49,8 +56,8 @@ test("MySQL-compatible catalogless services can opt into the default database no
|
|||
test("DuckDB shows primary catalog schemas directly under the connection", () => {
|
||||
const nodes = buildDuckDbConnectionTreeNodes(
|
||||
"conn-1",
|
||||
[{ name: "main" }, { name: "attached_reports" }],
|
||||
["main", "mysql", "prod_sales"],
|
||||
[{ name: "main" }, { name: "attached_reports" }, { name: "analytics_20" }, { name: "analytics_3" }],
|
||||
["prod_sales", "main", "mysql"],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
|
|
@ -59,8 +66,14 @@ test("DuckDB shows primary catalog schemas directly under the connection", () =>
|
|||
["schema", "main", "main", "main"],
|
||||
["schema", "mysql", "main", "mysql"],
|
||||
["schema", "prod_sales", "main", "prod_sales"],
|
||||
["database", "analytics_3", "analytics_3", undefined],
|
||||
["database", "analytics_20", "analytics_20", undefined],
|
||||
["database", "attached_reports", "attached_reports", undefined],
|
||||
],
|
||||
);
|
||||
assert.equal(nodes.find((node) => node.label === "mysql")?.id, "conn-1:main:mysql");
|
||||
});
|
||||
|
||||
test("sidebar name sorting uses numeric-aware ordering", () => {
|
||||
assert.deepEqual(sortSidebarNames(["db10", "db2", "db1"]), ["db1", "db2", "db10"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { sortSidebarTreeChildrenForParent } from "../../apps/desktop/src/lib/sidebarNodeOrdering.ts";
|
||||
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
test("reorders cached MongoDB collections alphabetically", () => {
|
||||
const parent: Pick<TreeNode, "type"> = { type: "mongo-db" };
|
||||
const children: TreeNode[] = [
|
||||
{ id: "c:db:comments", label: "comments", type: "mongo-collection" },
|
||||
{ id: "c:db:movies", label: "movies", type: "mongo-collection" },
|
||||
{ id: "c:db:sessions", label: "sessions", type: "mongo-collection" },
|
||||
];
|
||||
|
||||
const sorted = sortSidebarTreeChildrenForParent(parent, [children[2], children[0], children[1]], "mongodb");
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((child) => child.label),
|
||||
["comments", "movies", "sessions"],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps SQL Server object groups first and sorts schema children after them", () => {
|
||||
const parent: Pick<TreeNode, "type"> = { type: "database" };
|
||||
const children: TreeNode[] = [
|
||||
{ id: "conn:app:zeta", label: "zeta", type: "schema" },
|
||||
{ id: "conn:app:__tables", label: "tree.tables", type: "group-tables" },
|
||||
{ id: "conn:app:archive", label: "archive", type: "schema" },
|
||||
];
|
||||
|
||||
const sorted = sortSidebarTreeChildrenForParent(parent, children, "sqlserver");
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((child) => [child.type, child.label]),
|
||||
[
|
||||
["group-tables", "tree.tables"],
|
||||
["schema", "archive"],
|
||||
["schema", "zeta"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps DuckDB schemas before attached catalogs while sorting both", () => {
|
||||
const parent: Pick<TreeNode, "type"> = { type: "connection" };
|
||||
const children: TreeNode[] = [
|
||||
{ id: "conn:analytics_20", label: "analytics_20", type: "database" },
|
||||
{ id: "conn:main:mysql", label: "mysql", type: "schema" },
|
||||
{ id: "conn:analytics_3", label: "analytics_3", type: "database" },
|
||||
{ id: "conn:main:main", label: "main", type: "schema" },
|
||||
];
|
||||
|
||||
const sorted = sortSidebarTreeChildrenForParent(parent, children, "duckdb");
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((child) => [child.type, child.label]),
|
||||
[
|
||||
["schema", "main"],
|
||||
["schema", "mysql"],
|
||||
["database", "analytics_3"],
|
||||
["database", "analytics_20"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@ function obj(name: string, objectType = "TABLE"): ObjectInfo {
|
|||
}
|
||||
|
||||
test("SQL Server database tree groups dbo objects and keeps non-default schemas", () => {
|
||||
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "sales"], [
|
||||
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "zeta", "sales"], [
|
||||
obj("customers"),
|
||||
obj("customer_view", "VIEW"),
|
||||
obj("get_total", "FUNCTION"),
|
||||
|
|
@ -23,6 +23,7 @@ test("SQL Server database tree groups dbo objects and keeps non-default schemas"
|
|||
{ id: "conn:app:__views", label: "tree.views", type: "group-views" },
|
||||
{ id: "conn:app:__functions", label: "tree.functions", type: "group-functions" },
|
||||
{ id: "conn:app:sales", label: "sales", type: "schema" },
|
||||
{ id: "conn:app:zeta", label: "zeta", type: "schema" },
|
||||
]);
|
||||
|
||||
const tableGroup = nodes.find((n) => n.type === "group-tables");
|
||||
|
|
@ -39,11 +40,14 @@ test("SQL Server database tree groups dbo objects and keeps non-default schemas"
|
|||
});
|
||||
|
||||
test("SQL Server database tree shows only schemas when dbo has no objects", () => {
|
||||
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "archive"], []);
|
||||
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "archive", "beta"], []);
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((node) => ({ id: node.id, label: node.label, type: node.type })),
|
||||
[{ id: "conn:app:archive", label: "archive", type: "schema" }],
|
||||
[
|
||||
{ id: "conn:app:archive", label: "archive", type: "schema" },
|
||||
{ id: "conn:app:beta", label: "beta", type: "schema" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue