feat(grid): add list and range filters

This commit is contained in:
onenewcode
2026-07-13 18:14:11 +08:00
committed by GitHub
parent 8710bc9069
commit 66d7b965dc
12 changed files with 802 additions and 44 deletions
+83 -10
View File
@@ -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" />
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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: "列の操作",
+7
View File
@@ -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",
+7
View File
@@ -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: "字段操作",
+7
View File
@@ -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 {
+3 -1
View File
@@ -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;
}
+490 -25
View File
@@ -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
View File
@@ -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,
});
});