diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 843b918c34..b9c22f32f4 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -218,6 +218,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: ScrollText, colorClass: "text-blue-500" }; case "group-functions": return { icon: Braces, colorClass: "text-amber-500" }; + case "group-partitions": + return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" }; default: return { icon: Database, colorClass: "text-muted-foreground" }; } @@ -232,6 +234,7 @@ const groupTypes: Set = new Set([ "group-views", "group-procedures", "group-functions", + "group-partitions", "saved-sql-root", "saved-sql-folder", ]); @@ -290,7 +293,8 @@ async function toggle() { node.type === "group-tables" || node.type === "group-views" || node.type === "group-procedures" || - node.type === "group-functions" + node.type === "group-functions" || + node.type === "group-partitions" ) { node.isExpanded = !node.isExpanded; emit("node-toggled", node, wasExpanded); @@ -2203,7 +2207,7 @@ function treeItemMenuItems(): ContextMenuItem[] { }); items.push({ label: "", separator: true }); } - if (node.type !== "saved-sql-root" && node.type !== "saved-sql-folder") { + if (node.type !== "saved-sql-root" && node.type !== "saved-sql-folder" && node.type !== "group-partitions") { items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw }); } return items; @@ -2314,7 +2318,8 @@ function treeItemMenuItems(): ContextMenuItem[] { (node.type === 'group-tables' || node.type === 'group-views' || node.type === 'group-procedures' || - node.type === 'group-functions') && + node.type === 'group-functions' || + node.type === 'group-partitions') && node.objectCount != null " class="text-muted-foreground text-[10px] shrink-0" diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 315cdadf9d..86526e1b64 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -968,6 +968,7 @@ export default { views: "Views", procedures: "Procedures", functions: "Functions", + partitions: "Partitions", objectBrowser: "Browse in Object Browser ({count})", }, objects: { diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5da148763a..4469bdb507 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -861,6 +861,7 @@ export default { views: "Vistas", procedures: "Procedimientos", functions: "Funciones", + partitions: "Particiones", objectBrowser: "Explorar en el navegador de objetos ({count})", }, objects: { diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index c16a0f923b..f3c0ca28cc 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -946,6 +946,7 @@ export default { views: "视图", procedures: "存储过程", functions: "函数", + partitions: "分区", objectBrowser: "在对象浏览器中查看 ({count})", }, objects: { diff --git a/apps/desktop/src/lib/objectBrowserRows.ts b/apps/desktop/src/lib/objectBrowserRows.ts index d8d39eb230..be74c29d0c 100644 --- a/apps/desktop/src/lib/objectBrowserRows.ts +++ b/apps/desktop/src/lib/objectBrowserRows.ts @@ -11,6 +11,8 @@ export type ObjectBrowserRow = { updated_at?: string | null; partitionParentId?: string; partitionCount?: number; + partitionParentSchema?: string; + partitionParentName?: string; }; export type ObjectBrowserSortKey = "name" | "type" | "created_at" | "updated_at" | "comment"; @@ -49,6 +51,8 @@ export function buildObjectBrowserRows(options: { comment: object.comment, created_at: object.created_at, updated_at: object.updated_at, + partitionParentSchema: object.parent_schema ? normalizeDatabaseObjectName(object.parent_schema) : undefined, + partitionParentName: object.parent_name ? normalizeDatabaseObjectName(object.parent_name) : undefined, }, ]; }); @@ -67,9 +71,12 @@ function markPartitionRows(rows: ObjectBrowserRow[], fallbackSchema: string) { const partitionCountByParent = new Map(); for (const row of rows) { if (row.type !== "TABLE") continue; - const parentName = partitionParentName(row.name); + const parentName = row.partitionParentName || partitionParentName(row.name); if (!parentName) continue; - const parentKey = objectKey({ ...row, name: parentName }, fallbackSchema); + const parentKey = objectKey( + { ...row, schema: row.partitionParentSchema || row.schema, name: parentName }, + fallbackSchema, + ); const parent = tableByKey.get(parentKey); if (!parent || parent.id === row.id) continue; row.partitionParentId = parent.id; diff --git a/apps/desktop/src/lib/tableTree.ts b/apps/desktop/src/lib/tableTree.ts index 7eadc677fa..61edb2106b 100644 --- a/apps/desktop/src/lib/tableTree.ts +++ b/apps/desktop/src/lib/tableTree.ts @@ -17,23 +17,184 @@ export function buildTableTreeNodes({ schema?: string; tables: TableInfo[]; }): TreeNode[] { - return tables.flatMap((table) => { + const entries = tables.flatMap((table) => { const name = normalizeDatabaseObjectName(table.name); if (!name) return []; + const objectType = normalizeObjectType(table.table_type); + const childSchema = schema; return [ - { - id: `${nodeId}:${name}`, - label: name, - type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const), - comment: table.comment, + makeTableTreeEntry({ + nodeId, connectionId, database, - schema, - isExpanded: false, - children: [], - }, + schema: childSchema, + includeSchemaInId: false, + name, + objectType, + comment: table.comment, + parentSchema: table.parent_schema, + parentName: table.parent_name, + }), ]; }); + + return buildPartitionTree(entries, connectionId, database); +} + +type TableTreeEntry = { + key: string; + objectType: "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION"; + schema?: string; + parentSchema?: string; + parentName?: string; + node: TreeNode; +}; + +function makeTableTreeEntry({ + nodeId, + connectionId, + database, + schema, + includeSchemaInId, + name, + objectType, + comment, + parentSchema, + parentName, +}: { + nodeId: string; + connectionId: string; + database: string; + schema?: string; + includeSchemaInId?: boolean; + name: string; + objectType: "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION"; + comment?: string | null; + parentSchema?: string | null; + parentName?: string | null; +}): TableTreeEntry { + const normalizedParentSchema = parentSchema ? normalizeDatabaseObjectName(parentSchema) : undefined; + const normalizedParentName = parentName ? normalizeDatabaseObjectName(parentName) : undefined; + const nodeIdSchemaSuffix = includeSchemaInId !== false && schema ? `${schema}:` : ""; + const node: TreeNode = { + id: `${nodeId}:${nodeIdSchemaSuffix}${name}`, + label: name, + type: objectType === "VIEW" ? ("view" as const) : ("table" as const), + comment, + connectionId, + database, + schema, + isExpanded: false, + children: [], + }; + + return { + key: objectIdentityKey(objectType, schema, name), + objectType, + schema, + parentSchema: normalizedParentSchema, + parentName: normalizedParentName, + node, + }; +} + +function objectIdentityKey(objectType: string, schema: string | undefined, name: string) { + return `${objectType}\0${(schema || "").toLowerCase()}\0${name.toLowerCase()}`; +} + +function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, database: string): TreeNode[] { + const byKey = new Map(); + for (const entry of entries) { + byKey.set(entry.key, entry); + } + + const childrenByParent = new Map(); + const childKeys = new Set(); + for (const entry of entries) { + 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); + if (!parent || parent.key === entry.key) continue; + const children = childrenByParent.get(parent.key) ?? []; + children.push(entry); + childrenByParent.set(parent.key, children); + childKeys.add(entry.key); + } + + const materialize = (entry: TableTreeEntry): TreeNode => { + const partitionChildren = childrenByParent.get(entry.key); + if (!partitionChildren?.length) return entry.node; + + const partitionGroup: TreeNode = { + id: `${entry.node.id}:__partitions`, + label: "tree.partitions", + type: "group-partitions", + connectionId, + database, + schema: entry.schema, + tableName: entry.node.label, + objectCount: partitionChildren.length, + isExpanded: false, + children: partitionChildren.map(materialize), + }; + entry.node.children = [partitionGroup]; + entry.node.hiddenChildren = [partitionGroup]; + return entry.node; + }; + + return entries.filter((entry) => !childKeys.has(entry.key)).map(materialize); +} + +function partitionGroupChildren(node: TreeNode): TreeNode[] { + const children = node.children?.filter((child) => child.type === "group-partitions") ?? []; + if (children.length) return children; + return node.hiddenChildren?.filter((child) => child.type === "group-partitions") ?? []; +} + +export function tablePartitionGroups(node: TreeNode): TreeNode[] { + return partitionGroupChildren(node); +} + +export function hasTablePartitionGroups(node: TreeNode): boolean { + return partitionGroupChildren(node).length > 0; +} + +function buildObjectTreeEntries({ + nodeId, + connectionId, + database, + schema, + objects, + objectType, +}: { + nodeId: string; + connectionId: string; + database: string; + schema?: string; + objects: ObjectInfo[]; + objectType: "TABLE" | "VIEW"; +}): TreeNode[] { + const entries = objects.flatMap((obj) => { + const name = normalizeDatabaseObjectName(obj.name); + if (!name) return []; + const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema; + return [ + makeTableTreeEntry({ + nodeId, + connectionId, + database, + schema: childSchema, + name, + objectType, + comment: obj.comment, + parentSchema: obj.parent_schema, + parentName: obj.parent_name, + }), + ]; + }); + + return buildPartitionTree(entries, connectionId, database); } export function buildSimpleObjectTreeNodes({ @@ -50,7 +211,8 @@ export function buildSimpleObjectTreeNodes({ objects: ObjectInfo[]; }): TreeNode[] { const seen = new Set(); - const nodes: TreeNode[] = []; + const tableEntries: TableTreeEntry[] = []; + const viewNodes: TreeNode[] = []; for (const obj of objects) { const objectType = normalizeObjectType(obj.object_type); @@ -64,20 +226,25 @@ export function buildSimpleObjectTreeNodes({ if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); - nodes.push({ - id: `${nodeId}:${childSchema ? `${childSchema}:` : ""}${name}`, - label: name, - type: objectType === "VIEW" ? ("view" as const) : ("table" as const), - comment: obj.comment, + const entry = makeTableTreeEntry({ + nodeId, connectionId, database, schema: childSchema, - isExpanded: false, - children: [], + name, + objectType, + comment: obj.comment, + parentSchema: obj.parent_schema, + parentName: obj.parent_name, }); + if (objectType === "TABLE") { + tableEntries.push(entry); + } else { + viewNodes.push(entry.node); + } } - return nodes; + return [...buildPartitionTree(tableEntries, connectionId, database), ...viewNodes]; } function normalizeObjectType(type: string): "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION" { @@ -160,6 +327,29 @@ export function buildGroupedObjectTreeNodes({ const items = buckets.get(def.objectType); if (!items?.length) continue; const isExpandable = def.childType === "table" || def.childType === "view"; + const children = isExpandable + ? buildObjectTreeEntries({ + nodeId: `${nodeId}:${def.key}`, + connectionId, + database, + schema, + objects: items, + objectType: def.objectType as "TABLE" | "VIEW", + }) + : items.map((obj) => { + const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema; + return { + id: `${nodeId}:${def.key}:${childSchema ? `${childSchema}:` : ""}${obj.name}`, + label: obj.name, + type: def.childType, + comment: obj.comment, + connectionId, + database, + schema: childSchema, + isExpanded: false, + children: undefined, + }; + }); groups.push({ id: `${nodeId}:${def.key}`, label: def.label, @@ -169,20 +359,7 @@ export function buildGroupedObjectTreeNodes({ schema, objectCount: items.length, isExpanded: false, - children: items.map((obj) => { - const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema; - return { - id: `${nodeId}:${def.key}:${childSchema ? `${childSchema}:` : ""}${obj.name}`, - label: obj.name, - type: def.childType, - comment: obj.comment, - connectionId, - database, - schema: childSchema, - isExpanded: false, - children: isExpandable ? [] : undefined, - }; - }), + children, }); } return groups; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 7f31403f42..616aeff789 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -35,6 +35,7 @@ import { buildTableTreeNodes, expandCachedObjectBrowserNodes, objectGroupRefreshParentId, + tablePartitionGroups, } from "@/lib/tableTree"; import { hasTreeNodeDatabaseContext, @@ -1055,6 +1056,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!node) return; const children: TreeNode[] = [ + ...tablePartitionGroups(node), { id: `${parentId}:__columns`, label: "tree.columns", @@ -1317,7 +1319,8 @@ export const useConnectionStore = defineStore("connection", () => { node.type === "group-tables" || node.type === "group-views" || node.type === "group-procedures" || - node.type === "group-functions" + node.type === "group-functions" || + node.type === "group-partitions" ) { node.isExpanded = true; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 5d1f5a2139..9663f1e7cb 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -153,6 +153,8 @@ export interface TableInfo { name: string; table_type: string; comment?: string | null; + parent_schema?: string | null; + parent_name?: string | null; } export type DatabaseObjectType = "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION"; @@ -164,6 +166,8 @@ export interface ObjectInfo { comment?: string | null; created_at?: string | null; updated_at?: string | null; + parent_schema?: string | null; + parent_name?: string | null; } export type ObjectSourceKind = "VIEW" | "PROCEDURE" | "FUNCTION"; @@ -264,6 +268,7 @@ export type TreeNodeType = | "group-views" | "group-procedures" | "group-functions" + | "group-partitions" | "object-browser" | "saved-sql-root" | "saved-sql-folder" diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 5bf9d3a5de..98447cf6fe 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -670,7 +670,13 @@ mod tests { use serde_json::{json, Value}; fn table(name: &str, table_type: &str) -> TableInfo { - TableInfo { name: name.to_string(), table_type: table_type.to_string(), comment: None } + TableInfo { + name: name.to_string(), + table_type: table_type.to_string(), + comment: None, + parent_schema: None, + parent_name: None, + } } #[test] diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index d6ead12b42..83b9e27892 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -192,6 +192,8 @@ pub async fn list_tables(client: &ChClient, database: &str) -> Result Result ObjectInfo { comment: get_opt_str(row, "object_comment").filter(|s| !s.is_empty()), created_at: get_opt_str(row, "created_at"), updated_at: get_opt_str(row, "updated_at"), + parent_schema: None, + parent_name: None, } } diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs index c8ff550a17..b1366000d0 100644 --- a/crates/dbx-core/src/db/ob_oracle.rs +++ b/crates/dbx-core/src/db/ob_oracle.rs @@ -56,7 +56,16 @@ pub async fn list_tables(pool: &mysql_async::Pool, schema: &str) -> Result = result.collect_and_drop().await.map_err(|e| e.to_string())?; - Ok(rows.iter().map(|row| TableInfo { name: get_str(row, 0), table_type: get_str(row, 1), comment: None }).collect()) + Ok(rows + .iter() + .map(|row| TableInfo { + name: get_str(row, 0), + table_type: get_str(row, 1), + comment: None, + parent_schema: None, + parent_name: None, + }) + .collect()) } fn list_objects_sql(schema: &str) -> String { @@ -90,6 +99,8 @@ pub async fn list_objects(pool: &mysql_async::Pool, schema: &str) -> Result Result, St name: row.get::<_, String>(0), table_type: row.get::<_, String>(1), comment: row.try_get::<_, Option>(2).ok().flatten().filter(|s| !s.is_empty()), + parent_schema: row.try_get::<_, Option>(3).ok().flatten().filter(|s| !s.is_empty()), + parent_name: row.try_get::<_, Option>(4).ok().flatten().filter(|s| !s.is_empty()), }) .collect()) } @@ -716,9 +718,14 @@ fn postgres_tables_sql() -> &'static str { CASE c.relkind WHEN 'r' THEN 'BASE TABLE' WHEN 'v' THEN 'VIEW' \ WHEN 'm' THEN 'MATERIALIZED VIEW' WHEN 'f' THEN 'FOREIGN TABLE' \ WHEN 'p' THEN 'BASE TABLE' END AS table_type, \ - obj_description(c.oid) AS table_comment \ + obj_description(c.oid) AS table_comment, \ + pn.nspname AS parent_schema, \ + pc.relname AS parent_name \ FROM pg_catalog.pg_class c \ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + LEFT JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid \ + LEFT JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent \ + LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace \ WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \ ORDER BY c.relname" } @@ -738,9 +745,14 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str { THEN pg_xact_commit_timestamp(c.xmin)::text END, \ stat.modification::text \ ) AS updated_at, \ + pn.nspname AS parent_schema, \ + pc.relname AS parent_name, \ CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 ELSE 0 END AS sort_order \ FROM pg_catalog.pg_class c \ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + LEFT JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid \ + LEFT JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent \ + LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace \ LEFT JOIN LATERAL pg_stat_file( \ CASE WHEN c.relkind IN ('r','m','f','p') THEN pg_relation_filepath(c.oid) END, true \ ) stat ON true \ @@ -752,6 +764,8 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str { NULL::text AS created_at, \ CASE WHEN current_setting('track_commit_timestamp', true) = 'on' \ THEN pg_xact_commit_timestamp(p.xmin)::text END AS updated_at, \ + NULL::text AS parent_schema, \ + NULL::text AS parent_name, \ CASE p.prokind WHEN 'p' THEN 2 ELSE 3 END AS sort_order \ FROM pg_catalog.pg_proc p \ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \ @@ -768,9 +782,14 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str { obj_description(c.oid) AS object_comment, \ NULL::text AS created_at, \ NULL::text AS updated_at, \ + pn.nspname AS parent_schema, \ + pc.relname AS parent_name, \ CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 ELSE 0 END AS sort_order \ FROM pg_catalog.pg_class c \ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + LEFT JOIN pg_catalog.pg_inherits i ON i.inhrelid = c.oid \ + LEFT JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent \ + LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace \ WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \ UNION ALL \ SELECT p.proname AS object_name, \ @@ -778,6 +797,8 @@ fn list_objects_sql(include_timestamps: bool) -> &'static str { obj_description(p.oid) AS object_comment, \ NULL::text AS created_at, \ NULL::text AS updated_at, \ + NULL::text AS parent_schema, \ + NULL::text AS parent_name, \ CASE p.prokind WHEN 'p' THEN 2 ELSE 3 END AS sort_order \ FROM pg_catalog.pg_proc p \ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \ @@ -805,6 +826,8 @@ pub async fn list_objects(pool: &Pool, schema: &str) -> Result, comment: row.try_get::<_, Option>(2).ok().flatten().filter(|s| !s.is_empty()), created_at: row.try_get::<_, Option>(3).ok().flatten().filter(|s| !s.is_empty()), updated_at: row.try_get::<_, Option>(4).ok().flatten().filter(|s| !s.is_empty()), + parent_schema: row.try_get::<_, Option>(5).ok().flatten().filter(|s| !s.is_empty()), + parent_name: row.try_get::<_, Option>(6).ok().flatten().filter(|s| !s.is_empty()), }) .collect()) } @@ -1349,6 +1372,9 @@ mod tests { assert!(sql.contains("table_name")); assert!(sql.contains("table_type")); assert!(sql.contains("table_comment")); + assert!(sql.contains("pg_catalog.pg_inherits")); + assert!(sql.contains("parent_schema")); + assert!(sql.contains("parent_name")); assert!(sql.contains("$1")); assert!(sql.contains("BASE TABLE")); assert!(sql.contains("VIEW")); @@ -1361,6 +1387,9 @@ mod tests { let sql = list_objects_sql(true); assert!(sql.contains("pg_catalog.pg_class")); assert!(sql.contains("pg_catalog.pg_proc")); + assert!(sql.contains("pg_catalog.pg_inherits")); + assert!(sql.contains("parent_schema")); + assert!(sql.contains("parent_name")); assert!(sql.contains("pg_stat_file")); assert!(sql.contains("pg_xact_commit_timestamp")); assert!(sql.contains("'PROCEDURE'")); diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 2f047aca1f..5d28e94194 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -227,6 +227,8 @@ pub async fn list_tables(pool: &SqliteHandle, _schema: &str) -> Result(0).unwrap_or("").to_string(), table_type: row.get::<&str, _>(1).unwrap_or("BASE TABLE").to_string(), comment: row.get::<&str, _>(2).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), + parent_schema: None, + parent_name: None, }) .collect()) } @@ -335,6 +337,8 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result< comment: row.get::<&str, _>(4).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), created_at: row.get::(2).map(|value| value.to_string()), updated_at: row.get::(3).map(|value| value.to_string()), + parent_schema: None, + parent_name: None, }) .collect()) } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 7af622ec25..ba330881d6 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -66,7 +66,13 @@ pub fn duckdb_query_tables_in_database_with_attached( ).map_err(|e| e.to_string())?; let rows = stmt .query_map((database.as_str(), schema), |row| { - Ok(db::TableInfo { name: row.get::<_, String>(0)?, table_type: row.get::<_, String>(1)?, comment: None }) + Ok(db::TableInfo { + name: row.get::<_, String>(0)?, + table_type: row.get::<_, String>(1)?, + comment: None, + parent_schema: None, + parent_name: None, + }) }) .map_err(|e| e.to_string())?; Ok(rows.filter_map(|r| r.ok()).collect()) @@ -425,7 +431,16 @@ async fn list_tables_once( } fn collection_names_to_tables(names: Vec, table_type: &str) -> Vec { - names.into_iter().map(|name| db::TableInfo { name, table_type: table_type.to_string(), comment: None }).collect() + names + .into_iter() + .map(|name| db::TableInfo { + name, + table_type: table_type.to_string(), + comment: None, + parent_schema: None, + parent_name: None, + }) + .collect() } fn filter_table_infos(tables: Vec, filter: Option<&str>, limit: Option) -> Vec { @@ -526,6 +541,8 @@ async fn list_objects_once( comment: table.comment, created_at: None, updated_at: None, + parent_schema: table.parent_schema, + parent_name: table.parent_name, }) .collect()) }) @@ -572,6 +589,8 @@ async fn list_objects_once( comment: table.comment, created_at: None, updated_at: None, + parent_schema: table.parent_schema, + parent_name: table.parent_name, }) .collect()) } diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index aa6daf92cf..8574ac9be7 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -10,6 +10,8 @@ pub struct TableInfo { pub name: String, pub table_type: String, // "TABLE" or "VIEW" pub comment: Option, + pub parent_schema: Option, + pub parent_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -20,6 +22,8 @@ pub struct ObjectInfo { pub comment: Option, pub created_at: Option, pub updated_at: Option, + pub parent_schema: Option, + pub parent_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/packages/app-tests/objectBrowserRows.test.ts b/packages/app-tests/objectBrowserRows.test.ts index 0d29ce6066..9b53fa27e0 100644 --- a/packages/app-tests/objectBrowserRows.test.ts +++ b/packages/app-tests/objectBrowserRows.test.ts @@ -96,6 +96,22 @@ test("object browser rows mark partition-like tables when their parent table exi assert.equal(rows.find((row) => row.name === "audit_p20220802")?.partitionParentId, undefined); }); +test("object browser rows use explicit partition metadata before name heuristics", () => { + const rows = buildObjectBrowserRows({ + objects: [ + { name: "events", object_type: "TABLE", schema: "public" }, + { name: "events_may", object_type: "TABLE", schema: "public", parent_schema: "public", parent_name: "events" }, + ], + database: "app", + fallbackSchema: "public", + needsSchema: true, + }); + + const parent = rows.find((row) => row.name === "events"); + assert.equal(parent?.partitionCount, 1); + assert.equal(rows.find((row) => row.name === "events_may")?.partitionParentId, parent?.id); +}); + 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"); diff --git a/packages/app-tests/tableTree.test.ts b/packages/app-tests/tableTree.test.ts new file mode 100644 index 0000000000..78acceb624 --- /dev/null +++ b/packages/app-tests/tableTree.test.ts @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildGroupedObjectTreeNodes, buildTableTreeNodes } from "../../apps/desktop/src/lib/tableTree.ts"; +import type { ObjectInfo, TableInfo, TreeNode } from "../../apps/desktop/src/types/database.ts"; + +function table(name: string, parent?: string): TableInfo { + return { + name, + table_type: "BASE TABLE", + comment: null, + parent_schema: parent ? "public" : null, + parent_name: parent ?? null, + }; +} + +function object(name: string, parent?: string): ObjectInfo { + return { + name, + object_type: "TABLE", + schema: "public", + comment: null, + created_at: null, + updated_at: null, + parent_schema: parent ? "public" : null, + parent_name: parent ?? null, + }; +} + +function partitionGroup(node: TreeNode): TreeNode | undefined { + return node.children?.find((child) => child.type === "group-partitions"); +} + +test("buildTableTreeNodes nests multi-level table partitions", () => { + const nodes = buildTableTreeNodes({ + nodeId: "conn:app:public", + connectionId: "conn", + database: "app", + schema: "public", + tables: [table("events"), table("events_2026", "events"), table("events_2026_05", "events_2026"), table("users")], + }); + + assert.deepEqual( + nodes.map((node) => node.label), + ["events", "users"], + ); + assert.equal(nodes[0].id, "conn:app:public:events"); + + const events = nodes[0]; + const firstLevel = partitionGroup(events); + assert.equal(firstLevel?.label, "tree.partitions"); + assert.deepEqual( + firstLevel?.children?.map((node) => node.label), + ["events_2026"], + ); + + const secondLevel = partitionGroup(firstLevel!.children![0]); + assert.deepEqual( + secondLevel?.children?.map((node) => node.label), + ["events_2026_05"], + ); +}); + +test("buildTableTreeNodes keeps partitions visible when their parent is not loaded", () => { + const nodes = buildTableTreeNodes({ + nodeId: "conn:app:public", + connectionId: "conn", + database: "app", + schema: "public", + tables: [table("events_2026", "events")], + }); + + assert.deepEqual( + nodes.map((node) => node.label), + ["events_2026"], + ); +}); + +test("buildGroupedObjectTreeNodes nests partitions inside the tables group", () => { + const groups = buildGroupedObjectTreeNodes({ + nodeId: "conn:app:public", + connectionId: "conn", + database: "app", + schema: "public", + objects: [object("events"), object("events_2026", "events"), object("events_2026_05", "events_2026")], + }); + + const tableGroup = groups.find((node) => node.type === "group-tables"); + assert.equal(tableGroup?.objectCount, 3); + assert.deepEqual( + tableGroup?.children?.map((node) => node.label), + ["events"], + ); + assert.equal(tableGroup?.children?.[0]?.id, "conn:app:public:__tables:public:events"); + assert.deepEqual( + partitionGroup(tableGroup!.children![0])?.children?.map((node) => node.label), + ["events_2026"], + ); +});