From b74d49e982860f4f9fddbbf5f89530440b2d9e86 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sun, 23 Aug 2026 01:38:19 +0800 Subject: [PATCH] feat(sidebar): extend filters to views and routines --- .../sidebar/SidebarTreeRuntimeHost.vue | 3 +- .../src/components/sidebar/TreeItem.vue | 7 +- apps/desktop/src/i18n/locales/en.ts | 6 +- apps/desktop/src/i18n/locales/es.ts | 6 +- apps/desktop/src/i18n/locales/it.ts | 6 +- apps/desktop/src/i18n/locales/ja.ts | 6 +- apps/desktop/src/i18n/locales/ko.ts | 6 +- apps/desktop/src/i18n/locales/pt-BR.ts | 6 +- apps/desktop/src/i18n/locales/zh-CN.ts | 6 +- apps/desktop/src/i18n/locales/zh-TW.ts | 6 +- .../sidebar/sidebarObjectNameFilter.spec.ts | 13 ++ apps/desktop/src/lib/backend/http.ts | 3 +- apps/desktop/src/lib/backend/tauri.ts | 3 +- .../lib/sidebar/sidebarObjectNameFilter.ts | 12 ++ apps/desktop/src/stores/connectionStore.ts | 18 ++- crates/dbx-core/src/database_export.rs | 1 + crates/dbx-core/src/schema.rs | 55 +++++-- crates/dbx-web/src/routes/schema.rs | 7 +- .../app-tests/sidebarObjectNameFilter.test.ts | 140 ++++++++++++++++++ src-tauri/src/commands/schema.rs | 4 +- 20 files changed, 268 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sidebar/sidebarObjectNameFilter.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/sidebarObjectNameFilter.ts create mode 100644 packages/app-tests/sidebarObjectNameFilter.test.ts diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index af4d05d881..0396c7efe1 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -74,6 +74,7 @@ import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; import { connectionUsesVisibleSchemaFilter } from "@/lib/database/visibleDatabases"; import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout"; import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu"; +import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter"; import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout"; import { objectTypesForGroupNode } from "@/lib/table/tableTree"; import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"; @@ -5658,7 +5659,7 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea disabled: node.isLoading, }); } - if (node.type === "group-tables") { + if (supportsSidebarObjectNameFilter(node)) { items.push({ label: t("contextMenu.tableNameFilters"), action: () => emit("open-table-name-filters", node), diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index e74c532f62..851c10335d 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -69,6 +69,7 @@ import { connectionBearingGroupIdsUnder, connectionIdsUnderGroup } from "@/lib/s import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; +import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter"; import { isWindows } from "@/lib/backend/platform"; import { flattenTree } from "@/composables/useFlatTree"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; @@ -407,8 +408,8 @@ function visibleLabel(node: TreeNode): string { return withValidity(displayLabel(node)); } -function hasActiveTableNameFilter(node: TreeNode): boolean { - if (node.type !== "group-tables" || !node.connectionId || !node.database) return false; +function hasActiveObjectNameFilter(node: TreeNode): boolean { + if (!supportsSidebarObjectNameFilter(node) || !node.connectionId || !node.database) return false; const filter = connectionStore.tableNameFilterForScope({ connectionId: node.connectionId, database: node.database, @@ -1471,7 +1472,7 @@ function onKeydown(event: KeyboardEvent) { node.objectCount != null " class="text-muted-foreground text-[10px] shrink-0" - >{{ node.objectCount }} · {{ t("tree.tableNameFilterActive") }}{{ node.objectCount }} · {{ t("tree.tableNameFilterActive") }} {{ t("editor.defaultDatabase") }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b48d036e0c..6d8f5ecada 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2783,9 +2783,9 @@ export default { refreshChildren: "Refresh", expandAll: "Expand All", tableNameFilters: "Filters...", - tableNameFiltersDescription: "Show only table names that match the include list, then hide names that match the exclude list. One SQL LIKE pattern per line.", - tableNameFilterInclude: "Include table patterns", - tableNameFilterExclude: "Exclude table patterns", + tableNameFiltersDescription: "Show only object names that match the include list, then hide names that match the exclude list. One SQL LIKE pattern per line.", + tableNameFilterInclude: "Include object name patterns", + tableNameFilterExclude: "Exclude object name patterns", tableNameFilterIncludePlaceholder: "Example:\nads_%\nuser_%", tableNameFilterExcludePlaceholder: "Example:\n%_bak\ntmp_%", tableNameFilterLikeHint: "Use SQL LIKE syntax: % matches any characters, _ matches one character. Matching is case-insensitive.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index da50ec6ab5..81a1383632 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2716,9 +2716,9 @@ export default withEnglishFallback({ refreshChildren: "Actualizar", expandAll: "Expandir todo", tableNameFilters: "Filtros...", - tableNameFiltersDescription: "Muestra solo tablas que coincidan con include y oculta las que coincidan con exclude. Un patrón SQL LIKE por línea.", - tableNameFilterInclude: "Patrones de tablas incluidas", - tableNameFilterExclude: "Patrones de tablas excluidas", + tableNameFiltersDescription: "Muestra solo objetos que coincidan con include y oculta los que coincidan con exclude. Un patrón SQL LIKE por línea.", + tableNameFilterInclude: "Patrones de nombres de objetos incluidos", + tableNameFilterExclude: "Patrones de nombres de objetos excluidos", tableNameFilterIncludePlaceholder: "Ejemplo:\nads_%\nuser_%", tableNameFilterExcludePlaceholder: "Ejemplo:\n%_bak\ntmp_%", tableNameFilterLikeHint: "Usa sintaxis SQL LIKE: % coincide con cualquier texto, _ con un carácter. No distingue mayúsculas.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index afedec294a..7699ea4d98 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2714,9 +2714,9 @@ export default withEnglishFallback({ refreshChildren: "Aggiorna", expandAll: "Espandi tutto", tableNameFilters: "Filtri...", - tableNameFiltersDescription: "Mostra solo le tabelle che corrispondono alla lista include, poi nasconde quelle che corrispondono alla lista exclude. Un pattern SQL LIKE per riga.", - tableNameFilterInclude: "Pattern tabelle incluse", - tableNameFilterExclude: "Pattern tabelle escluse", + tableNameFiltersDescription: "Mostra solo gli oggetti che corrispondono alla lista include, poi nasconde quelli che corrispondono alla lista exclude. Un pattern SQL LIKE per riga.", + tableNameFilterInclude: "Pattern nomi oggetto inclusi", + tableNameFilterExclude: "Pattern nomi oggetto esclusi", tableNameFilterIncludePlaceholder: "Esempio:\nads_%\nuser_%", tableNameFilterExcludePlaceholder: "Esempio:\n%_bak\ntmp_%", tableNameFilterLikeHint: "Usa la sintassi SQL LIKE: % corrisponde a qualsiasi testo, _ a un carattere. Match case-insensitive.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 47935d8e29..e7eddd20bf 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2738,9 +2738,9 @@ export default withEnglishFallback({ refreshChildren: "更新", expandAll: "すべて展開", tableNameFilters: "フィルター...", - tableNameFiltersDescription: "Include リストに一致するテーブル名だけを表示し、Exclude リストに一致する名前を非表示にします。1 行に 1 つの SQL LIKE パターンを入力します。", - tableNameFilterInclude: "含めるテーブル名パターン", - tableNameFilterExclude: "除外するテーブル名パターン", + tableNameFiltersDescription: "Include リストに一致するオブジェクト名だけを表示し、Exclude リストに一致する名前を非表示にします。1 行に 1 つの SQL LIKE パターンを入力します。", + tableNameFilterInclude: "含めるオブジェクト名パターン", + tableNameFilterExclude: "除外するオブジェクト名パターン", tableNameFilterIncludePlaceholder: "例:\nads_%\nuser_%", tableNameFilterExcludePlaceholder: "例:\n%_bak\ntmp_%", tableNameFilterLikeHint: "SQL LIKE 構文を使用します。% は任意の文字列、_ は 1 文字に一致します。大文字小文字は区別しません。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index c8fbdd7201..f6a7b7b7a0 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2625,9 +2625,9 @@ export default withEnglishFallback({ refreshChildren: "새로고침", expandAll: "모두 펼치기", tableNameFilters: "필터...", - tableNameFiltersDescription: "포함 목록과 일치하는 테이블 이름만 표시한 다음 제외 목록과 일치하는 이름을 숨깁니다. 한 줄에 하나의 SQL LIKE 패턴.", - tableNameFilterInclude: "포함 테이블 패턴", - tableNameFilterExclude: "제외 테이블 패턴", + tableNameFiltersDescription: "포함 목록과 일치하는 객체 이름만 표시한 다음 제외 목록과 일치하는 이름을 숨깁니다. 한 줄에 하나의 SQL LIKE 패턴.", + tableNameFilterInclude: "포함 객체 이름 패턴", + tableNameFilterExclude: "제외 객체 이름 패턴", tableNameFilterIncludePlaceholder: "예:\nads_%\nuser_%", tableNameFilterExcludePlaceholder: "예:\n%_bak\ntmp_%", tableNameFilterLikeHint: "SQL LIKE 문법을 사용하세요: %는 임의의 문자, _는 한 문자와 일치합니다. 대소문자를 구분하지 않습니다.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a085b019b7..b54deed993 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2716,9 +2716,9 @@ export default withEnglishFallback({ refreshChildren: "Atualizar", expandAll: "Expandir tudo", tableNameFilters: "Filtros...", - tableNameFiltersDescription: "Mostra apenas tabelas que correspondem à lista include e oculta as que correspondem à lista exclude. Um padrão SQL LIKE por linha.", - tableNameFilterInclude: "Padrões de tabelas incluídas", - tableNameFilterExclude: "Padrões de tabelas excluídas", + tableNameFiltersDescription: "Mostra apenas objetos que correspondem à lista include e oculta os que correspondem à lista exclude. Um padrão SQL LIKE por linha.", + tableNameFilterInclude: "Padrões de nomes de objetos incluídos", + tableNameFilterExclude: "Padrões de nomes de objetos excluídos", tableNameFilterIncludePlaceholder: "Exemplo:\nads_%\nuser_%", tableNameFilterExcludePlaceholder: "Exemplo:\n%_bak\ntmp_%", tableNameFilterLikeHint: "Use sintaxe SQL LIKE: % corresponde a qualquer texto, _ a um caractere. A comparação ignora maiúsculas.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 57275e7e0a..4880a54faf 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2707,9 +2707,9 @@ export default withEnglishFallback({ refreshChildren: "刷新", expandAll: "展开全部", tableNameFilters: "过滤器...", - tableNameFiltersDescription: "先仅显示命中 include 列表的表名,再隐藏命中 exclude 列表的表名。每行一个 SQL LIKE 模式。", - tableNameFilterInclude: "包含表名模式", - tableNameFilterExclude: "排除表名模式", + tableNameFiltersDescription: "先仅显示命中 include 列表的对象名称,再隐藏命中 exclude 列表的对象名称。每行一个 SQL LIKE 模式。", + tableNameFilterInclude: "包含对象名称模式", + tableNameFilterExclude: "排除对象名称模式", tableNameFilterIncludePlaceholder: "例如:\nads_%\nuser_%", tableNameFilterExcludePlaceholder: "例如:\n%_bak\ntmp_%", tableNameFilterLikeHint: "使用 SQL LIKE 语法:% 匹配任意字符,_ 匹配单个字符。匹配时不区分大小写。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0cc0761ea1..3dac8434ff 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2714,9 +2714,9 @@ export default withEnglishFallback({ refreshChildren: "重新整理", expandAll: "展開全部", tableNameFilters: "篩選器...", - tableNameFiltersDescription: "先僅顯示符合 include 清單的資料表名稱,再隱藏符合 exclude 清單的資料表名稱。每行一個 SQL LIKE 模式。", - tableNameFilterInclude: "包含資料表名稱模式", - tableNameFilterExclude: "排除資料表名稱模式", + tableNameFiltersDescription: "先僅顯示符合 include 清單的物件名稱,再隱藏符合 exclude 清單的物件名稱。每行一個 SQL LIKE 模式。", + tableNameFilterInclude: "包含物件名稱模式", + tableNameFilterExclude: "排除物件名稱模式", tableNameFilterIncludePlaceholder: "範例:\nads_%\nuser_%", tableNameFilterExcludePlaceholder: "範例:\n%_bak\ntmp_%", tableNameFilterLikeHint: "使用 SQL LIKE 語法:% 符合任意字元,_ 符合單一字元。比對不區分大小寫。", diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectNameFilter.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectNameFilter.spec.ts new file mode 100644 index 0000000000..99c8123496 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectNameFilter.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { supportsSidebarObjectNameFilter } from "../../sidebar/sidebarObjectNameFilter"; + +describe("supportsSidebarObjectNameFilter", () => { + it("allows only supported object groups", () => { + const supportedTypes = ["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions"] as const; + expect(supportedTypes.every((type) => supportsSidebarObjectNameFilter({ type, parentType: undefined }))).toBe(true); + expect(supportsSidebarObjectNameFilter({ type: "group-indexes", parentType: undefined })).toBe(false); + expect(supportsSidebarObjectNameFilter({ type: "group-triggers", parentType: undefined })).toBe(false); + expect(supportsSidebarObjectNameFilter({ type: "group-events", parentType: undefined })).toBe(false); + expect(supportsSidebarObjectNameFilter({ type: "group-functions", parentType: "package" })).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index b688e77405..e3ff1ba1e9 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -829,7 +829,7 @@ export async function getTableComment(_connectionId: string, _database: string, throw new Error("Table comment lookup is not available in the web backend"); } -export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string): Promise { +export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string, tableNameFilter?: TableNameFilter): Promise { return get( `/api/schema/objects?${qs({ connection_id: connectionId, @@ -840,6 +840,7 @@ export async function listObjects(connectionId: string, database: string, schema limit, offset, catalog, + table_name_filter: tableNameFilter ? JSON.stringify(tableNameFilter) : undefined, })}`, ); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 5e09d242c3..79ceb6e360 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1093,7 +1093,7 @@ export async function getTableComment(connectionId: string, database: string, sc }); } -export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string): Promise { +export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string, tableNameFilter?: import("@/types/database").TableNameFilter): Promise { return invoke("list_objects", { connectionId, database, @@ -1103,6 +1103,7 @@ export async function listObjects(connectionId: string, database: string, schema limit, offset, catalog, + tableNameFilter, }); } diff --git a/apps/desktop/src/lib/sidebar/sidebarObjectNameFilter.ts b/apps/desktop/src/lib/sidebar/sidebarObjectNameFilter.ts new file mode 100644 index 0000000000..4d611d30f6 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarObjectNameFilter.ts @@ -0,0 +1,12 @@ +import type { TreeNode, TreeNodeType } from "@/types/database"; + +const sidebarObjectNameFilterGroupTypes: ReadonlySet = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions"]); + +type SidebarObjectNameFilterTarget = { + type: TreeNodeType; + parentType?: TreeNode["parentType"]; +}; + +export function supportsSidebarObjectNameFilter(node: SidebarObjectNameFilterTarget): boolean { + return node.parentType !== "package" && sidebarObjectNameFilterGroupTypes.has(node.type); +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 8f6fae45ab..8059c73004 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1715,9 +1715,9 @@ export const useConnectionStore = defineStore("connection", () => { tableListSourceRevisions.set(connectionId, tableListSourceRevision(connectionId) + 1); } - function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined, sourceRevision: number): MetadataScopeInput["extra"] { + function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined, sourceRevision?: number): MetadataScopeInput["extra"] { return { - tableListSourceRevision: sourceRevision, + ...(sourceRevision === undefined ? {} : { tableListSourceRevision: sourceRevision }), ...(filter ? { tableNameFilterInclude: filter.includePatterns, @@ -2066,6 +2066,14 @@ export const useConnectionStore = defineStore("connection", () => { return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset }; } const searchFilter = options.searchFilter || undefined; + const userTableNameFilter = activeTableNameFilterForScope({ + connectionId: options.node.connectionId, + database: options.node.database, + schema: options.node.schema, + nodeKind: options.node.type, + catalog: options.node.catalog, + }); + const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter); const fetchLimit = searchFilter ? undefined : options.pageSize + 1; const fetchOffset = searchFilter ? undefined : options.offset; const objects = await loadCachedMetadataListPage( @@ -2080,8 +2088,12 @@ export const useConnectionStore = defineStore("connection", () => { limit: fetchLimit, offset: fetchOffset, sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay, + extra: tableNameFilterMetadataExtra(tableNameFilter), }), - () => api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset), + () => + tableNameFilter || options.node.catalog + ? api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset, options.node.catalog, tableNameFilter) + : api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset), { force: options.force }, ); const hasMore = searchFilter ? false : objects.length > options.pageSize; diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index e63852f145..65882b34d9 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -2103,6 +2103,7 @@ async fn export_database_sql_core_inner( None, None, None, + None, ) .await { diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 801df39533..dc2e477a02 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -2523,6 +2523,7 @@ fn filter_object_infos( limit: Option, offset: Option, object_types: Option<&[String]>, + table_name_filter: Option<&TableNameFilter>, ) -> Vec { let filter = filter.unwrap_or(""); let limit = limit.unwrap_or(usize::MAX); @@ -2530,6 +2531,7 @@ fn filter_object_infos( objects .into_iter() .filter(|object| metadata_name_or_comment_matches(&object.name, object.comment.as_deref(), filter)) + .filter(|object| table_name_filter_matches(&object.name, table_name_filter)) .filter(|object| object_info_matches_object_types(object, object_types)) .skip(offset) .take(limit) @@ -4224,6 +4226,26 @@ for line in sys.stdin: assert_eq!(filtered.into_iter().map(|table| table.name).collect::>(), vec!["monthly_orders_mv"]); } + #[test] + fn filter_object_infos_applies_sql_like_name_filter() { + let objects = vec![ + test_object_info("fn_get_user", "FUNCTION"), + test_object_info("fn_get_role", "FUNCTION"), + test_object_info("fn_get_role_bak", "FUNCTION"), + test_object_info("internal_hash", "FUNCTION"), + ]; + let object_types = vec!["FUNCTION".to_string()]; + let name_filter = + TableNameFilter { include_patterns: vec!["FN_%".to_string()], exclude_patterns: vec!["%_BAK".to_string()] }; + + let filtered = filter_object_infos(objects, None, None, None, Some(&object_types), Some(&name_filter)); + + assert_eq!( + filtered.into_iter().map(|object| object.name).collect::>(), + vec!["fn_get_user", "fn_get_role"] + ); + } + #[test] fn filter_object_infos_filters_object_type_before_offset_and_limit() { let objects = vec![ @@ -4234,7 +4256,7 @@ for line in sys.stdin: ]; let object_types = vec!["FUNCTION".to_string()]; - let filtered = filter_object_infos(objects, Some("fn"), Some(1), Some(1), Some(&object_types)); + let filtered = filter_object_infos(objects, Some("fn"), Some(1), Some(1), Some(&object_types), None); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "fetch_name"); @@ -4250,7 +4272,7 @@ for line in sys.stdin: ]; let object_types = vec!["MATERIALIZED_VIEW".to_string()]; - let filtered = filter_object_infos(objects, Some("orders"), Some(1), Some(1), Some(&object_types)); + let filtered = filter_object_infos(objects, Some("orders"), Some(1), Some(1), Some(&object_types), None); assert_eq!(filtered.into_iter().map(|object| object.name).collect::>(), vec!["monthly_orders_mv"]); } @@ -4264,7 +4286,7 @@ for line in sys.stdin: let objects = vec![order_view, sync_user, test_object_info("audit_log", "TABLE")]; let object_types = vec!["VIEW".to_string()]; - let filtered = filter_object_infos(objects, Some("revenue"), None, None, Some(&object_types)); + let filtered = filter_object_infos(objects, Some("revenue"), None, None, Some(&object_types), None); assert_eq!(filtered.into_iter().map(|object| object.name).collect::>(), vec!["order_view"]); } @@ -5024,13 +5046,16 @@ pub async fn list_objects_core( limit: Option, offset: Option, object_types: Option<&[String]>, + table_name_filter: Option<&TableNameFilter>, ) -> Result, String> { let db_config = connection_config(state, connection_id).await; let filter_locally_after_oracle_comments = db_config.as_ref().is_some_and(|config| { config.db_type == DatabaseType::Oracle && filter.is_some_and(|filter| !filter.trim().is_empty()) }); - let use_oracle_agent_paging = - db_config.as_ref().is_some_and(is_default_oracle_agent_config) && !filter_locally_after_oracle_comments; + let force_local_table_name_filter = table_name_filter.is_some_and(|filter| !filter.is_empty()); + let use_oracle_agent_paging = db_config.as_ref().is_some_and(is_default_oracle_agent_config) + && !filter_locally_after_oracle_comments + && !force_local_table_name_filter; let metadata_session = EphemeralAgentMetadataSession::open(state, connection_id, Some(database), "objects").await; let result = retry_metadata_connection_for_session( state, @@ -5047,6 +5072,7 @@ pub async fn list_objects_core( limit, offset, object_types, + table_name_filter, metadata_session.client_session_id(), ) .await @@ -5058,7 +5084,7 @@ pub async fn list_objects_core( } else { offset }; - filter_object_infos(outcome.objects, filter, limit, final_offset, object_types) + filter_object_infos(outcome.objects, filter, limit, final_offset, object_types, table_name_filter) })?; Ok(objects) }, @@ -5481,13 +5507,20 @@ async fn list_objects_once( limit: Option, offset: Option, object_types: Option<&[String]>, + table_name_filter: Option<&TableNameFilter>, client_session_id: Option<&str>, ) -> Result { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?; let db_config = connection_config(state, connection_id).await; - let (mysql_limit, mysql_offset) = - if filter.is_none_or(|value| value.trim().is_empty()) { (limit, offset) } else { (None, None) }; + let force_local_table_name_filter = table_name_filter.is_some_and(|filter| !filter.is_empty()); + let (mysql_limit, mysql_offset) = if filter.is_none_or(|value| value.trim().is_empty()) + && !table_name_filter.is_some_and(|filter| !filter.is_empty()) + { + (limit, offset) + } else { + (None, None) + }; { let connections = state.connections.read().await; @@ -5544,14 +5577,14 @@ async fn list_objects_once( } let mut client = client.lock().await; let agent_filter = if filter_locally_after_oracle_comments { None } else { filter }; - let agent_limit = if filter_locally_after_oracle_comments { + let agent_limit = if filter_locally_after_oracle_comments || force_local_table_name_filter { None } else if use_oracle_agent_paging { limit } else { None }; - let agent_offset = if filter_locally_after_oracle_comments { + let agent_offset = if filter_locally_after_oracle_comments || force_local_table_name_filter { None } else if use_oracle_agent_paging { offset @@ -5818,7 +5851,7 @@ async fn list_completion_objects_once( } PoolKind::SqlServer(_) => { let outcome = - list_objects_once(state, connection_id, database, schema, None, None, None, None, None).await?; + list_objects_once(state, connection_id, database, schema, None, None, None, None, None, None).await?; Ok(filter_completion_objects(outcome.objects)) } _ => Ok(Vec::new()), diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 1c358c9947..53d4f55b56 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -240,6 +240,10 @@ pub async fn list_objects( let object_types = q.object_types.as_ref().map(|value| { value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(str::to_string).collect::>() }); + let table_name_filter = q + .table_name_filter + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()); let result = if let Some(catalog) = external_doris_catalog(&state, &q.connection_id, q.catalog.as_deref()).await { let tables = dbx_core::schema::list_doris_catalog_tables_core( &state.app, @@ -250,7 +254,7 @@ pub async fn list_objects( q.limit, q.offset, object_types.as_deref(), - None, + table_name_filter.as_ref(), ) .await .map_err(AppError::from)?; @@ -283,6 +287,7 @@ pub async fn list_objects( q.limit, q.offset, object_types.as_deref(), + table_name_filter.as_ref(), ) .await .map_err(AppError::from)? diff --git a/packages/app-tests/sidebarObjectNameFilter.test.ts b/packages/app-tests/sidebarObjectNameFilter.test.ts new file mode 100644 index 0000000000..e07d11b127 --- /dev/null +++ b/packages/app-tests/sidebarObjectNameFilter.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, afterEach, expect, test, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import type { ConnectionConfig, TreeNode } from "../../apps/desktop/src/types/database.ts"; + +function installMemoryStorage() { + const values = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }); +} + +function connection(): ConnectionConfig { + return { + id: "pg-1", + name: "Postgres", + db_type: "postgres", + host: "127.0.0.1", + port: 5432, + username: "postgres", + password: "", + database: "app", + } as ConnectionConfig; +} + +beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installMemoryStorage(); + setActivePinia(createPinia()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("object-group filters keep table, view, procedure, and function scopes isolated", async () => { + const listTables = vi.fn(async (...args: unknown[]) => { + const objectTypes = args[6] as string[] | undefined; + if (objectTypes?.[0] === "TABLE") return [{ name: "tbl_orders", table_type: "BASE TABLE", comment: null }]; + if (objectTypes?.[0] === "VIEW") return [{ name: "vw_orders", table_type: "VIEW", comment: null }]; + return []; + }); + const listObjects = vi.fn(async (...args: unknown[]) => { + const objectTypes = args[3] as string[] | undefined; + const filter = args[8] as { includePatterns: string[]; excludePatterns: string[] } | undefined; + if (objectTypes?.[0] === "PROCEDURE" && filter?.includePatterns.includes("proc_%")) return [{ name: "proc_sync_orders", object_type: "PROCEDURE" }]; + if (objectTypes?.[0] === "FUNCTION" && filter?.includePatterns.includes("fn_%")) return [{ name: "fn_get_user", object_type: "FUNCTION" }]; + if (objectTypes?.[0] === "FUNCTION" && !filter) + return [ + { name: "fn_get_user", object_type: "FUNCTION" }, + { name: "internal_hash", object_type: "FUNCTION" }, + ]; + return []; + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listObjects, + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("../../apps/desktop/src/stores/connectionStore.ts"); + const store = useConnectionStore(); + const currentConnection = connection(); + const group = (type: TreeNode["type"]): TreeNode => ({ + id: `pg-1:app:public:__${type.slice("group-".length)}`, + label: type, + type, + connectionId: currentConnection.id, + database: "app", + schema: "public", + isExpanded: false, + children: [], + }); + const groups = [group("group-tables"), group("group-views"), group("group-procedures"), group("group-functions")]; + const schemaNode: TreeNode = { + id: "pg-1:app:public", + label: "public", + type: "schema", + connectionId: currentConnection.id, + database: "app", + schema: "public", + isExpanded: true, + children: groups, + }; + store.connections = [currentConnection]; + store.connectedIds.add(currentConnection.id); + store.treeNodes = [ + { + id: currentConnection.id, + label: currentConnection.name, + type: "connection", + connectionId: currentConnection.id, + isExpanded: true, + children: [{ id: "pg-1:app", label: "app", type: "database", connectionId: currentConnection.id, database: "app", isExpanded: true, children: [schemaNode] }], + }, + ]; + + const liveGroup = (type: TreeNode["type"]): TreeNode => { + const node = store.treeNodes[0]?.children?.[0]?.children?.[0]?.children?.find((child: TreeNode) => child.type === type); + if (!node) throw new Error(`Missing object group: ${type}`); + return node; + }; + const filters = [ + ["group-tables", { includePatterns: ["tbl_%"], excludePatterns: [] }], + ["group-views", { includePatterns: ["vw_%"], excludePatterns: [] }], + ["group-procedures", { includePatterns: ["proc_%"], excludePatterns: [] }], + ["group-functions", { includePatterns: ["fn_%"], excludePatterns: ["%_bak"] }], + ] as const; + for (const [type, filter] of filters) { + const node = liveGroup(type); + const scopeKey = store.tableNameFilterScopeKey({ connectionId: currentConnection.id, database: "app", schema: "public", nodeKind: node.type }); + const revision = store.setSidebarTableNameFilter(scopeKey, filter); + await store.refreshTreeNodeForTableNameFilter(node, scopeKey, revision); + } + + const liveGroups = filters.map(([type]) => liveGroup(type)); + expect(new Set(liveGroups.map((node) => store.tableNameFilterScopeKey({ connectionId: currentConnection.id, database: "app", schema: "public", nodeKind: node.type }))).size).toBe(4); + expect(liveGroups.map((node) => node.children?.map((child) => child.label))).toEqual([["tbl_orders"], ["vw_orders"], ["proc_sync_orders"], ["fn_get_user"]]); + expect(store.tableNameFilterForScope({ connectionId: currentConnection.id, database: "app", schema: "public", nodeKind: "group-functions" })).toEqual({ + includePatterns: ["fn_%"], + excludePatterns: ["%_bak"], + }); + + const functionGroup = liveGroup("group-functions"); + const functionScopeKey = store.tableNameFilterScopeKey({ connectionId: currentConnection.id, database: "app", schema: "public", nodeKind: functionGroup.type }); + const clearRevision = store.setSidebarTableNameFilter(functionScopeKey, { includePatterns: [], excludePatterns: [] }); + await store.refreshTreeNodeForTableNameFilter(functionGroup, functionScopeKey, clearRevision); + + expect(liveGroup("group-functions").children?.map((child) => child.label)).toEqual(["fn_get_user", "internal_hash"]); + expect(listObjects.mock.calls.at(-1)).toEqual([currentConnection.id, "app", "public", ["FUNCTION"], undefined, 1001, 0]); +}, 15000); diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index a15def4876..6cf9162937 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -221,6 +221,7 @@ pub async fn list_objects( offset: Option, object_types: Option>, catalog: Option, + table_name_filter: Option, ) -> Result, String> { if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { let tables = dbx_core::schema::list_doris_catalog_tables_core( @@ -232,7 +233,7 @@ pub async fn list_objects( limit, offset, object_types.as_deref(), - None, + table_name_filter.as_ref(), ) .await?; return Ok(tables @@ -264,6 +265,7 @@ pub async fn list_objects( limit, offset, object_types.as_deref(), + table_name_filter.as_ref(), ) .await }