From 4c48263793b33af70ef1370bcf1abcd19560e573 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 29 Jun 2026 03:13:35 +0800 Subject: [PATCH] feat(grid): add database value filter --- apps/desktop/src/components/grid/DataGrid.vue | 249 ++++++++++++++-- apps/desktop/src/i18n/locales/en.ts | 12 +- apps/desktop/src/i18n/locales/es.ts | 10 +- apps/desktop/src/i18n/locales/it.ts | 10 +- apps/desktop/src/i18n/locales/ja.ts | 12 +- apps/desktop/src/i18n/locales/pt-BR.ts | 10 +- apps/desktop/src/i18n/locales/zh-CN.ts | 12 +- apps/desktop/src/i18n/locales/zh-TW.ts | 10 +- apps/desktop/src/lib/api.ts | 2 + apps/desktop/src/lib/dataGridColumnFilter.ts | 17 +- apps/desktop/src/lib/dataGridSql.ts | 27 ++ apps/desktop/src/lib/http.ts | 21 +- apps/desktop/src/lib/tauri.ts | 21 +- crates/dbx-core/src/data_grid_sql.rs | 265 +++++++++++++++++- crates/dbx-web/src/main.rs | 8 + crates/dbx-web/src/routes/query.rs | 24 ++ .../app-tests/dataGridColumnFilter.test.ts | 25 +- src-tauri/src/commands/query.rs | 14 + src-tauri/src/lib.rs | 2 + 19 files changed, 700 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 66467ddf8..a7533a5c5 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -92,7 +92,7 @@ import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql" import { uuid } from "@/lib/utils"; import { resolveHeaderColumnType } from "@/lib/dataGridColumnType"; import { canEditExistingTableRows, canUseKeylessRowPredicate, hiveTablePropertiesIndicateTransactional, isClickHouseExistingRowReadonlyColumn, isHiddenGridColumn, isTdengineExistingRowReadonlyColumn, usesSyntheticRowIdKey } from "@/lib/tableEditing"; -import { buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGridSql"; +import { buildDataGridColumnDistinctValuesSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGridSql"; import { buildVisibleTransposeRows, nextAppendedTransposeState, @@ -125,7 +125,7 @@ import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/canvasDat import { dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGridSaveUi"; import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editorThemes"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; -import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, combineWhereInputs, filterModeNeedsValue, parseFilterValue } from "@/lib/dataGridColumnFilter"; +import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, buildColumnValuesFilterCondition, combineWhereInputs, filterModeNeedsValue, parseFilterValue } from "@/lib/dataGridColumnFilter"; import { clampSearchSplitWidth } from "@/lib/dataGridSearchSplit"; import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE, normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/paginationPageSize"; import { allNullColumnIndexes, filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGridColumnVisibility"; @@ -521,9 +521,19 @@ const orderBySuggestionStyle = computed(() => ({ top: `${orderBySuggestionPosition.value.top}px`, })); +type LocalFilterMode = "local" | "server"; +type LocalFilterOption = { + key: string; + label: string; + count: number | null; + value: CellValue; +}; + type LocalColumnFilterDraft = { columnIndex: number; values: Set; + mode: LocalFilterMode; + touched: boolean; }; type FilterMode = DataGridContextFilterMode; @@ -544,6 +554,15 @@ const headerSortMenuOpenColumn = ref(null); const headerPanelDismissGuardUntil = ref(0); const localFilterSearch = ref(""); const localFilterDraft = ref(null); +const SERVER_COLUMN_FILTER_LIMIT = 1000; +const SERVER_COLUMN_FILTER_DEBOUNCE_MS = 300; +const serverFilterLoading = ref(false); +const serverFilterError = ref(""); +const serverFilterOptions = ref([]); +const serverFilterLimited = ref(false); +const serverFilterValueByKey = ref>(new Map()); +let serverFilterRequestId = 0; +let serverFilterSearchTimer: ReturnType | undefined; const filterBuilderOpen = ref(false); const filterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [ { value: "equals", labelKey: "grid.filterBuilderEquals" }, @@ -635,13 +654,13 @@ const localFilteredRows = computed(() => { return indices; }); -function buildLocalFilterOptions(columnIndex: number) { - const byKey = new Map(); +function buildLocalFilterOptions(columnIndex: number): LocalFilterOption[] { + const byKey = new Map(); const addValue = (value: CellValue) => { const key = localFilterKey(value); const current = byKey.get(key); if (current) { - current.count += 1; + current.count = (current.count ?? 0) + 1; } else { byKey.set(key, { key, label: localFilterLabel(value, columnIndex), count: 1, value }); } @@ -662,12 +681,14 @@ function buildLocalFilterOptions(columnIndex: number) { } const localFilterAllOptions = computed(() => { + if (localFilterDraft.value?.mode === "server") return serverFilterOptions.value; const columnIndex = localFilterDraft.value?.columnIndex; if (columnIndex === undefined) return []; return buildLocalFilterOptions(columnIndex); }); const localFilterOptions = computed(() => { + if (localFilterDraft.value?.mode === "server") return serverFilterOptions.value; const query = localFilterSearch.value.trim().toLowerCase(); return localFilterAllOptions.value.filter((option) => !query || option.label.toLowerCase().includes(query)).slice(0, 500); }); @@ -695,14 +716,119 @@ const canApplyTypedLocalFilterValue = computed(() => { return !localFilterAllOptions.value.some((option) => option.label.toLowerCase() === normalized); }); -function openLocalFilter(colIdx: number) { +function openLocalFilter(colIdx: number, requestedMode: LocalFilterMode = "local") { localFilterSearch.value = ""; - const allKeys = buildLocalFilterOptions(colIdx).map((option) => option.key); + const mode: LocalFilterMode = requestedMode === "server" && canUseServerColumnFilter.value ? "server" : "local"; + const allKeys = mode === "server" ? [] : buildLocalFilterOptions(colIdx).map((option) => option.key); localFilterDraft.value = { columnIndex: colIdx, - values: new Set(localColumnFilters.value[colIdx] ?? allKeys), + values: new Set(mode === "server" ? allKeys : (localColumnFilters.value[colIdx] ?? allKeys)), + mode, + touched: false, }; localFilterOpenColumn.value = colIdx; + if (mode === "server") { + resetServerFilterState(); + void loadServerFilterValues(colIdx, ""); + } else { + resetServerFilterState(); + } +} + +function resetServerFilterState() { + serverFilterRequestId++; + if (serverFilterSearchTimer !== undefined) { + window.clearTimeout(serverFilterSearchTimer); + serverFilterSearchTimer = undefined; + } + serverFilterLoading.value = false; + serverFilterError.value = ""; + serverFilterOptions.value = []; + serverFilterLimited.value = false; + serverFilterValueByKey.value = new Map(); +} + +function serverFilterOptionFromRow(row: QueryResult["rows"][number], columnIndex: number): LocalFilterOption { + const value = (row[0] ?? null) as CellValue; + const countValue = Number(row[1]); + const count = Number.isFinite(countValue) ? countValue : null; + return { + key: localFilterKey(value), + label: localFilterLabel(value, columnIndex), + count, + value, + }; +} + +function serverFilterOptionsFromResult(result: QueryResult, columnIndex: number): LocalFilterOption[] { + const byKey = new Map(); + for (const row of result.rows) { + const option = serverFilterOptionFromRow(row, columnIndex); + const current = byKey.get(option.key); + if (current) { + current.count = (current.count ?? 0) + (option.count ?? 0); + } else { + byKey.set(option.key, option); + } + } + return [...byKey.values()]; +} + +function syncServerFilterDraft(columnIndex: number, options: LocalFilterOption[]) { + const draft = localFilterDraft.value; + if (!draft || draft.mode !== "server" || draft.columnIndex !== columnIndex) return; + if (draft.touched) return; + localFilterDraft.value = { + ...draft, + values: new Set(options.map((option) => option.key)), + }; +} + +async function loadServerFilterValues(columnIndex: number, searchValue: string) { + if (!canUseServerColumnFilter.value || !props.connectionId) return; + const columnName = props.result.columns[columnIndex]; + if (!columnName) return; + const requestId = ++serverFilterRequestId; + serverFilterLoading.value = true; + serverFilterError.value = ""; + serverFilterLimited.value = false; + try { + const tableMeta = await waitForTableMeta(); + if (!tableMeta) return; + const columnInfo = tableMeta.columns.find((column) => column.name === columnName); + const sql = await buildDataGridColumnDistinctValuesSql({ + databaseType: resolvedDatabaseType.value, + schema: tableMeta.schema, + tableName: tableMeta.tableName, + columnName, + columnInfo, + whereInput: currentWhereInput(), + searchValue: searchValue.trim() || undefined, + limit: SERVER_COLUMN_FILTER_LIMIT, + includeCounts: true, + }); + const result = await api.executeQuery(props.connectionId, props.database ?? "", sql, tableMeta.schema ?? props.schema, undefined, { + maxRows: SERVER_COLUMN_FILTER_LIMIT, + fetchSize: SERVER_COLUMN_FILTER_LIMIT, + pageSize: SERVER_COLUMN_FILTER_LIMIT, + }); + if (requestId !== serverFilterRequestId || localFilterOpenColumn.value !== columnIndex) return; + const options = serverFilterOptionsFromResult(result, columnIndex); + const nextValueByKey = new Map(serverFilterValueByKey.value); + for (const option of options) nextValueByKey.set(option.key, option.value); + serverFilterValueByKey.value = nextValueByKey; + serverFilterOptions.value = options; + serverFilterLimited.value = result.truncated === true || result.rows.length >= SERVER_COLUMN_FILTER_LIMIT; + syncServerFilterDraft(columnIndex, options); + } catch (e: any) { + if (requestId !== serverFilterRequestId) return; + serverFilterOptions.value = []; + serverFilterError.value = String(e?.message || e); + } finally { + if (requestId === serverFilterRequestId) { + serverFilterLoading.value = false; + } + } } function guardHeaderPanelDismiss() { @@ -713,13 +839,13 @@ function shouldIgnoreHeaderPanelClose(columnIndex: number, openColumn: number | return compactColumnHeaderActions.value && openColumn === columnIndex && Date.now() < headerPanelDismissGuardUntil.value; } -function openCompactLocalFilter(colIdx: number) { +function openCompactLocalFilter(colIdx: number, mode: LocalFilterMode = "local") { headerActionMenuOpenColumn.value = null; guardHeaderPanelDismiss(); nextTick(() => { window.setTimeout(() => { guardHeaderPanelDismiss(); - openLocalFilter(colIdx); + openLocalFilter(colIdx, mode); }, 0); }); } @@ -737,20 +863,35 @@ function compactColumnActionMenuItems(columnName: string) { value: "localFilter", icon: Filter, }, + ...(canUseServerColumnFilter.value + ? [ + { + label: t("grid.databaseValueFilter"), + value: "serverFilter", + icon: Database, + }, + ] + : []), ]; } +function columnFilterPanelTitle(columnName: string): string { + return localFilterDraft.value?.mode === "server" ? t("grid.databaseValueFilterFor", { column: columnName }) : t("grid.localFilterFor", { column: columnName }); +} + function selectCompactColumnAction(value: string, columnIndex: number) { if (value === "formatter") { openCompactColumnFormatter(columnIndex); } else if (value === "localFilter") { - openCompactLocalFilter(columnIndex); + openCompactLocalFilter(columnIndex, "local"); + } else if (value === "serverFilter") { + openCompactLocalFilter(columnIndex, "server"); } } function handleLocalFilterOpenChange(value: boolean, columnIndex: number) { if (value) { - openLocalFilter(columnIndex); + openLocalFilter(columnIndex, "local"); } else if (!shouldIgnoreHeaderPanelClose(columnIndex, localFilterOpenColumn.value)) { closeLocalFilter(); } @@ -760,6 +901,7 @@ function closeLocalFilter() { localFilterOpenColumn.value = null; localFilterDraft.value = null; localFilterSearch.value = ""; + resetServerFilterState(); } function formatterKeyForColumn(column: string): string | null { @@ -924,7 +1066,7 @@ function toggleLocalFilterValue(key: string) { const next = new Set(draft.values); if (next.has(key)) next.delete(key); else next.add(key); - localFilterDraft.value = { ...draft, values: next }; + localFilterDraft.value = { ...draft, values: next, touched: true }; } function toggleAllLocalFilterOptions() { @@ -937,12 +1079,16 @@ function toggleAllLocalFilterOptions() { } else { visibleKeys.forEach((key) => next.add(key)); } - localFilterDraft.value = { ...draft, values: next }; + localFilterDraft.value = { ...draft, values: next, touched: true }; } async function applyLocalFilter() { const draft = localFilterDraft.value; if (!draft) return; + if (draft.mode === "server") { + await applyServerColumnFilter(draft); + return; + } if (canApplyTypedLocalFilterValue.value && localFilterDraftIsAllSelected.value && localFilterOptions.value.length === 0) { await applyTypedLocalFilterValue(); return; @@ -964,6 +1110,40 @@ async function applyLocalFilter() { resetGridVerticalScroll(); } +async function applyServerColumnFilter(draft: LocalColumnFilterDraft) { + if (!draft.touched && !localFilterSearch.value.trim()) { + closeLocalFilter(); + return; + } + if (canApplyTypedLocalFilterValue.value && serverFilterOptions.value.length === 0) { + await applyTypedLocalFilterValue(); + return; + } + const columnName = props.result.columns[draft.columnIndex]; + if (!columnName) return; + const values = [...draft.values].flatMap((key) => { + if (!serverFilterValueByKey.value.has(key)) return []; + return [serverFilterValueByKey.value.get(key)!]; + }); + if (values.length === 0) { + closeLocalFilter(); + return; + } + const condition = await buildColumnValuesFilterCondition({ + databaseType: resolvedDatabaseType.value, + columnName, + columnInfo: props.tableMeta?.columns.find((column) => column.name === columnName), + values, + }); + if (!condition) return; + const next = { ...localColumnFilters.value }; + delete next[draft.columnIndex]; + localColumnFilters.value = next; + whereFilterInput.value = appendColumnValueFilterCondition(whereFilterInput.value, condition); + closeLocalFilter(); + await applyWhereFilter(); +} + async function applyTypedLocalFilterValue() { const draft = localFilterDraft.value; if (!draft) return; @@ -996,6 +1176,17 @@ function clearLocalFilter(colIdx?: number) { resetGridVerticalScroll(); } +watch(localFilterSearch, (value) => { + const draft = localFilterDraft.value; + if (!draft || draft.mode !== "server" || localFilterOpenColumn.value !== draft.columnIndex) return; + if (serverFilterSearchTimer !== undefined) { + window.clearTimeout(serverFilterSearchTimer); + } + serverFilterSearchTimer = window.setTimeout(() => { + void loadServerFilterValues(draft.columnIndex, value); + }, SERVER_COLUMN_FILTER_DEBOUNCE_MS); +}); + function defaultStructuredFilterRule(): StructuredFilterRule { return { id: uuid(), @@ -2506,6 +2697,7 @@ const queryEditReadyTargetLabel = computed(() => props.tableMeta?.tableName ?? p const showKeylessEditWarning = computed(() => !!props.editable && !!props.tableMeta && canUseKeylessRowPredicate(props.databaseType, props.tableMeta.primaryKeys ?? [])); const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value); const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value); +const canUseServerColumnFilter = computed(() => canUseWhereSearch.value && !!props.connectionId && !!props.tableMeta); type DataGridTableMeta = NonNullable; const hiveTableTransactional = ref(undefined); const canEditExistingRows = computed(() => !!props.customSaveHandler || canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? [])); @@ -6637,6 +6829,9 @@ onUnmounted(() => { finishCellSelection(); clearTimeout(highlightedColumnTimer); clearTimeout(_searchTimer); + if (serverFilterSearchTimer !== undefined) { + window.clearTimeout(serverFilterSearchTimer); + } clearInterval(_loadingTimer); }); @@ -7842,7 +8037,7 @@ const gridContextMenuItems = computed(() => {
- {{ t("grid.localFilterFor", { column: col.name }) }} + {{ columnFilterPanelTitle(col.name) }}
@@ -7855,6 +8050,14 @@ const gridContextMenuItems = computed(() => { {{ t("grid.value") }} {{ t("grid.count") }}
+
+ + + + + + +
-
+
{{ t("grid.moreValues", { count: localFilterAllOptions.length - localFilterOptions.length, @@ -7878,7 +8081,7 @@ const gridContextMenuItems = computed(() => { {{ t("grid.filterTypedValue", { value: localFilterTypedValue }) }} -
+
{{ t("grid.noSearchResults") }}
@@ -7897,6 +8100,16 @@ const gridContextMenuItems = computed(() => {
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 824e05ecf..471c1a83f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -622,8 +622,10 @@ export default { filterBuilderIsNull: "Is NULL", filterBuilderIsNotNull: "Is not NULL", columnActions: "Column actions", - localFilter: "Local column filter", - localFilterFor: "Local Filter For '{column}'", + localFilter: "Local value filter", + localFilterFor: "Local Values For '{column}'", + databaseValueFilter: "Database value filter", + databaseValueFilterFor: "Database Values For '{column}'", columnFormatter: "Column formatter", columnFormatterFor: "Formatter for '{column}'", columnFormatterHint: "Formats display only; raw values stay unchanged.", @@ -649,13 +651,15 @@ export default { saveFormatter: "Save", clearFormatter: "Clear", searchValues: "Search values...", + loadingValues: "Loading values...", + serverValuesLimited: "Showing first {count} values", filterTypedValue: "Use '{value}'", value: "Value", count: "Count", applyFilter: "Apply Filter", - clearLocalFilters: "Clear local column filters", + clearLocalFilters: "Clear local value filters", clearLocalFiltersShort: "Clear local", - localFiltersActive: "{count} local column filters", + localFiltersActive: "{count} local value filters", localFilterMoreValues: " +{count} more", columnVisibility: "Columns", columnVisibilityHint: "At least one column stays visible.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 9295be2a2..b28c06f46 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -624,8 +624,10 @@ export default withEnglishFallback({ filterBuilderIsNull: "Es NULL", filterBuilderIsNotNull: "No es NULL", columnActions: "Acciones de columna", - localFilter: "Filtro local de columna", - localFilterFor: "Filtro local para '{column}'", + localFilter: "Filtro local de valores", + localFilterFor: "Valores locales para '{column}'", + databaseValueFilter: "Filtro de valores de base de datos", + databaseValueFilterFor: "Valores de base de datos para '{column}'", columnFormatter: "Formateador de columna", columnFormatterFor: "Formateador para '{column}'", columnFormatterHint: "Solo formatea la visualización; los valores sin procesar no cambian.", @@ -655,9 +657,9 @@ export default withEnglishFallback({ value: "Valor", count: "Conteo", applyFilter: "Aplicar filtro", - clearLocalFilters: "Limpiar filtros locales de columnas", + clearLocalFilters: "Limpiar filtros locales de valores", clearLocalFiltersShort: "Limpiar local", - localFiltersActive: "{count} filtros locales de columna", + localFiltersActive: "{count} filtros locales de valores", localFilterMoreValues: " +{count} más", columnVisibility: "Columnas", columnVisibilityHint: "Al menos una columna permanece visible.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index dbeecf8e9..b6d3f76ba 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -631,8 +631,10 @@ export default withEnglishFallback({ filterBuilderIsNull: "È NULL", filterBuilderIsNotNull: "Non è NULL", columnActions: "Azioni colonna", - localFilter: "Filtro colonna locale", - localFilterFor: "Filtro Locale Per '{column}'", + localFilter: "Filtro valori locale", + localFilterFor: "Valori locali per '{column}'", + databaseValueFilter: "Filtro valori database", + databaseValueFilterFor: "Valori database per '{column}'", columnFormatter: "Formattatore colonna", columnFormatterFor: "Formattatore per '{column}'", columnFormatterHint: "Formatta solo la visualizzazione; i valori effettivi rimangono invariati.", @@ -662,9 +664,9 @@ export default withEnglishFallback({ value: "Valore", count: "Conteggio", applyFilter: "Applica Filtro", - clearLocalFilters: "Cancella filtri colonna locali", + clearLocalFilters: "Cancella filtri valori locali", clearLocalFiltersShort: "Cancella locali", - localFiltersActive: "{count} filtri colonna locali", + localFiltersActive: "{count} filtri valori locali", localFilterMoreValues: " +{count} altri", columnVisibility: "Colonne", columnVisibilityHint: "Almeno una colonna deve rimanere visibile.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index cef92cea4..7397ec95b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -620,8 +620,10 @@ export default withEnglishFallback({ filterBuilderIsNull: "NULLである", filterBuilderIsNotNull: "NULLでない", columnActions: "列の操作", - localFilter: "ローカル列フィルター", - localFilterFor: "'{column}'のローカルフィルター", + localFilter: "ローカル値フィルター", + localFilterFor: "'{column}'のローカル値", + databaseValueFilter: "データベース値フィルター", + databaseValueFilterFor: "'{column}'のデータベース値", columnFormatter: "列フォーマッター", columnFormatterFor: "'{column}'のフォーマッター", columnFormatterHint: "表示のみをフォーマットし、実際の値は変更されません。", @@ -647,13 +649,15 @@ export default withEnglishFallback({ saveFormatter: "保存", clearFormatter: "クリア", searchValues: "値を検索...", + loadingValues: "値を読み込み中...", + serverValuesLimited: "最初の{count}件の値を表示中", filterTypedValue: "'{value}'を使用", value: "値", count: "件数", applyFilter: "フィルターを適用", - clearLocalFilters: "ローカル列フィルターをクリア", + clearLocalFilters: "ローカル値フィルターをクリア", clearLocalFiltersShort: "ローカルをクリア", - localFiltersActive: "{count}件のローカル列フィルター", + localFiltersActive: "{count}件のローカル値フィルター", localFilterMoreValues: " +{count}件以上", columnVisibility: "列", columnVisibilityHint: "少なくとも1列は表示されます。", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 04902ff85..d2b37cf62 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -636,8 +636,10 @@ export default withEnglishFallback({ filterBuilderIsNull: "É NULL", filterBuilderIsNotNull: "Não é NULL", columnActions: "Ações da coluna", - localFilter: "Filtro local de coluna", - localFilterFor: "Filtro Local Para '{column}'", + localFilter: "Filtro local de valores", + localFilterFor: "Valores locais para '{column}'", + databaseValueFilter: "Filtro de valores do banco de dados", + databaseValueFilterFor: "Valores do banco de dados para '{column}'", columnFormatter: "Formatador de coluna", columnFormatterFor: "Formatador para '{column}'", columnFormatterHint: "Formata apenas a exibição; os valores brutos permanecem inalterados.", @@ -667,9 +669,9 @@ export default withEnglishFallback({ value: "Valor", count: "Contagem", applyFilter: "Aplicar Filtro", - clearLocalFilters: "Limpar filtros locais de coluna", + clearLocalFilters: "Limpar filtros locais de valores", clearLocalFiltersShort: "Limpar local", - localFiltersActive: "{count} filtros locais de coluna", + localFiltersActive: "{count} filtros locais de valores", localFilterMoreValues: " +{count} mais", columnVisibility: "Colunas", columnVisibilityHint: "Pelo menos uma coluna permanece visível.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index bfc5e6da7..8e95c3eaf 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -625,8 +625,10 @@ export default withEnglishFallback({ filterBuilderIsNull: "为空", filterBuilderIsNotNull: "不为空", columnActions: "字段操作", - localFilter: "本地列筛选", - localFilterFor: "本地筛选“{column}”", + localFilter: "本地值筛选", + localFilterFor: "本地值筛选“{column}”", + databaseValueFilter: "数据库值筛选", + databaseValueFilterFor: "数据库值筛选“{column}”", columnFormatter: "列格式化", columnFormatterFor: "格式化“{column}”", columnFormatterHint: "仅改变显示效果,原始值保持不变。", @@ -652,13 +654,15 @@ export default withEnglishFallback({ saveFormatter: "保存", clearFormatter: "清除", searchValues: "搜索值...", + loadingValues: "正在加载值...", + serverValuesLimited: "仅显示前 {count} 个值", filterTypedValue: "使用“{value}”筛选", value: "值", count: "数量", applyFilter: "应用筛选", - clearLocalFilters: "清除本地列筛选", + clearLocalFilters: "清除本地值筛选", clearLocalFiltersShort: "清除本地", - localFiltersActive: "{count} 个本地列筛选", + localFiltersActive: "{count} 个本地值筛选", localFilterMoreValues: ",还有 {count} 个", columnVisibility: "字段筛选", columnVisibilityHint: "至少保留一列可见。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2c1002159..bbeb5eab6 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -624,8 +624,10 @@ export default withEnglishFallback({ filterBuilderIsNull: "為空", filterBuilderIsNotNull: "不為空", columnActions: "欄位操作", - localFilter: "本機欄位篩選", - localFilterFor: "本機篩選「{column}」", + localFilter: "本機值篩選", + localFilterFor: "本機值篩選「{column}」", + databaseValueFilter: "資料庫值篩選", + databaseValueFilterFor: "資料庫值篩選「{column}」", columnFormatter: "欄位格式設定", columnFormatterFor: "格式化「{column}」", columnFormatterHint: "僅改變顯示效果,原始值保持不變。", @@ -655,9 +657,9 @@ export default withEnglishFallback({ value: "值", count: "數量", applyFilter: "套用篩選", - clearLocalFilters: "清除本機欄位篩選", + clearLocalFilters: "清除本機值篩選", clearLocalFiltersShort: "清除本機", - localFiltersActive: "{count} 個本機欄位篩選", + localFiltersActive: "{count} 個本機值篩選", localFilterMoreValues: ",還有 {count} 個", columnVisibility: "欄位篩選", columnVisibilityHint: "至少保留一欄可見。", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 4b76d4156..fa539e6f6 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -181,6 +181,8 @@ export const buildDataGridCopyUpdateStatements = forward("buildDataGridCopyUpdat export const buildDataGridCopyInsertStatement = forward("buildDataGridCopyInsertStatement"); export const buildDataGridContextFilterCondition = forward("buildDataGridContextFilterCondition"); export const buildDataGridColumnValueFilterCondition = forward("buildDataGridColumnValueFilterCondition"); +export const buildDataGridColumnValuesFilterCondition = forward("buildDataGridColumnValuesFilterCondition"); +export const buildDataGridColumnDistinctValuesSql = forward("buildDataGridColumnDistinctValuesSql"); export const buildDataGridCountSql = forward("buildDataGridCountSql"); export const buildHiveTablePropertiesSql = forward("buildHiveTablePropertiesSql"); export const buildExportInsertStatements = forward("buildExportInsertStatements"); diff --git a/apps/desktop/src/lib/dataGridColumnFilter.ts b/apps/desktop/src/lib/dataGridColumnFilter.ts index 5d3ee0db4..9077d03cc 100644 --- a/apps/desktop/src/lib/dataGridColumnFilter.ts +++ b/apps/desktop/src/lib/dataGridColumnFilter.ts @@ -1,6 +1,6 @@ import type { ColumnInfo, DatabaseType } from "@/types/database"; import type { DataGridColumnInfo, DataGridContextFilterMode, GridCellValue } from "@/lib/dataGridSql"; -import { buildDataGridColumnValueFilterCondition } from "@/lib/dataGridSql"; +import { buildDataGridColumnValueFilterCondition, buildDataGridColumnValuesFilterCondition } from "@/lib/dataGridSql"; import { normalizeWhereInput } from "@/lib/tableSelectSql"; export function buildColumnValueFilterCondition(options: { databaseType?: DatabaseType; columnName: string; columnInfo?: Pick; rawValue: string }): Promise { @@ -18,6 +18,21 @@ export function buildColumnValueFilterCondition(options: { databaseType?: Databa }); } +export function buildColumnValuesFilterCondition(options: { databaseType?: DatabaseType; columnName: string; columnInfo?: Pick; values: GridCellValue[] }): Promise { + return buildDataGridColumnValuesFilterCondition({ + databaseType: options.databaseType, + columnName: options.columnName, + columnInfo: options.columnInfo + ? { + name: options.columnName, + data_type: options.columnInfo.data_type, + is_nullable: true, + } + : undefined, + values: options.values, + }); +} + export function appendColumnValueFilterCondition(whereInput: string | undefined, condition: string | undefined): string { if (!condition) return normalizeWhereInput(whereInput); const existing = normalizeWhereInput(whereInput); diff --git a/apps/desktop/src/lib/dataGridSql.ts b/apps/desktop/src/lib/dataGridSql.ts index cf66ac324..b6a4748ea 100644 --- a/apps/desktop/src/lib/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGridSql.ts @@ -64,6 +64,25 @@ export interface DataGridColumnValueFilterConditionOptions { rawValue: string; } +export interface DataGridColumnValuesFilterConditionOptions { + databaseType?: DatabaseType; + columnName: string; + columnInfo?: DataGridColumnInfo; + values: GridCellValue[]; +} + +export interface DataGridColumnDistinctValuesSqlOptions { + databaseType?: DatabaseType; + schema?: string; + tableName: string; + columnName: string; + columnInfo?: DataGridColumnInfo; + whereInput?: string; + searchValue?: string; + limit?: number; + includeCounts?: boolean; +} + export interface DataGridCountSqlOptions { databaseType?: DatabaseType; schema?: string; @@ -93,6 +112,14 @@ export function buildDataGridColumnValueFilterCondition(options: DataGridColumnV return api.buildDataGridColumnValueFilterCondition(options); } +export function buildDataGridColumnValuesFilterCondition(options: DataGridColumnValuesFilterConditionOptions): Promise { + return api.buildDataGridColumnValuesFilterCondition(options); +} + +export function buildDataGridColumnDistinctValuesSql(options: DataGridColumnDistinctValuesSqlOptions): Promise { + return api.buildDataGridColumnDistinctValuesSql(options); +} + export function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise { return api.buildDataGridCountSql(options); } diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 4502d2eac..f22365bb4 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -90,7 +90,17 @@ import type { DroppedFilePreviewSqlOptions, } from "./tauri"; import type { QueryEditability } from "@/lib/sqlAnalysis"; -import type { DataGridColumnValueFilterConditionOptions, DataGridContextFilterConditionOptions, DataGridCountSqlOptions, DataGridCopyInsertStatementOptions, DataGridCopyUpdateStatementOptions, DataGridSaveStatementOptions, HiveTablePropertiesSqlOptions } from "@/lib/dataGridSql"; +import type { + DataGridColumnDistinctValuesSqlOptions, + DataGridColumnValueFilterConditionOptions, + DataGridColumnValuesFilterConditionOptions, + DataGridContextFilterConditionOptions, + DataGridCountSqlOptions, + DataGridCopyInsertStatementOptions, + DataGridCopyUpdateStatementOptions, + DataGridSaveStatementOptions, + HiveTablePropertiesSqlOptions, +} from "@/lib/dataGridSql"; import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, TableStructureChangeSql } from "@/lib/tableStructureEditorSql"; import type { BuildTableSelectSqlOptions } from "@/lib/tableSelectSql"; import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/databaseSearch"; @@ -808,6 +818,15 @@ export async function buildDataGridColumnValueFilterCondition(options: DataGridC return result ?? undefined; } +export async function buildDataGridColumnValuesFilterCondition(options: DataGridColumnValuesFilterConditionOptions): Promise { + const result = await post("/api/query/build-data-grid-column-values-filter-condition", { options }); + return result ?? undefined; +} + +export async function buildDataGridColumnDistinctValuesSql(options: DataGridColumnDistinctValuesSqlOptions): Promise { + return post("/api/query/build-data-grid-column-distinct-values-sql", { options }); +} + export async function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise { return post("/api/query/build-data-grid-count-sql", { options }); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index f9aa02499..f16ce4556 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -35,7 +35,17 @@ import type { CollectionInfo } from "@/types/database"; import type { SidebarObjectKind } from "@/lib/databaseObjectCapabilities"; import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore"; import type { QueryEditability } from "@/lib/sqlAnalysis"; -import type { DataGridColumnValueFilterConditionOptions, DataGridContextFilterConditionOptions, DataGridCountSqlOptions, DataGridCopyInsertStatementOptions, DataGridCopyUpdateStatementOptions, DataGridSaveStatementOptions, HiveTablePropertiesSqlOptions } from "@/lib/dataGridSql"; +import type { + DataGridColumnDistinctValuesSqlOptions, + DataGridColumnValueFilterConditionOptions, + DataGridColumnValuesFilterConditionOptions, + DataGridContextFilterConditionOptions, + DataGridCountSqlOptions, + DataGridCopyInsertStatementOptions, + DataGridCopyUpdateStatementOptions, + DataGridSaveStatementOptions, + HiveTablePropertiesSqlOptions, +} from "@/lib/dataGridSql"; import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataCompare"; import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schemaDiff"; import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, TableStructureChangeSql } from "@/lib/tableStructureEditorSql"; @@ -802,6 +812,15 @@ export async function buildDataGridColumnValueFilterCondition(options: DataGridC return result ?? undefined; } +export async function buildDataGridColumnValuesFilterCondition(options: DataGridColumnValuesFilterConditionOptions): Promise { + const result = await invoke("build_data_grid_column_values_filter_condition", { options }); + return result ?? undefined; +} + +export async function buildDataGridColumnDistinctValuesSql(options: DataGridColumnDistinctValuesSqlOptions): Promise { + return invoke("build_data_grid_column_distinct_values_sql", { options }); +} + export async function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise { return invoke("build_data_grid_count_sql", { options }); } diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index be8d11204..42d9cc41f 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -11,12 +11,14 @@ mod data_grid_tdengine_sql; use data_grid_tdengine_sql::build_tdengine_data_grid_save_statements; use crate::models::connection::DatabaseType; -use crate::sql_dialect::quote_table_identifier; +use crate::sql_dialect::{quote_table_identifier, table_pagination_strategy, TablePaginationStrategy}; use crate::transfer::{format_ch_array_sql_literal, format_pg_array_sql_literal}; const DBX_ROWID_COLUMN: &str = "__DBX_ROWID"; pub(crate) const DBX_NEO4J_ELEMENT_ID_COLUMN: &str = "__DBX_ELEMENT_ID"; pub(crate) const DBX_TDENGINE_TBNAME_COLUMN: &str = "tbname"; +const DATA_GRID_COLUMN_DISTINCT_VALUES_DEFAULT_LIMIT: usize = 1000; +const DATA_GRID_COLUMN_DISTINCT_VALUES_MAX_LIMIT: usize = 1000; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -129,6 +131,39 @@ pub struct DataGridColumnValueFilterConditionOptions { pub raw_value: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataGridColumnValuesFilterConditionOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_type: Option, + pub column_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column_info: Option, + #[serde(default)] + pub values: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataGridColumnDistinctValuesSqlOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub table_name: String, + pub column_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub column_info: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub where_input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default)] + pub include_counts: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DataGridCountSqlOptions { @@ -387,6 +422,96 @@ pub fn build_data_grid_column_value_filter_condition( Some(format!("{column} = {}", format_grid_sql_literal(&value, options.database_type, options.column_info.as_ref()))) } +pub fn build_data_grid_column_values_filter_condition( + options: DataGridColumnValuesFilterConditionOptions, +) -> Option { + if options.values.is_empty() { + return None; + } + + let column = column_filter_ref(options.database_type, &options.column_name); + let mut has_null = false; + let mut literals = Vec::new(); + for value in &options.values { + if value.is_null() { + has_null = true; + continue; + } + let literal = format_grid_sql_literal(value, options.database_type, options.column_info.as_ref()); + if !literals.contains(&literal) { + literals.push(literal); + } + } + + let mut predicates = Vec::new(); + if has_null { + predicates.push(format!("{column} IS NULL")); + } + if !literals.is_empty() { + let list = literals.join(", "); + if literals.len() == 1 { + predicates.push(format!("{column} = {list}")); + } else if options.database_type == Some(DatabaseType::Neo4j) { + predicates.push(format!("{column} IN [{list}]")); + } else { + predicates.push(format!("{column} IN ({list})")); + } + } + + match predicates.len() { + 0 => None, + 1 => predicates.into_iter().next(), + _ => Some(format!("({})", predicates.join(" OR "))), + } +} + +pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinctValuesSqlOptions) -> String { + if options.database_type == Some(DatabaseType::Neo4j) { + return build_neo4j_data_grid_column_distinct_values_sql(&options); + } + + let limit = data_grid_column_distinct_values_limit(options.limit); + let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name); + let column = column_filter_ref(options.database_type, &options.column_name); + let mut predicates = Vec::new(); + let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref()); + if !predicate.is_empty() { + predicates.push(format!("({predicate})")); + } + if let Some(search_predicate) = data_grid_column_distinct_values_search_predicate(&options) { + predicates.push(search_predicate); + } + let where_clause = + if predicates.is_empty() { String::new() } else { format!(" WHERE {}", predicates.join(" AND ")) }; + let select_list = if options.include_counts { + format!("{column} AS dbx_value, COUNT(*) AS dbx_count") + } else { + format!("{column} AS dbx_value") + }; + let group_by = format!(" GROUP BY {column}"); + let order_by = if options.include_counts { " ORDER BY dbx_count DESC, dbx_value" } else { " ORDER BY dbx_value" }; + let from_clause = format!(" FROM {table}{where_clause}{group_by}{order_by}"); + + match table_pagination_strategy(options.database_type) { + TablePaginationStrategy::SqlServerTop => format!("SELECT TOP ({limit}) {select_list}{from_clause}"), + TablePaginationStrategy::IrisTop => format!("SELECT TOP {limit} {select_list}{from_clause}"), + TablePaginationStrategy::InformixFirst => format!("SELECT FIRST {limit} {select_list}{from_clause}"), + TablePaginationStrategy::Db2FetchFirst | TablePaginationStrategy::FetchFirst => { + format!("SELECT {select_list}{from_clause} FETCH FIRST {limit} ROWS ONLY") + } + TablePaginationStrategy::Rownum => { + let inner = format!("SELECT {select_list}{from_clause}"); + format!("SELECT * FROM ({inner}) WHERE ROWNUM <= {limit}") + } + TablePaginationStrategy::AgentMaxRows | TablePaginationStrategy::Unbounded => { + format!("SELECT {select_list}{from_clause}") + } + TablePaginationStrategy::QuestDbLimit | TablePaginationStrategy::LimitOffset => { + format!("SELECT {select_list}{from_clause} LIMIT {limit}") + } + } +} + pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String { let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name); let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref()); @@ -400,6 +525,60 @@ pub fn build_hive_table_properties_sql(options: HiveTablePropertiesSqlOptions) - format!("SHOW TBLPROPERTIES {table} ('{property}')") } +fn data_grid_column_distinct_values_limit(limit: Option) -> usize { + limit.unwrap_or(DATA_GRID_COLUMN_DISTINCT_VALUES_DEFAULT_LIMIT).clamp(1, DATA_GRID_COLUMN_DISTINCT_VALUES_MAX_LIMIT) +} + +fn data_grid_column_distinct_values_search_predicate( + options: &DataGridColumnDistinctValuesSqlOptions, +) -> Option { + let search = options.search_value.as_deref()?.trim(); + if search.is_empty() { + return None; + } + if !options.column_info.as_ref().map(|column| is_textual_column_type(&column.data_type)).unwrap_or(true) + && !is_postgres_like_pattern_database(options.database_type) + { + let column = column_filter_ref(options.database_type, &options.column_name); + let value = parse_typed_filter_value(search, options.column_info.as_ref()); + return Some(format!( + "{column} = {}", + format_grid_sql_literal(&value, options.database_type, options.column_info.as_ref()) + )); + } + let column = column_like_filter_ref(options.database_type, &options.column_name, options.column_info.as_ref()); + let pattern = Value::String(format!("%{search}%")); + Some(format!("{column} LIKE {}", format_grid_sql_literal(&pattern, options.database_type, None))) +} + +fn build_neo4j_data_grid_column_distinct_values_sql(options: &DataGridColumnDistinctValuesSqlOptions) -> String { + let limit = data_grid_column_distinct_values_limit(options.limit); + let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_name); + let column = column_filter_ref(Some(DatabaseType::Neo4j), &options.column_name); + let mut predicates = Vec::new(); + let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref()); + if !predicate.is_empty() { + predicates.push(predicate); + } + if let Some(search) = options.search_value.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + predicates.push(format!( + "toString({column}) CONTAINS {}", + format_grid_sql_literal(&Value::String(search.to_string()), Some(DatabaseType::Neo4j), None) + )); + } + let where_clause = + if predicates.is_empty() { String::new() } else { format!(" WHERE {}", predicates.join(" AND ")) }; + if options.include_counts { + format!( + "MATCH (n:{label}){where_clause} RETURN {column} AS dbx_value, count(*) AS dbx_count ORDER BY dbx_count DESC, dbx_value LIMIT {limit}" + ) + } else { + format!( + "MATCH (n:{label}){where_clause} RETURN DISTINCT {column} AS dbx_value ORDER BY dbx_value LIMIT {limit}" + ) + } +} + fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option { if let Some(error) = validate_inserted_primary_keys(options) { return Some(error); @@ -1856,6 +2035,90 @@ mod tests { ); } + #[test] + fn builds_multi_value_filter_conditions() { + assert_eq!( + build_data_grid_column_values_filter_condition(DataGridColumnValuesFilterConditionOptions { + database_type: Some(DatabaseType::Postgres), + column_name: "status".to_string(), + column_info: Some(column("status", "varchar", true, None)), + values: vec![json!("active"), json!("pending"), Value::Null, json!("active")], + }) + .as_deref(), + Some("(\"status\" IS NULL OR \"status\" IN ('active', 'pending'))") + ); + assert_eq!( + build_data_grid_column_values_filter_condition(DataGridColumnValuesFilterConditionOptions { + database_type: Some(DatabaseType::Mysql), + column_name: "id".to_string(), + column_info: Some(column("id", "int", false, None)), + values: vec![json!(42)], + }) + .as_deref(), + Some("`id` = 42") + ); + } + + #[test] + fn builds_column_distinct_values_sql() { + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::Postgres), + schema: Some("public".to_string()), + table_name: "users".to_string(), + column_name: "status".to_string(), + column_info: Some(column("status", "varchar", true, None)), + where_input: Some("WHERE deleted_at IS NULL;".to_string()), + search_value: Some("act".to_string()), + limit: None, + include_counts: true, + }), + "SELECT \"status\" AS dbx_value, COUNT(*) AS dbx_count FROM \"public\".\"users\" WHERE (deleted_at IS NULL) AND \"status\" LIKE '%act%' GROUP BY \"status\" ORDER BY dbx_count DESC, dbx_value LIMIT 1000" + ); + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::SqlServer), + schema: None, + table_name: "users".to_string(), + column_name: "status".to_string(), + column_info: Some(column("status", "nvarchar", true, None)), + where_input: None, + search_value: None, + limit: Some(25), + include_counts: false, + }), + "SELECT TOP (25) [status] AS dbx_value FROM [users] GROUP BY [status] ORDER BY dbx_value" + ); + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::SqlServer), + schema: None, + table_name: "users".to_string(), + column_name: "id".to_string(), + column_info: Some(column("id", "int", false, None)), + where_input: None, + search_value: Some("42".to_string()), + limit: Some(25), + include_counts: true, + }), + "SELECT TOP (25) [id] AS dbx_value, COUNT(*) AS dbx_count FROM [users] WHERE [id] = 42 GROUP BY [id] ORDER BY dbx_count DESC, dbx_value" + ); + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::Oracle), + schema: Some("APP".to_string()), + table_name: "EVENTS".to_string(), + column_name: "KIND".to_string(), + column_info: Some(column("KIND", "VARCHAR2", true, None)), + where_input: None, + search_value: None, + limit: Some(10), + include_counts: true, + }), + "SELECT * FROM (SELECT \"KIND\" AS dbx_value, COUNT(*) AS dbx_count FROM \"APP\".\"EVENTS\" GROUP BY \"KIND\" ORDER BY dbx_count DESC, dbx_value) WHERE ROWNUM <= 10" + ); + } + #[test] fn builds_grid_count_sql() { assert_eq!( diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index dcbe1a235..236b35c89 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -290,6 +290,14 @@ async fn main() { "/query/build-data-grid-column-value-filter-condition", post(routes::query::build_data_grid_column_value_filter_condition), ) + .route( + "/query/build-data-grid-column-values-filter-condition", + post(routes::query::build_data_grid_column_values_filter_condition), + ) + .route( + "/query/build-data-grid-column-distinct-values-sql", + post(routes::query::build_data_grid_column_distinct_values_sql), + ) .route("/query/build-data-grid-count-sql", post(routes::query::build_data_grid_count_sql)) .route("/query/build-hive-table-properties-sql", post(routes::query::build_hive_table_properties_sql)) .route("/query/build-export-insert-statements", post(routes::query::build_export_insert_statements)) diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index fbde51303..0032e3975 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -234,6 +234,18 @@ pub struct BuildDataGridColumnValueFilterConditionRequest { pub options: dbx_core::data_grid_sql::DataGridColumnValueFilterConditionOptions, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildDataGridColumnValuesFilterConditionRequest { + pub options: dbx_core::data_grid_sql::DataGridColumnValuesFilterConditionOptions, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildDataGridColumnDistinctValuesSqlRequest { + pub options: dbx_core::data_grid_sql::DataGridColumnDistinctValuesSqlOptions, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildDataGridCountSqlRequest { @@ -690,6 +702,18 @@ pub async fn build_data_grid_column_value_filter_condition( Json(dbx_core::data_grid_sql::build_data_grid_column_value_filter_condition(req.options)) } +pub async fn build_data_grid_column_values_filter_condition( + Json(req): Json, +) -> Json> { + Json(dbx_core::data_grid_sql::build_data_grid_column_values_filter_condition(req.options)) +} + +pub async fn build_data_grid_column_distinct_values_sql( + Json(req): Json, +) -> Json { + Json(dbx_core::data_grid_sql::build_data_grid_column_distinct_values_sql(req.options)) +} + pub async fn build_data_grid_count_sql(Json(req): Json) -> Json { Json(dbx_core::data_grid_sql::build_data_grid_count_sql(req.options)) } diff --git a/packages/app-tests/dataGridColumnFilter.test.ts b/packages/app-tests/dataGridColumnFilter.test.ts index d997ada30..73ce6aef3 100644 --- a/packages/app-tests/dataGridColumnFilter.test.ts +++ b/packages/app-tests/dataGridColumnFilter.test.ts @@ -1,9 +1,20 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { appendColumnValueFilterCondition, buildColumnValueFilterCondition } from "../../apps/desktop/src/lib/dataGridColumnFilter.ts"; +import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, buildColumnValuesFilterCondition } from "../../apps/desktop/src/lib/dataGridColumnFilter.ts"; function installFilterFetchMock() { globalThis.fetch = (async (input, init) => { + if (String(input) === "/api/query/build-data-grid-column-values-filter-condition") { + const body = JSON.parse(String(init?.body ?? "{}")); + const options = body.options; + const quote = options.databaseType === "mysql" ? (name: string) => `\`${name}\`` : options.databaseType === "sqlserver" ? (name: string) => `[${name}]` : (name: string) => `"${name}"`; + const values = options.values ?? []; + const nonNull = values.filter((value: unknown) => value !== null); + const nullClause = values.some((value: unknown) => value === null) ? `${quote(options.columnName)} IS NULL` : ""; + const valueClause = nonNull.length ? `${quote(options.columnName)} IN (${nonNull.map((value: unknown) => (typeof value === "number" ? value : `'${value}'`)).join(", ")})` : ""; + const result = [nullClause, valueClause].filter(Boolean).join(" OR "); + return new Response(JSON.stringify(result ? `(${result})` : null), { status: 200, headers: { "Content-Type": "application/json" } }); + } if (String(input) !== "/api/query/build-data-grid-column-value-filter-condition") { return new Response("unexpected request", { status: 500 }); } @@ -51,3 +62,15 @@ test("builds IS NULL for typed NULL filters", async () => { assert.equal(condition, "[archived_at] IS NULL"); }); + +test("builds multi-value server-side column filters", async () => { + installFilterFetchMock(); + const condition = await buildColumnValuesFilterCondition({ + databaseType: "postgres", + columnName: "status", + columnInfo: { name: "status", data_type: "varchar", is_nullable: true, is_primary_key: false }, + values: ["active", "pending", null], + }); + + assert.equal(condition, `("status" IS NULL OR "status" IN ('active', 'pending'))`); +}); diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index dd366f78b..611fc1b93 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -438,6 +438,20 @@ pub fn build_data_grid_column_value_filter_condition( Ok(dbx_core::data_grid_sql::build_data_grid_column_value_filter_condition(options)) } +#[tauri::command] +pub fn build_data_grid_column_values_filter_condition( + options: dbx_core::data_grid_sql::DataGridColumnValuesFilterConditionOptions, +) -> Result, String> { + Ok(dbx_core::data_grid_sql::build_data_grid_column_values_filter_condition(options)) +} + +#[tauri::command] +pub fn build_data_grid_column_distinct_values_sql( + options: dbx_core::data_grid_sql::DataGridColumnDistinctValuesSqlOptions, +) -> Result { + Ok(dbx_core::data_grid_sql::build_data_grid_column_distinct_values_sql(options)) +} + #[tauri::command] pub fn build_data_grid_count_sql(options: dbx_core::data_grid_sql::DataGridCountSqlOptions) -> Result { Ok(dbx_core::data_grid_sql::build_data_grid_count_sql(options)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 270322c28..f3a021679 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -755,6 +755,8 @@ pub fn run() { commands::query::build_data_grid_copy_insert_statement, commands::query::build_data_grid_context_filter_condition, commands::query::build_data_grid_column_value_filter_condition, + commands::query::build_data_grid_column_values_filter_condition, + commands::query::build_data_grid_column_distinct_values_sql, commands::query::build_data_grid_count_sql, commands::query::build_hive_table_properties_sql, commands::query::build_export_insert_statements,