feat(grid): add database value filter

This commit is contained in:
t8y2 2026-06-29 03:13:35 +08:00
parent 190b5b4ad8
commit 4c48263793
19 changed files with 700 additions and 51 deletions

View File

@ -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<string>;
mode: LocalFilterMode;
touched: boolean;
};
type FilterMode = DataGridContextFilterMode;
@ -544,6 +554,15 @@ const headerSortMenuOpenColumn = ref<number | null>(null);
const headerPanelDismissGuardUntil = ref(0);
const localFilterSearch = ref("");
const localFilterDraft = ref<LocalColumnFilterDraft | null>(null);
const SERVER_COLUMN_FILTER_LIMIT = 1000;
const SERVER_COLUMN_FILTER_DEBOUNCE_MS = 300;
const serverFilterLoading = ref(false);
const serverFilterError = ref("");
const serverFilterOptions = ref<LocalFilterOption[]>([]);
const serverFilterLimited = ref(false);
const serverFilterValueByKey = ref<Map<string, CellValue>>(new Map());
let serverFilterRequestId = 0;
let serverFilterSearchTimer: ReturnType<typeof window.setTimeout> | 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<string, { key: string; label: string; count: number; value: CellValue }>();
function buildLocalFilterOptions(columnIndex: number): LocalFilterOption[] {
const byKey = new Map<string, LocalFilterOption>();
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<string, LocalFilterOption>();
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<typeof props.tableMeta>;
const hiveTableTransactional = ref<boolean | undefined>(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<ContextMenuItem[]>(() => {
</PopoverTrigger>
<PopoverContent align="start" side="bottom" class="w-[300px] max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-2 py-1.5 text-center text-xs font-semibold">
{{ t("grid.localFilterFor", { column: col.name }) }}
{{ columnFilterPanelTitle(col.name) }}
</div>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@ -7855,6 +8050,14 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span>{{ t("grid.value") }}</span>
<span class="text-right">{{ t("grid.count") }}</span>
</div>
<div v-if="localFilterDraft?.mode === 'server' && (serverFilterLoading || serverFilterError || serverFilterLimited)" class="flex items-center gap-1.5 border-b px-2 py-1 text-[11px] text-muted-foreground">
<Loader2 v-if="serverFilterLoading" class="h-3 w-3 animate-spin" />
<span class="min-w-0 truncate">
<template v-if="serverFilterLoading">{{ t("grid.loadingValues") }}</template>
<template v-else-if="serverFilterError">{{ serverFilterError }}</template>
<template v-else>{{ t("grid.serverValuesLimited", { count: SERVER_COLUMN_FILTER_LIMIT }) }}</template>
</span>
</div>
<div class="max-h-72 overflow-auto py-0.5">
<button v-for="option in localFilterOptions" :key="option.key" type="button" class="grid w-full grid-cols-[1.75rem_minmax(0,1fr)_3.5rem] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="toggleLocalFilterValue(option.key)">
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="localFilterDraft?.values.has(option.key) ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'">
@ -7863,9 +8066,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span class="truncate font-mono" :class="{ 'italic text-muted-foreground': option.value === null }">
{{ option.label }}
</span>
<span class="text-right tabular-nums text-muted-foreground text-xs">{{ option.count }}</span>
<span class="text-right tabular-nums text-muted-foreground text-xs">{{ option.count ?? "" }}</span>
</button>
<div v-if="localFilterAllOptions.length > localFilterOptions.length" class="px-2 py-0.5 text-center text-[10px] text-muted-foreground">
<div v-if="localFilterDraft?.mode === 'local' && localFilterAllOptions.length > localFilterOptions.length" class="px-2 py-0.5 text-center text-[10px] text-muted-foreground">
{{
t("grid.moreValues", {
count: localFilterAllOptions.length - localFilterOptions.length,
@ -7878,7 +8081,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
{{ t("grid.filterTypedValue", { value: localFilterTypedValue }) }}
</span>
</button>
<div v-if="localFilterOptions.length === 0 && !canApplyTypedLocalFilterValue" class="px-2 py-6 text-center text-xs text-muted-foreground">
<div v-if="localFilterOptions.length === 0 && !canApplyTypedLocalFilterValue && !serverFilterLoading" class="px-2 py-6 text-center text-xs text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
</div>
@ -7897,6 +8100,16 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
</PopoverContent>
</Popover>
<button
v-if="!compactColumnHeaderActions && canUseServerColumnFilter"
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-foreground"
:class="localFilterOpenColumn === col.actualColIdx && localFilterDraft?.mode === 'server' ? 'text-primary opacity-100' : 'opacity-80'"
:title="t('grid.databaseValueFilter')"
@click.stop="openLocalFilter(col.actualColIdx, 'server')"
>
<Database class="h-3.5 w-3.5" />
</button>
</span>
<div data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onResizeStart(col.visibleColIdx, $event)" @dblclick.stop="autoFitColumn(col.visibleColIdx)" />
</div>

View File

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

View File

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

View File

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

View File

@ -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列は表示されます。",

View File

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

View File

@ -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: "至少保留一列可见。",

View File

@ -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: "至少保留一欄可見。",

View File

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

View File

@ -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<ColumnInfo, "data_type">; rawValue: string }): Promise<string | undefined> {
@ -18,6 +18,21 @@ export function buildColumnValueFilterCondition(options: { databaseType?: Databa
});
}
export function buildColumnValuesFilterCondition(options: { databaseType?: DatabaseType; columnName: string; columnInfo?: Pick<ColumnInfo, "data_type">; values: GridCellValue[] }): Promise<string | undefined> {
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);

View File

@ -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<string | undefined> {
return api.buildDataGridColumnValuesFilterCondition(options);
}
export function buildDataGridColumnDistinctValuesSql(options: DataGridColumnDistinctValuesSqlOptions): Promise<string> {
return api.buildDataGridColumnDistinctValuesSql(options);
}
export function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise<string> {
return api.buildDataGridCountSql(options);
}

View File

@ -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<string | undefined> {
const result = await post<string | null>("/api/query/build-data-grid-column-values-filter-condition", { options });
return result ?? undefined;
}
export async function buildDataGridColumnDistinctValuesSql(options: DataGridColumnDistinctValuesSqlOptions): Promise<string> {
return post("/api/query/build-data-grid-column-distinct-values-sql", { options });
}
export async function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise<string> {
return post("/api/query/build-data-grid-count-sql", { options });
}

View File

@ -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<string | undefined> {
const result = await invoke<string | null>("build_data_grid_column_values_filter_condition", { options });
return result ?? undefined;
}
export async function buildDataGridColumnDistinctValuesSql(options: DataGridColumnDistinctValuesSqlOptions): Promise<string> {
return invoke("build_data_grid_column_distinct_values_sql", { options });
}
export async function buildDataGridCountSql(options: DataGridCountSqlOptions): Promise<string> {
return invoke("build_data_grid_count_sql", { options });
}

View File

@ -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<DatabaseType>,
pub column_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column_info: Option<DataGridColumnInfo>,
#[serde(default)]
pub values: Vec<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataGridColumnDistinctValuesSqlOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option<DatabaseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub table_name: String,
pub column_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column_info: Option<DataGridColumnInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub where_input: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[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<String> {
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>) -> 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<String> {
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<String> {
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!(

View File

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

View File

@ -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<BuildDataGridColumnValuesFilterConditionRequest>,
) -> Json<Option<String>> {
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<BuildDataGridColumnDistinctValuesSqlRequest>,
) -> Json<String> {
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<BuildDataGridCountSqlRequest>) -> Json<String> {
Json(dbx_core::data_grid_sql::build_data_grid_count_sql(req.options))
}

View File

@ -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'))`);
});

View File

@ -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<Option<String>, 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<String, String> {
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<String, String> {
Ok(dbx_core::data_grid_sql::build_data_grid_count_sql(options))

View File

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