mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:58:41 +00:00
feat(grid): add list and range filters
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
type CachedStructuredFilterRule = {
|
||||
id: string;
|
||||
columnName: string;
|
||||
mode: "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "greater-than";
|
||||
mode: "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "greater-than" | "in" | "not-in" | "between" | "not-between";
|
||||
rawValue: string;
|
||||
rawEndValue: string;
|
||||
conjunction: "AND" | "OR";
|
||||
disabled?: boolean;
|
||||
};
|
||||
@@ -164,7 +165,19 @@ import { dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid
|
||||
import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis";
|
||||
import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, buildColumnValuesFilterCondition, combineWhereInputs, filterModeNeedsValue, parseFilterValue } from "@/lib/dataGrid/dataGridColumnFilter";
|
||||
import {
|
||||
appendColumnValueFilterCondition,
|
||||
buildColumnValueFilterCondition,
|
||||
buildColumnValuesFilterCondition,
|
||||
combineWhereInputs,
|
||||
filterModeHasCompleteValue,
|
||||
filterModeIsSupportedForDatabase,
|
||||
filterModeNeedsValue,
|
||||
filterModeUsesList,
|
||||
filterModeUsesRange,
|
||||
parseFilterValue,
|
||||
parseFilterValues,
|
||||
} from "@/lib/dataGrid/dataGridColumnFilter";
|
||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE, normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { allNullColumnIndexes, filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
|
||||
@@ -1136,6 +1149,7 @@ type StructuredFilterRule = {
|
||||
columnName: string;
|
||||
mode: FilterMode;
|
||||
rawValue: string;
|
||||
rawEndValue: string;
|
||||
conjunction: "AND" | "OR";
|
||||
disabled?: boolean;
|
||||
};
|
||||
@@ -1158,16 +1172,21 @@ let serverFilterRequestId = 0;
|
||||
let serverFilterSearchTimer: ReturnType<typeof window.setTimeout> | undefined;
|
||||
const filterBuilderOpen = ref(false);
|
||||
const filterBuilderColumnSearch = ref("");
|
||||
const filterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [
|
||||
const allFilterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [
|
||||
{ value: "equals", labelKey: "grid.filterBuilderEquals" },
|
||||
{ value: "not-equals", labelKey: "grid.filterBuilderNotEquals" },
|
||||
{ value: "like", labelKey: "grid.filterBuilderContains" },
|
||||
{ value: "not-like", labelKey: "grid.filterBuilderNotContains" },
|
||||
{ value: "greater-than", labelKey: "grid.filterBuilderGreaterThan" },
|
||||
{ value: "less-than", labelKey: "grid.filterBuilderLessThan" },
|
||||
{ value: "in", labelKey: "grid.filterBuilderIn" },
|
||||
{ value: "not-in", labelKey: "grid.filterBuilderNotIn" },
|
||||
{ value: "between", labelKey: "grid.filterBuilderBetween" },
|
||||
{ value: "not-between", labelKey: "grid.filterBuilderNotBetween" },
|
||||
{ value: "is-null", labelKey: "grid.filterBuilderIsNull" },
|
||||
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
|
||||
];
|
||||
const filterModeOptions = computed(() => allFilterModeOptions.filter((option) => filterModeIsSupportedForDatabase(option.value, resolvedDatabaseType.value)));
|
||||
const filterBuilderColumns = computed(() => props.tableMeta?.columns ?? []);
|
||||
const filterBuilderColumnOptions = computed(() => filterBuilderColumns.value.map((column) => column.name));
|
||||
const filteredFilterBuilderColumnOptions = computed(() => {
|
||||
@@ -1180,7 +1199,7 @@ const structuredFilterCacheKey = computed(() => props.cacheKey || [props.connect
|
||||
const structuredFilterScopeKey = computed(() => [props.connectionId ?? "", props.database ?? "", props.schema ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", filterBuilderColumnOptions.value.join("\0")].join("\u0001"));
|
||||
const structuredFilterRules = ref<StructuredFilterRule[]>([]);
|
||||
const appliedStructuredWhereInput = ref("");
|
||||
const structuredFilterCount = computed(() => structuredFilterRules.value.filter((rule) => !rule.disabled && !!rule.columnName && (!filterModeNeedsValue(rule.mode) || rule.rawValue.trim().length > 0)).length);
|
||||
const structuredFilterCount = computed(() => structuredFilterRules.value.filter((rule) => !rule.disabled && !!rule.columnName && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue)).length);
|
||||
const hasStructuredFilters = computed(() => !!combineWhereInputs(undefined, appliedStructuredWhereInput.value));
|
||||
const formatterOpenColumn = ref<number | null>(null);
|
||||
type FormatterDraftKind = Exclude<ColumnFormatterConfig["kind"], "custom-ref">;
|
||||
@@ -1796,10 +1815,15 @@ function defaultStructuredFilterRule(): StructuredFilterRule {
|
||||
columnName: filterBuilderColumnOptions.value[0] ?? "",
|
||||
mode: "equals",
|
||||
rawValue: "",
|
||||
rawEndValue: "",
|
||||
conjunction: "AND",
|
||||
};
|
||||
}
|
||||
|
||||
function filterModeUsesExpandedLayout(mode: FilterMode): boolean {
|
||||
return filterModeUsesList(mode) || filterModeUsesRange(mode);
|
||||
}
|
||||
|
||||
function cloneStructuredFilterRules(rules: StructuredFilterRule[]): StructuredFilterRule[] {
|
||||
return rules.map((rule) => ({ ...rule }));
|
||||
}
|
||||
@@ -1815,8 +1839,11 @@ async function buildStructuredWhereFromRules(rules: StructuredFilterRule[]): Pro
|
||||
rules.map(async (rule) => {
|
||||
if (rule.disabled) return { rule, condition: null };
|
||||
if (!rule.columnName) return { rule, condition: null };
|
||||
if (filterModeNeedsValue(rule.mode) && !rule.rawValue.trim()) return { rule, condition: null };
|
||||
if (!filterModeIsSupportedForDatabase(rule.mode, resolvedDatabaseType.value)) return { rule, condition: null };
|
||||
if (!filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue)) return { rule, condition: null };
|
||||
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName);
|
||||
const usesList = filterModeUsesList(rule.mode);
|
||||
const usesRange = filterModeUsesRange(rule.mode);
|
||||
return {
|
||||
rule,
|
||||
condition:
|
||||
@@ -1825,7 +1852,9 @@ async function buildStructuredWhereFromRules(rules: StructuredFilterRule[]): Pro
|
||||
columnName: rule.columnName,
|
||||
columnInfo,
|
||||
mode: rule.mode,
|
||||
value: filterModeNeedsValue(rule.mode) ? parseFilterValue(rule.rawValue, columnInfo) : null,
|
||||
value: !usesList && filterModeNeedsValue(rule.mode) ? parseFilterValue(rule.rawValue, columnInfo, resolvedDatabaseType.value) : null,
|
||||
values: usesList ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : undefined,
|
||||
endValue: usesRange ? parseFilterValue(rule.rawEndValue, columnInfo, resolvedDatabaseType.value) : undefined,
|
||||
})) ?? null,
|
||||
};
|
||||
}),
|
||||
@@ -1895,7 +1924,12 @@ function updateStructuredFilterRule(ruleId: string, patch: Partial<StructuredFil
|
||||
structuredFilterRules.value = structuredFilterRules.value.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
const next = { ...rule, ...patch };
|
||||
if (!filterModeNeedsValue(next.mode)) next.rawValue = "";
|
||||
if (!filterModeNeedsValue(next.mode)) {
|
||||
next.rawValue = "";
|
||||
next.rawEndValue = "";
|
||||
} else if (!filterModeUsesRange(next.mode)) {
|
||||
next.rawEndValue = "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -9002,7 +9036,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
{{ rule.conjunction }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.2fr)_auto] items-center gap-2">
|
||||
<div class="grid items-center gap-2" :class="filterModeUsesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.2fr)_auto]'">
|
||||
<Select :model-value="rule.columnName" :disabled="rule.disabled" :class="rule.disabled ? 'opacity-45' : ''" @update:model-value="(value: any) => updateStructuredFilterRuleColumn(rule.id, String(value))">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue :placeholder="t('grid.filterBuilderColumn')" />
|
||||
@@ -9043,8 +9077,47 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div v-if="filterModeUsesRange(rule.mode)" class="col-span-2 flex min-w-0 gap-2">
|
||||
<Input
|
||||
:model-value="rule.rawValue"
|
||||
class="h-8 min-w-0 flex-1 text-xs"
|
||||
:class="rule.disabled ? 'opacity-45' : ''"
|
||||
:disabled="rule.disabled"
|
||||
:aria-label="t('grid.filterBuilderRangeStart')"
|
||||
:placeholder="t('grid.filterBuilderRangeStart')"
|
||||
@update:model-value="(value) => updateStructuredFilterRule(rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown.enter.prevent="applyStructuredFilters"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.rawEndValue"
|
||||
class="h-8 min-w-0 flex-1 text-xs"
|
||||
:class="rule.disabled ? 'opacity-45' : ''"
|
||||
:disabled="rule.disabled"
|
||||
:aria-label="t('grid.filterBuilderRangeEnd')"
|
||||
:placeholder="t('grid.filterBuilderRangeEnd')"
|
||||
@update:model-value="(value) => updateStructuredFilterRule(rule.id, { rawEndValue: String(value ?? '') })"
|
||||
@keydown.enter.prevent="applyStructuredFilters"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
v-else-if="filterModeUsesList(rule.mode)"
|
||||
rows="2"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="col-span-2 min-h-14 w-full min-w-0 resize-y rounded-[6px] border border-input bg-transparent px-2.5 py-1 text-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring"
|
||||
:class="rule.disabled ? 'opacity-45' : ''"
|
||||
:disabled="rule.disabled"
|
||||
:aria-label="t('grid.filterBuilderValues')"
|
||||
:placeholder="t('grid.filterBuilderValues')"
|
||||
:value="rule.rawValue"
|
||||
@input="updateStructuredFilterRule(rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
|
||||
@keydown.ctrl.enter.prevent="applyStructuredFilters"
|
||||
@keydown.meta.enter.prevent="applyStructuredFilters"
|
||||
></textarea>
|
||||
<Input
|
||||
v-if="filterModeNeedsValue(rule.mode)"
|
||||
v-else-if="filterModeNeedsValue(rule.mode)"
|
||||
:model-value="rule.rawValue"
|
||||
class="h-8 min-w-0 text-xs"
|
||||
:class="rule.disabled ? 'opacity-45' : ''"
|
||||
@@ -9057,7 +9130,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
<span class="truncate">{{ t("grid.filterBuilderNoValue") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1" :class="filterModeUsesExpandedLayout(rule.mode) ? 'col-start-3 row-start-1 row-span-2 self-center' : ''">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" @click="updateStructuredFilterRule(rule.id, { disabled: !rule.disabled })">
|
||||
<EyeOff v-if="rule.disabled" class="h-3.5 w-3.5" />
|
||||
<Eye v-else class="h-3.5 w-3.5" />
|
||||
|
||||
@@ -767,6 +767,9 @@ export default {
|
||||
filterBuilderSearchColumns: "Search columns...",
|
||||
filterBuilderNoMatchingColumns: "No matching columns",
|
||||
filterBuilderValue: "Value",
|
||||
filterBuilderValues: "Values (comma or newline separated)",
|
||||
filterBuilderRangeStart: "Start value",
|
||||
filterBuilderRangeEnd: "End value",
|
||||
filterBuilderNoValue: "No value needed",
|
||||
filterBuilderEmpty: "Add a rule to start filtering rows.",
|
||||
resetFilterBuilder: "Reset rules",
|
||||
@@ -776,6 +779,10 @@ export default {
|
||||
filterBuilderNotContains: "Does not contain",
|
||||
filterBuilderGreaterThan: "Greater than",
|
||||
filterBuilderLessThan: "Less than",
|
||||
filterBuilderIn: "In list",
|
||||
filterBuilderNotIn: "Not in list",
|
||||
filterBuilderBetween: "Within range",
|
||||
filterBuilderNotBetween: "Outside range",
|
||||
filterBuilderIsNull: "Is NULL",
|
||||
filterBuilderIsNotNull: "Is not NULL",
|
||||
columnActions: "Column actions",
|
||||
|
||||
@@ -714,6 +714,9 @@ export default withEnglishFallback({
|
||||
filterBuilderAddRule: "Agregar regla",
|
||||
filterBuilderColumn: "Columna",
|
||||
filterBuilderValue: "Valor",
|
||||
filterBuilderValues: "Valores (separados por comas o saltos de línea)",
|
||||
filterBuilderRangeStart: "Valor inicial",
|
||||
filterBuilderRangeEnd: "Valor final",
|
||||
filterBuilderNoValue: "No requiere valor",
|
||||
filterBuilderEmpty: "Agrega una regla para empezar a filtrar filas.",
|
||||
resetFilterBuilder: "Restablecer reglas",
|
||||
@@ -723,6 +726,10 @@ export default withEnglishFallback({
|
||||
filterBuilderNotContains: "No contiene",
|
||||
filterBuilderGreaterThan: "Mayor que",
|
||||
filterBuilderLessThan: "Menor que",
|
||||
filterBuilderIn: "En lista",
|
||||
filterBuilderNotIn: "Fuera de lista",
|
||||
filterBuilderBetween: "En rango",
|
||||
filterBuilderNotBetween: "Fuera de rango",
|
||||
filterBuilderIsNull: "Es NULL",
|
||||
filterBuilderIsNotNull: "No es NULL",
|
||||
columnActions: "Acciones de columna",
|
||||
|
||||
@@ -712,6 +712,9 @@ export default withEnglishFallback({
|
||||
filterBuilderAddRule: "Aggiungi regola",
|
||||
filterBuilderColumn: "Colonna",
|
||||
filterBuilderValue: "Valore",
|
||||
filterBuilderValues: "Valori (separati da virgole o nuove righe)",
|
||||
filterBuilderRangeStart: "Valore iniziale",
|
||||
filterBuilderRangeEnd: "Valore finale",
|
||||
filterBuilderNoValue: "Nessun valore richiesto",
|
||||
filterBuilderEmpty: "Aggiungi una regola per iniziare a filtrare le righe.",
|
||||
resetFilterBuilder: "Reimposta regole",
|
||||
@@ -721,6 +724,10 @@ export default withEnglishFallback({
|
||||
filterBuilderNotContains: "Non contiene",
|
||||
filterBuilderGreaterThan: "Maggiore di",
|
||||
filterBuilderLessThan: "Minore di",
|
||||
filterBuilderIn: "Nell'elenco",
|
||||
filterBuilderNotIn: "Fuori elenco",
|
||||
filterBuilderBetween: "Nell'intervallo",
|
||||
filterBuilderNotBetween: "Fuori intervallo",
|
||||
filterBuilderIsNull: "È NULL",
|
||||
filterBuilderIsNotNull: "Non è NULL",
|
||||
columnActions: "Azioni colonna",
|
||||
|
||||
@@ -709,6 +709,9 @@ export default withEnglishFallback({
|
||||
filterBuilderAddRule: "ルールを追加",
|
||||
filterBuilderColumn: "列",
|
||||
filterBuilderValue: "値",
|
||||
filterBuilderValues: "値(カンマまたは改行で区切る)",
|
||||
filterBuilderRangeStart: "開始値",
|
||||
filterBuilderRangeEnd: "終了値",
|
||||
filterBuilderNoValue: "値は不要です",
|
||||
filterBuilderEmpty: "ルールを追加して行のフィルタリングを開始してください。",
|
||||
resetFilterBuilder: "ルールをリセット",
|
||||
@@ -718,6 +721,10 @@ export default withEnglishFallback({
|
||||
filterBuilderNotContains: "含まない",
|
||||
filterBuilderGreaterThan: "より大きい",
|
||||
filterBuilderLessThan: "より小さい",
|
||||
filterBuilderIn: "一覧内",
|
||||
filterBuilderNotIn: "一覧外",
|
||||
filterBuilderBetween: "範囲内",
|
||||
filterBuilderNotBetween: "範囲外",
|
||||
filterBuilderIsNull: "NULLである",
|
||||
filterBuilderIsNotNull: "NULLでない",
|
||||
columnActions: "列の操作",
|
||||
|
||||
@@ -714,6 +714,9 @@ export default withEnglishFallback({
|
||||
filterBuilderAddRule: "Adicionar regra",
|
||||
filterBuilderColumn: "Coluna",
|
||||
filterBuilderValue: "Valor",
|
||||
filterBuilderValues: "Valores (separados por vírgulas ou quebras de linha)",
|
||||
filterBuilderRangeStart: "Valor inicial",
|
||||
filterBuilderRangeEnd: "Valor final",
|
||||
filterBuilderNoValue: "Nenhum valor necessário",
|
||||
filterBuilderEmpty: "Adicione uma regra para começar a filtrar linhas.",
|
||||
resetFilterBuilder: "Redefinir regras",
|
||||
@@ -723,6 +726,10 @@ export default withEnglishFallback({
|
||||
filterBuilderNotContains: "Não contém",
|
||||
filterBuilderGreaterThan: "Maior que",
|
||||
filterBuilderLessThan: "Menor que",
|
||||
filterBuilderIn: "Na lista",
|
||||
filterBuilderNotIn: "Fora da lista",
|
||||
filterBuilderBetween: "No intervalo",
|
||||
filterBuilderNotBetween: "Fora do intervalo",
|
||||
filterBuilderIsNull: "É NULL",
|
||||
filterBuilderIsNotNull: "Não é NULL",
|
||||
columnActions: "Ações da coluna",
|
||||
|
||||
@@ -768,6 +768,9 @@ export default withEnglishFallback({
|
||||
filterBuilderSearchColumns: "搜索字段...",
|
||||
filterBuilderNoMatchingColumns: "没有匹配的字段",
|
||||
filterBuilderValue: "值",
|
||||
filterBuilderValues: "多个值(用逗号或换行分隔)",
|
||||
filterBuilderRangeStart: "起始值",
|
||||
filterBuilderRangeEnd: "结束值",
|
||||
filterBuilderNoValue: "无需输入值",
|
||||
filterBuilderEmpty: "新增一条条件后即可开始筛选。",
|
||||
resetFilterBuilder: "重置条件",
|
||||
@@ -777,6 +780,10 @@ export default withEnglishFallback({
|
||||
filterBuilderNotContains: "不包含",
|
||||
filterBuilderGreaterThan: "大于",
|
||||
filterBuilderLessThan: "小于",
|
||||
filterBuilderIn: "列表内",
|
||||
filterBuilderNotIn: "列表外",
|
||||
filterBuilderBetween: "介于",
|
||||
filterBuilderNotBetween: "不介于",
|
||||
filterBuilderIsNull: "为空",
|
||||
filterBuilderIsNotNull: "不为空",
|
||||
columnActions: "字段操作",
|
||||
|
||||
@@ -713,6 +713,9 @@ export default withEnglishFallback({
|
||||
filterBuilderAddRule: "新增條件",
|
||||
filterBuilderColumn: "欄位",
|
||||
filterBuilderValue: "值",
|
||||
filterBuilderValues: "多個值(以逗號或換行分隔)",
|
||||
filterBuilderRangeStart: "起始值",
|
||||
filterBuilderRangeEnd: "結束值",
|
||||
filterBuilderNoValue: "無需輸入值",
|
||||
filterBuilderEmpty: "新增一條條件後即可開始篩選。",
|
||||
resetFilterBuilder: "重設條件",
|
||||
@@ -722,6 +725,10 @@ export default withEnglishFallback({
|
||||
filterBuilderNotContains: "不包含",
|
||||
filterBuilderGreaterThan: "大於",
|
||||
filterBuilderLessThan: "小於",
|
||||
filterBuilderIn: "清單內",
|
||||
filterBuilderNotIn: "清單外",
|
||||
filterBuilderBetween: "介於",
|
||||
filterBuilderNotBetween: "不介於",
|
||||
filterBuilderIsNull: "為空",
|
||||
filterBuilderIsNotNull: "不為空",
|
||||
columnActions: "欄位操作",
|
||||
|
||||
@@ -50,18 +50,43 @@ export function filterModeNeedsValue(mode: DataGridContextFilterMode): boolean {
|
||||
return mode !== "is-null" && mode !== "is-not-null";
|
||||
}
|
||||
|
||||
export function parseFilterValue(rawValue: string, columnInfo?: Pick<DataGridColumnInfo, "data_type">): GridCellValue {
|
||||
export function filterModeUsesList(mode: DataGridContextFilterMode): boolean {
|
||||
return mode === "in" || mode === "not-in";
|
||||
}
|
||||
|
||||
export function filterModeUsesRange(mode: DataGridContextFilterMode): boolean {
|
||||
return mode === "between" || mode === "not-between";
|
||||
}
|
||||
|
||||
export function filterModeIsSupportedForDatabase(mode: DataGridContextFilterMode, databaseType?: DatabaseType): boolean {
|
||||
if (!filterModeUsesList(mode) && !filterModeUsesRange(mode)) return true;
|
||||
// These targets do not support all four new SQL predicates reliably.
|
||||
return databaseType !== "cassandra" && databaseType !== "influxdb" && databaseType !== "jdbc";
|
||||
}
|
||||
|
||||
export function filterModeHasCompleteValue(mode: DataGridContextFilterMode, rawValue: string, rawEndValue = ""): boolean {
|
||||
if (!filterModeNeedsValue(mode)) return true;
|
||||
if (filterModeUsesList(mode)) return parseFilterValues(rawValue).length > 0;
|
||||
if (filterModeUsesRange(mode)) return rawValue.trim().length > 0 && rawEndValue.trim().length > 0;
|
||||
return rawValue.trim().length > 0;
|
||||
}
|
||||
|
||||
export function parseFilterValue(rawValue: string, columnInfo?: Pick<DataGridColumnInfo, "data_type">, databaseType?: DatabaseType): GridCellValue {
|
||||
const unquoted = unwrapMatchingQuotes(rawValue.trim());
|
||||
const dataType = (columnInfo?.data_type ?? "").toLowerCase();
|
||||
|
||||
if (isBooleanType(dataType) && unquoted.toLowerCase() === "true") return true;
|
||||
if (isBooleanType(dataType) && unquoted.toLowerCase() === "false") return false;
|
||||
if (isBooleanType(dataType, databaseType) && unquoted.toLowerCase() === "true") return true;
|
||||
if (isBooleanType(dataType, databaseType) && unquoted.toLowerCase() === "false") return false;
|
||||
|
||||
if ((isNumericType(dataType) || !dataType) && isNumericLiteral(unquoted)) {
|
||||
if (isNumericType(dataType) && isNumericLiteral(unquoted)) {
|
||||
// Preserve the original decimal/integer spelling so the backend can emit it exactly.
|
||||
return unquoted;
|
||||
}
|
||||
|
||||
if (!dataType && isNumericLiteral(unquoted)) {
|
||||
const numeric = Number(unquoted);
|
||||
if (Number.isFinite(numeric)) {
|
||||
// Keep large integers as strings to avoid JS precision loss (> Number.MAX_SAFE_INTEGER).
|
||||
// The Rust backend parses strings exactly via serde_json::Number, so this is safe.
|
||||
if (Number.isInteger(numeric) && Math.abs(numeric) > Number.MAX_SAFE_INTEGER) {
|
||||
return unquoted;
|
||||
}
|
||||
@@ -72,6 +97,73 @@ export function parseFilterValue(rawValue: string, columnInfo?: Pick<DataGridCol
|
||||
return unquoted;
|
||||
}
|
||||
|
||||
export function parseFilterValues(rawValue: string, columnInfo?: Pick<DataGridColumnInfo, "data_type">, databaseType?: DatabaseType): GridCellValue[] {
|
||||
return splitFilterValues(rawValue).map(({ value, quoted }) => {
|
||||
if (!quoted && value.toLowerCase() === "null") return null;
|
||||
return parseFilterValue(value, columnInfo, databaseType);
|
||||
});
|
||||
}
|
||||
|
||||
type ParsedFilterValue = {
|
||||
value: string;
|
||||
quoted: boolean;
|
||||
};
|
||||
|
||||
function splitFilterValues(source: string): ParsedFilterValue[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
const next = source[index + 1];
|
||||
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) {
|
||||
if (next === quote) {
|
||||
current += next;
|
||||
index += 1;
|
||||
} else {
|
||||
quote = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((char === "'" || char === '"') && current.trim().length === 0) {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "," || char === "\n" || char === "\r") {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
tokens.push(current);
|
||||
|
||||
return tokens.map(normalizeFilterValue).filter((value): value is ParsedFilterValue => !!value);
|
||||
}
|
||||
|
||||
function normalizeFilterValue(token: string): ParsedFilterValue | null {
|
||||
const text = token.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const first = text[0];
|
||||
const last = text[text.length - 1];
|
||||
const quoted = text.length >= 2 && ((first === "'" && last === "'") || (first === '"' && last === '"'));
|
||||
if (!quoted) return { value: text, quoted: false };
|
||||
|
||||
const quote = first as "'" | '"';
|
||||
const value = text.slice(1, -1).replaceAll(`${quote}${quote}`, quote);
|
||||
return { value, quoted: true };
|
||||
}
|
||||
|
||||
function unwrapMatchingQuotes(text: string): string {
|
||||
if (text.length >= 2) {
|
||||
const first = text[0];
|
||||
@@ -87,8 +179,8 @@ function isNumericType(dataType: string): boolean {
|
||||
return ["int", "integer", "bigint", "smallint", "tinyint", "mediumint", "serial", "number", "numeric", "decimal", "float", "double", "real", "money"].some((part) => dataType.split(/[^a-z0-9]+/).includes(part));
|
||||
}
|
||||
|
||||
function isBooleanType(dataType: string): boolean {
|
||||
return dataType.split(/[^a-z0-9]+/).some((part) => part === "bool" || part === "boolean" || part === "bit");
|
||||
function isBooleanType(dataType: string, databaseType?: DatabaseType): boolean {
|
||||
return dataType.split(/[^a-z0-9]+/).some((part) => part === "bool" || part === "boolean" || (part === "bit" && databaseType !== "postgres"));
|
||||
}
|
||||
|
||||
function isNumericLiteral(text: string): boolean {
|
||||
|
||||
@@ -52,13 +52,15 @@ export interface DataGridCopyInsertStatementOptions {
|
||||
insertMode?: DataGridCopyInsertMode;
|
||||
}
|
||||
|
||||
export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "greater-than";
|
||||
export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "greater-than" | "in" | "not-in" | "between" | "not-between";
|
||||
|
||||
export interface DataGridContextFilterConditionOptions {
|
||||
databaseType?: DatabaseType;
|
||||
columnName: string;
|
||||
mode: DataGridContextFilterMode;
|
||||
value: GridCellValue;
|
||||
values?: GridCellValue[];
|
||||
endValue?: GridCellValue;
|
||||
columnInfo?: DataGridColumnInfo;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use chrono::{Local, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[path = "data_grid_neo4j_sql.rs"]
|
||||
mod data_grid_neo4j_sql;
|
||||
@@ -126,6 +127,26 @@ pub enum DataGridContextFilterMode {
|
||||
NotLike,
|
||||
LessThan,
|
||||
GreaterThan,
|
||||
In,
|
||||
NotIn,
|
||||
Between,
|
||||
NotBetween,
|
||||
}
|
||||
|
||||
fn supports_data_grid_context_filter_mode(
|
||||
database_type: Option<DatabaseType>,
|
||||
mode: DataGridContextFilterMode,
|
||||
) -> bool {
|
||||
!matches!(
|
||||
(database_type, mode),
|
||||
(
|
||||
Some(DatabaseType::InfluxDb | DatabaseType::Cassandra | DatabaseType::Jdbc),
|
||||
DataGridContextFilterMode::In
|
||||
| DataGridContextFilterMode::NotIn
|
||||
| DataGridContextFilterMode::Between
|
||||
| DataGridContextFilterMode::NotBetween
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -136,6 +157,10 @@ pub struct DataGridContextFilterConditionOptions {
|
||||
pub column_name: String,
|
||||
pub mode: DataGridContextFilterMode,
|
||||
pub value: Value,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub values: Vec<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end_value: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub column_info: Option<DataGridColumnInfo>,
|
||||
}
|
||||
@@ -413,6 +438,10 @@ pub fn build_data_grid_copy_insert_statement(options: DataGridCopyInsertStatemen
|
||||
}
|
||||
|
||||
pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterConditionOptions) -> Option<String> {
|
||||
if !supports_data_grid_context_filter_mode(options.database_type, options.mode) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let column = column_filter_ref(options.database_type, &options.column_name);
|
||||
let like_column = column_like_filter_ref(options.database_type, &options.column_name, options.column_info.as_ref());
|
||||
let value = &options.value;
|
||||
@@ -445,6 +474,36 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo
|
||||
"{column} > {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
)),
|
||||
DataGridContextFilterMode::In => build_data_grid_context_membership_filter_condition(
|
||||
&column,
|
||||
&options.values,
|
||||
options.database_type,
|
||||
options.column_info.as_ref(),
|
||||
false,
|
||||
),
|
||||
DataGridContextFilterMode::NotIn => build_data_grid_context_membership_filter_condition(
|
||||
&column,
|
||||
&options.values,
|
||||
options.database_type,
|
||||
options.column_info.as_ref(),
|
||||
true,
|
||||
),
|
||||
DataGridContextFilterMode::Between => build_data_grid_context_range_filter_condition(
|
||||
&column,
|
||||
value,
|
||||
options.end_value.as_ref(),
|
||||
options.database_type,
|
||||
options.column_info.as_ref(),
|
||||
false,
|
||||
),
|
||||
DataGridContextFilterMode::NotBetween => build_data_grid_context_range_filter_condition(
|
||||
&column,
|
||||
value,
|
||||
options.end_value.as_ref(),
|
||||
options.database_type,
|
||||
options.column_info.as_ref(),
|
||||
true,
|
||||
),
|
||||
DataGridContextFilterMode::Equals => Some(format!(
|
||||
"{column} = {}",
|
||||
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
|
||||
@@ -456,6 +515,101 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo
|
||||
}
|
||||
}
|
||||
|
||||
fn build_data_grid_context_membership_filter_condition(
|
||||
column: &str,
|
||||
values: &[Value],
|
||||
database_type: Option<DatabaseType>,
|
||||
column_info: Option<&DataGridColumnInfo>,
|
||||
negated: bool,
|
||||
) -> Option<String> {
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut has_null = false;
|
||||
let mut literals = Vec::new();
|
||||
let mut seen_literals = HashSet::new();
|
||||
for value in values {
|
||||
if value.is_null() {
|
||||
has_null = true;
|
||||
continue;
|
||||
}
|
||||
let literal = format_grid_sql_literal(value, database_type, column_info);
|
||||
if seen_literals.insert(literal.clone()) {
|
||||
literals.push(literal);
|
||||
}
|
||||
}
|
||||
|
||||
let membership = build_membership_predicate(column, &literals, database_type, negated);
|
||||
|
||||
if negated {
|
||||
return match membership {
|
||||
Some(membership) => Some(format!("({column} IS NOT NULL AND {membership})")),
|
||||
None if has_null => Some(format!("{column} IS NOT NULL")),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
|
||||
match membership {
|
||||
Some(membership) if has_null => Some(format!("({column} IS NULL OR {membership})")),
|
||||
Some(membership) => Some(membership),
|
||||
None if has_null => Some(format!("{column} IS NULL")),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_membership_predicate(
|
||||
column: &str,
|
||||
literals: &[String],
|
||||
database_type: Option<DatabaseType>,
|
||||
negated: bool,
|
||||
) -> Option<String> {
|
||||
if literals.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if database_type == Some(DatabaseType::Neo4j) {
|
||||
let predicate = format!("{column} IN [{}]", literals.join(", "));
|
||||
return Some(if negated { format!("NOT ({predicate})") } else { predicate });
|
||||
}
|
||||
|
||||
let operator = if negated { "NOT IN" } else { "IN" };
|
||||
if database_type != Some(DatabaseType::Oracle) || literals.len() <= 1000 {
|
||||
return Some(format!("{column} {operator} ({})", literals.join(", ")));
|
||||
}
|
||||
|
||||
// Oracle limits each IN expression to 1000 values; preserve NOT IN semantics
|
||||
// by joining its chunks with AND instead of OR.
|
||||
let joiner = if negated { " AND " } else { " OR " };
|
||||
let chunks =
|
||||
literals.chunks(1000).map(|chunk| format!("{column} {operator} ({})", chunk.join(", "))).collect::<Vec<_>>();
|
||||
Some(format!("({})", chunks.join(joiner)))
|
||||
}
|
||||
|
||||
fn build_data_grid_context_range_filter_condition(
|
||||
column: &str,
|
||||
start_value: &Value,
|
||||
end_value: Option<&Value>,
|
||||
database_type: Option<DatabaseType>,
|
||||
column_info: Option<&DataGridColumnInfo>,
|
||||
negated: bool,
|
||||
) -> Option<String> {
|
||||
let end_value = end_value?;
|
||||
if start_value.is_null() || end_value.is_null() {
|
||||
return None;
|
||||
}
|
||||
let start = format_grid_sql_literal(start_value, database_type, column_info);
|
||||
let end = format_grid_sql_literal(end_value, database_type, column_info);
|
||||
if database_type == Some(DatabaseType::Neo4j) {
|
||||
return if negated {
|
||||
Some(format!("({column} < {start} OR {column} > {end})"))
|
||||
} else {
|
||||
Some(format!("({column} >= {start} AND {column} <= {end})"))
|
||||
};
|
||||
}
|
||||
let operator = if negated { "NOT BETWEEN" } else { "BETWEEN" };
|
||||
Some(format!("{column} {operator} {start} AND {end}"))
|
||||
}
|
||||
|
||||
pub fn build_data_grid_column_value_filter_condition(
|
||||
options: DataGridColumnValueFilterConditionOptions,
|
||||
) -> Option<String> {
|
||||
@@ -467,7 +621,7 @@ pub fn build_data_grid_column_value_filter_condition(
|
||||
if text.eq_ignore_ascii_case("null") {
|
||||
return Some(format!("{column} IS NULL"));
|
||||
}
|
||||
let value = parse_typed_filter_value(text, options.column_info.as_ref());
|
||||
let value = parse_typed_filter_value(text, options.database_type, options.column_info.as_ref());
|
||||
Some(format!("{column} = {}", format_grid_sql_literal(&value, options.database_type, options.column_info.as_ref())))
|
||||
}
|
||||
|
||||
@@ -481,13 +635,14 @@ pub fn build_data_grid_column_values_filter_condition(
|
||||
let column = column_filter_ref(options.database_type, &options.column_name);
|
||||
let mut has_null = false;
|
||||
let mut literals = Vec::new();
|
||||
let mut seen_literals = HashSet::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) {
|
||||
if seen_literals.insert(literal.clone()) {
|
||||
literals.push(literal);
|
||||
}
|
||||
}
|
||||
@@ -496,15 +651,10 @@ pub fn build_data_grid_column_values_filter_condition(
|
||||
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})"));
|
||||
}
|
||||
if literals.len() == 1 {
|
||||
predicates.push(format!("{column} = {}", literals[0]));
|
||||
} else if let Some(membership) = build_membership_predicate(&column, &literals, options.database_type, false) {
|
||||
predicates.push(membership);
|
||||
}
|
||||
|
||||
match predicates.len() {
|
||||
@@ -603,7 +753,7 @@ fn data_grid_column_distinct_values_search_predicate(
|
||||
&& !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());
|
||||
let value = parse_typed_filter_value(search, options.database_type, options.column_info.as_ref());
|
||||
return Some(format!(
|
||||
"{column} = {}",
|
||||
format_grid_sql_literal(&value, options.database_type, options.column_info.as_ref())
|
||||
@@ -1119,7 +1269,7 @@ pub fn format_grid_sql_literal(
|
||||
// is a numeric/boolean type rather than a bit-string type like
|
||||
// PostgreSQL's bit(n).
|
||||
if let Some(value) = value.as_bool() {
|
||||
if is_bit_literal_column(column_info) {
|
||||
if is_bit_literal_column(database_type, column_info) {
|
||||
return if value { "1" } else { "0" }.to_string();
|
||||
}
|
||||
return if value { "TRUE" } else { "FALSE" }.to_string();
|
||||
@@ -1190,7 +1340,12 @@ pub fn format_grid_sql_literal(
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let escaped = format!("'{}'", literal_text.replace('\\', "\\\\").replace('\'', "''"));
|
||||
let escaped_text = if database_type == Some(DatabaseType::Neo4j) {
|
||||
literal_text.replace('\\', "\\\\").replace('\'', "\\'")
|
||||
} else {
|
||||
literal_text.replace('\\', "\\\\").replace('\'', "''")
|
||||
};
|
||||
let escaped = format!("'{escaped_text}'");
|
||||
if database_type == Some(DatabaseType::SqlServer) {
|
||||
format!("N{escaped}")
|
||||
} else {
|
||||
@@ -1339,8 +1494,9 @@ fn is_mysql_bit_literal_column(database_type: Option<DatabaseType>, column_info:
|
||||
&& column_info.map(|column| is_bit_column_type(&column.data_type)).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_bit_literal_column(column_info: Option<&DataGridColumnInfo>) -> bool {
|
||||
column_info.map(|column| is_bit_column_type(&column.data_type)).unwrap_or(false)
|
||||
fn is_bit_literal_column(database_type: Option<DatabaseType>, column_info: Option<&DataGridColumnInfo>) -> bool {
|
||||
database_type != Some(DatabaseType::Postgres)
|
||||
&& column_info.map(|column| is_bit_column_type(&column.data_type)).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_bit_column_type(data_type: &str) -> bool {
|
||||
@@ -1407,10 +1563,10 @@ fn is_geometry_column_type(data_type: &str) -> bool {
|
||||
|
||||
fn manticore_typed_attribute_value(text: &str, column_info: Option<&DataGridColumnInfo>) -> Option<Value> {
|
||||
let data_type = column_info?.data_type.to_ascii_lowercase();
|
||||
if is_boolean_type(&data_type) && text.eq_ignore_ascii_case("true") {
|
||||
if is_boolean_type(&data_type, None) && text.eq_ignore_ascii_case("true") {
|
||||
return Some(Value::Bool(true));
|
||||
}
|
||||
if is_boolean_type(&data_type) && text.eq_ignore_ascii_case("false") {
|
||||
if is_boolean_type(&data_type, None) && text.eq_ignore_ascii_case("false") {
|
||||
return Some(Value::Bool(false));
|
||||
}
|
||||
if is_numeric_type(&data_type) && is_numeric_literal(text) {
|
||||
@@ -2005,13 +2161,17 @@ fn value_to_filter_text(value: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_typed_filter_value(text: &str, column_info: Option<&DataGridColumnInfo>) -> Value {
|
||||
fn parse_typed_filter_value(
|
||||
text: &str,
|
||||
database_type: Option<DatabaseType>,
|
||||
column_info: Option<&DataGridColumnInfo>,
|
||||
) -> Value {
|
||||
let unquoted = unwrap_matching_quotes(text);
|
||||
let data_type = column_info.map(|column| column.data_type.to_ascii_lowercase()).unwrap_or_default();
|
||||
if is_boolean_type(&data_type) && unquoted.eq_ignore_ascii_case("true") {
|
||||
if is_boolean_type(&data_type, database_type) && unquoted.eq_ignore_ascii_case("true") {
|
||||
return Value::Bool(true);
|
||||
}
|
||||
if is_boolean_type(&data_type) && unquoted.eq_ignore_ascii_case("false") {
|
||||
if is_boolean_type(&data_type, database_type) && unquoted.eq_ignore_ascii_case("false") {
|
||||
return Value::Bool(false);
|
||||
}
|
||||
if (is_numeric_type(&data_type) || data_type.is_empty()) && is_numeric_literal(&unquoted) {
|
||||
@@ -2059,11 +2219,12 @@ fn is_numeric_type(data_type: &str) -> bool {
|
||||
.any(|part| lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == *part))
|
||||
}
|
||||
|
||||
fn is_boolean_type(data_type: &str) -> bool {
|
||||
fn is_boolean_type(data_type: &str, database_type: Option<DatabaseType>) -> bool {
|
||||
let lower = data_type.to_ascii_lowercase();
|
||||
lower
|
||||
.split(|ch: char| !ch.is_ascii_alphanumeric())
|
||||
.any(|token| matches!(token, "bool" | "boolean" | "bit" | "bitn"))
|
||||
lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| {
|
||||
matches!(token, "bool" | "boolean")
|
||||
|| (matches!(token, "bit" | "bitn") && database_type != Some(DatabaseType::Postgres))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_numeric_literal(text: &str) -> bool {
|
||||
@@ -2401,6 +2562,8 @@ mod tests {
|
||||
column_name: "status".to_string(),
|
||||
mode: DataGridContextFilterMode::Like,
|
||||
value: json!("active"),
|
||||
values: Vec::new(),
|
||||
end_value: None,
|
||||
column_info: Some(column("status", "varchar", true, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
@@ -2412,6 +2575,8 @@ mod tests {
|
||||
column_name: "update_date".to_string(),
|
||||
mode: DataGridContextFilterMode::Like,
|
||||
value: json!("128"),
|
||||
values: Vec::new(),
|
||||
end_value: None,
|
||||
column_info: Some(column("update_date", "bigint", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
@@ -2423,6 +2588,8 @@ mod tests {
|
||||
column_name: "created_at".to_string(),
|
||||
mode: DataGridContextFilterMode::NotLike,
|
||||
value: json!("2026"),
|
||||
values: Vec::new(),
|
||||
end_value: None,
|
||||
column_info: Some(column("created_at", "timestamp without time zone", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
@@ -2440,6 +2607,247 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_membership_and_range_context_filter_conditions() {
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
column_name: "id".to_string(),
|
||||
mode: DataGridContextFilterMode::In,
|
||||
value: Value::Null,
|
||||
values: vec![json!(42), json!(99)],
|
||||
end_value: None,
|
||||
column_info: Some(column("id", "int", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("`id` IN (42, 99)")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
column_name: "status".to_string(),
|
||||
mode: DataGridContextFilterMode::In,
|
||||
value: Value::Null,
|
||||
values: vec![Value::Null, json!("active"), json!("pending"), json!("active")],
|
||||
end_value: None,
|
||||
column_info: Some(column("status", "varchar", true, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("(\"status\" IS NULL OR \"status\" IN ('active', 'pending'))")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
column_name: "status".to_string(),
|
||||
mode: DataGridContextFilterMode::NotIn,
|
||||
value: Value::Null,
|
||||
values: vec![Value::Null, json!("active"), json!("pending")],
|
||||
end_value: None,
|
||||
column_info: Some(column("status", "varchar", true, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("(\"status\" IS NOT NULL AND \"status\" NOT IN ('active', 'pending'))")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
column_name: "name".to_string(),
|
||||
mode: DataGridContextFilterMode::In,
|
||||
value: Value::Null,
|
||||
values: vec![json!("O'Reilly"), json!(r"C:\temp")],
|
||||
end_value: None,
|
||||
column_info: Some(column("name", "string", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some(r#"n.`name` IN ['O\'Reilly', 'C:\\temp']"#)
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::Between,
|
||||
value: json!(10),
|
||||
values: Vec::new(),
|
||||
end_value: Some(json!(20)),
|
||||
column_info: Some(column("score", "int", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("[score] BETWEEN 10 AND 20")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::NotBetween,
|
||||
value: json!(10),
|
||||
values: Vec::new(),
|
||||
end_value: Some(json!(20)),
|
||||
column_info: Some(column("score", "int", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("[score] NOT BETWEEN 10 AND 20")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_neo4j_membership_and_range_context_filter_conditions() {
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::In,
|
||||
value: Value::Null,
|
||||
values: vec![json!(10), json!(20)],
|
||||
end_value: None,
|
||||
column_info: Some(column("score", "integer", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("n.`score` IN [10, 20]")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::NotIn,
|
||||
value: Value::Null,
|
||||
values: vec![json!(10), json!(20)],
|
||||
end_value: None,
|
||||
column_info: Some(column("score", "integer", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("(n.`score` IS NOT NULL AND NOT (n.`score` IN [10, 20]))")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::Between,
|
||||
value: json!(10),
|
||||
values: Vec::new(),
|
||||
end_value: Some(json!(20)),
|
||||
column_info: Some(column("score", "integer", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("(n.`score` >= 10 AND n.`score` <= 20)")
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::NotBetween,
|
||||
value: json!(10),
|
||||
values: Vec::new(),
|
||||
end_value: Some(json!(20)),
|
||||
column_info: Some(column("score", "integer", false, None)),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("(n.`score` < 10 OR n.`score` > 20)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_emit_membership_or_range_filters_for_unsupported_dialects() {
|
||||
for database_type in [DatabaseType::InfluxDb, DatabaseType::Cassandra, DatabaseType::Jdbc] {
|
||||
for mode in [
|
||||
DataGridContextFilterMode::In,
|
||||
DataGridContextFilterMode::NotIn,
|
||||
DataGridContextFilterMode::Between,
|
||||
DataGridContextFilterMode::NotBetween,
|
||||
] {
|
||||
let (value, values, end_value) = match mode {
|
||||
DataGridContextFilterMode::In | DataGridContextFilterMode::NotIn => {
|
||||
(Value::Null, vec![json!(10), json!(20)], None)
|
||||
}
|
||||
DataGridContextFilterMode::Between | DataGridContextFilterMode::NotBetween => {
|
||||
(json!(10), Vec::new(), Some(json!(20)))
|
||||
}
|
||||
_ => unreachable!("only membership and range modes are tested"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(database_type),
|
||||
column_name: "score".to_string(),
|
||||
mode,
|
||||
value,
|
||||
values,
|
||||
end_value,
|
||||
column_info: Some(column("score", "int", false, None)),
|
||||
}),
|
||||
None,
|
||||
"{database_type:?} must not emit {mode:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_membership_and_range_filters_require_complete_values() {
|
||||
let column_info = Some(column("score", "int", false, None));
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::In,
|
||||
value: Value::Null,
|
||||
values: Vec::new(),
|
||||
end_value: None,
|
||||
column_info: column_info.clone(),
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::Between,
|
||||
value: json!(10),
|
||||
values: Vec::new(),
|
||||
end_value: None,
|
||||
column_info: column_info.clone(),
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::Between,
|
||||
value: Value::Null,
|
||||
values: Vec::new(),
|
||||
end_value: Some(json!(20)),
|
||||
column_info: column_info.clone(),
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
column_name: "score".to_string(),
|
||||
mode: DataGridContextFilterMode::NotBetween,
|
||||
value: json!(10),
|
||||
values: Vec::new(),
|
||||
end_value: Some(Value::Null),
|
||||
column_info,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_filter_options_default_new_fields_when_deserializing_old_requests() {
|
||||
let options: DataGridContextFilterConditionOptions = serde_json::from_value(json!({
|
||||
"databaseType": "mysql",
|
||||
"columnName": "id",
|
||||
"mode": "equals",
|
||||
"value": 42,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(options.values.is_empty());
|
||||
assert_eq!(options.end_value, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_multi_value_filter_conditions() {
|
||||
assert_eq!(
|
||||
@@ -2464,6 +2872,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_large_oracle_membership_filters_with_correct_boolean_operator() {
|
||||
let values = (0..=1000).map(|value| json!(value)).collect::<Vec<_>>();
|
||||
let in_condition = build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
column_name: "id".to_string(),
|
||||
mode: DataGridContextFilterMode::In,
|
||||
value: Value::Null,
|
||||
values: values.clone(),
|
||||
end_value: None,
|
||||
column_info: Some(column("id", "number", false, None)),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(in_condition.matches("\"id\" IN (").count(), 2);
|
||||
assert!(in_condition.contains(") OR \"id\" IN ("));
|
||||
|
||||
let not_in_condition = build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
column_name: "id".to_string(),
|
||||
mode: DataGridContextFilterMode::NotIn,
|
||||
value: Value::Null,
|
||||
values,
|
||||
end_value: None,
|
||||
column_info: Some(column("id", "number", false, None)),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(not_in_condition.matches("\"id\" NOT IN (").count(), 2);
|
||||
assert!(not_in_condition.contains(") AND \"id\" NOT IN ("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_postgres_bit_strings_out_of_boolean_literal_handling() {
|
||||
let bit = column("flags", "bit(3)", false, None);
|
||||
let varying = column("flags", "bit varying", false, None);
|
||||
|
||||
assert_eq!(parse_typed_filter_value("true", Some(DatabaseType::Postgres), Some(&bit)), json!("true"));
|
||||
assert_eq!(parse_typed_filter_value("false", Some(DatabaseType::Postgres), Some(&varying)), json!("false"));
|
||||
assert_eq!(
|
||||
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
|
||||
database_type: Some(DatabaseType::Postgres),
|
||||
column_name: "flags".to_string(),
|
||||
column_info: Some(bit),
|
||||
raw_value: "true".to_string(),
|
||||
})
|
||||
.as_deref(),
|
||||
Some("\"flags\" = 'true'")
|
||||
);
|
||||
assert_eq!(
|
||||
format_grid_sql_literal(
|
||||
&json!(true),
|
||||
Some(DatabaseType::SqlServer),
|
||||
Some(&column("flag", "bit", false, None))
|
||||
),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_column_distinct_values_sql() {
|
||||
assert_eq!(
|
||||
|
||||
+78
-1
@@ -1,9 +1,17 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, buildColumnValuesFilterCondition } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts";
|
||||
import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, buildColumnValuesFilterCondition, filterModeHasCompleteValue, filterModeIsSupportedForDatabase, parseFilterValue, parseFilterValues } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnFilter.ts";
|
||||
import { buildDataGridContextFilterCondition } from "../../apps/desktop/src/lib/dataGrid/dataGridSql.ts";
|
||||
|
||||
let lastContextFilterOptions: Record<string, unknown> | undefined;
|
||||
|
||||
function installFilterFetchMock() {
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
if (String(input) === "/api/query/build-data-grid-context-filter-condition") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
lastContextFilterOptions = body.options;
|
||||
return new Response(JSON.stringify("mock condition"), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (String(input) === "/api/query/build-data-grid-column-values-filter-condition") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
const options = body.options;
|
||||
@@ -74,3 +82,72 @@ test("builds multi-value server-side column filters", async () => {
|
||||
|
||||
assert.equal(condition, `("status" IS NULL OR "status" IN ('active', 'pending'))`);
|
||||
});
|
||||
|
||||
test("parses comma and newline separated structured IN values", () => {
|
||||
const values = parseFilterValues(`alpha, 'beta,gamma'\n42\nNULL\n"NULL"\n'it''s'`, { data_type: "int" });
|
||||
|
||||
assert.deepEqual(values, ["alpha", "beta,gamma", "42", null, "NULL", "it's"]);
|
||||
assert.deepEqual(parseFilterValues('a,,\n"b""c",d\te'), ["a", 'b"c', "d\te"]);
|
||||
});
|
||||
|
||||
test("parses typed structured IN values without losing large integers", () => {
|
||||
assert.deepEqual(parseFilterValues("true, false", { data_type: "boolean" }), [true, false]);
|
||||
assert.deepEqual(parseFilterValues("9007199254740993", { data_type: "bigint" }), ["9007199254740993"]);
|
||||
assert.deepEqual(parseFilterValues("0.123456789012345678", { data_type: "decimal(38,18)" }), ["0.123456789012345678"]);
|
||||
assert.equal(parseFilterValue("0.123456789012345678", { data_type: "decimal(38,18)" }), "0.123456789012345678");
|
||||
assert.deepEqual(parseFilterValues("true, false", { data_type: "bit(1)" }, "postgres"), ["true", "false"]);
|
||||
assert.equal(parseFilterValue("true", { data_type: "bit varying" }, "postgres"), "true");
|
||||
assert.equal(parseFilterValue("true", { data_type: "bit" }, "sqlserver"), true);
|
||||
});
|
||||
|
||||
test("hides list and range modes for unsupported dialects", () => {
|
||||
for (const databaseType of ["cassandra", "influxdb", "jdbc"] as const) {
|
||||
assert.equal(filterModeIsSupportedForDatabase("in", databaseType), false);
|
||||
assert.equal(filterModeIsSupportedForDatabase("not-in", databaseType), false);
|
||||
assert.equal(filterModeIsSupportedForDatabase("between", databaseType), false);
|
||||
assert.equal(filterModeIsSupportedForDatabase("not-between", databaseType), false);
|
||||
}
|
||||
assert.equal(filterModeIsSupportedForDatabase("between", "postgres"), true);
|
||||
assert.equal(filterModeIsSupportedForDatabase("is-null", "influxdb"), true);
|
||||
});
|
||||
|
||||
test("requires usable values for structured list and range filters", () => {
|
||||
assert.equal(filterModeHasCompleteValue("in", " , \n "), false);
|
||||
assert.equal(filterModeHasCompleteValue("not-in", "active, pending"), true);
|
||||
assert.equal(filterModeHasCompleteValue("between", "10", ""), false);
|
||||
assert.equal(filterModeHasCompleteValue("not-between", "10", "20"), true);
|
||||
assert.equal(filterModeHasCompleteValue("is-null", "", ""), true);
|
||||
});
|
||||
|
||||
test("passes list and range values through the shared context filter API", async () => {
|
||||
installFilterFetchMock();
|
||||
await buildDataGridContextFilterCondition({
|
||||
databaseType: "postgres",
|
||||
columnName: "id",
|
||||
mode: "in",
|
||||
value: null,
|
||||
values: [1, 2, null],
|
||||
});
|
||||
assert.deepEqual(lastContextFilterOptions, {
|
||||
databaseType: "postgres",
|
||||
columnName: "id",
|
||||
mode: "in",
|
||||
value: null,
|
||||
values: [1, 2, null],
|
||||
});
|
||||
|
||||
await buildDataGridContextFilterCondition({
|
||||
databaseType: "sqlserver",
|
||||
columnName: "created_at",
|
||||
mode: "not-between",
|
||||
value: 10,
|
||||
endValue: 20,
|
||||
});
|
||||
assert.deepEqual(lastContextFilterOptions, {
|
||||
databaseType: "sqlserver",
|
||||
columnName: "created_at",
|
||||
mode: "not-between",
|
||||
value: 10,
|
||||
endValue: 20,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user