mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 18:39:48 +08:00
✨ feat(data-grid): 新增快速 WHERE 筛选输入与补全能力
- 新增表格筛选面板快速 WHERE 条件输入 - 支持字段、操作符和关键字自动补全 - 查询、分页统计与筛选导出合并快速 WHERE 条件 - 修复补全过程中的字段引号丢失和重复追加问题 Refs #354
This commit is contained in:
@@ -99,4 +99,28 @@ describe('DataGrid layout', () => {
|
||||
expect(markup).toContain('复制行');
|
||||
expect(markup).toContain('粘贴行');
|
||||
});
|
||||
|
||||
it('renders a quick WHERE condition editor when table filters are visible', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
showFilter
|
||||
quickWhereCondition="name like 'a%'"
|
||||
onApplyQuickWhereCondition={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-quick-where="true"');
|
||||
expect(markup).toContain('WHERE');
|
||||
expect(markup).toContain('输入 WHERE 后面的条件');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// cspell:ignore anticon sqls uuidv uuidv4 hscroll
|
||||
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
@@ -61,6 +61,13 @@ import {
|
||||
resolveTemporalEditorSaveValue,
|
||||
type TemporalPickerType,
|
||||
} from './dataGridTemporal';
|
||||
import {
|
||||
buildEffectiveFilterConditions,
|
||||
normalizeQuickWhereCondition,
|
||||
resolveWhereConditionSelectedValue,
|
||||
resolveWhereConditionSuggestions,
|
||||
validateQuickWhereCondition,
|
||||
} from '../utils/dataGridWhereFilter';
|
||||
|
||||
// --- Error Boundary ---
|
||||
interface DataGridErrorBoundaryState {
|
||||
@@ -889,6 +896,8 @@ interface DataGridProps {
|
||||
exportSqlWithFilter?: string;
|
||||
onApplyFilter?: (conditions: GridFilterCondition[]) => void;
|
||||
appliedFilterConditions?: FilterCondition[];
|
||||
quickWhereCondition?: string;
|
||||
onApplyQuickWhereCondition?: (condition: string) => void;
|
||||
scrollSnapshot?: { top: number; left: number };
|
||||
onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void;
|
||||
}
|
||||
@@ -914,7 +923,8 @@ const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: '
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions,
|
||||
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
|
||||
onApplyQuickWhereCondition,
|
||||
scrollSnapshot, onScrollSnapshotChange
|
||||
}) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
@@ -2199,6 +2209,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
// Filter State
|
||||
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
|
||||
const [nextFilterId, setNextFilterId] = useState(1);
|
||||
const [quickWhereDraft, setQuickWhereDraft] = useState(() => normalizeQuickWhereCondition(quickWhereCondition));
|
||||
const filterPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2208,6 +2219,29 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setNextFilterId(Math.max(1, maxId + 1));
|
||||
}, [appliedFilterConditions, normalizeGridFilterConditions]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuickWhereDraft(normalizeQuickWhereCondition(quickWhereCondition));
|
||||
}, [quickWhereCondition]);
|
||||
|
||||
const quickWhereSuggestionOptions = useMemo(() => {
|
||||
const columnSuggestionSource = allTableColumnNames.length > 0 ? allTableColumnNames : displayColumnNames;
|
||||
return resolveWhereConditionSuggestions({
|
||||
input: quickWhereDraft,
|
||||
columnNames: columnSuggestionSource,
|
||||
dbType,
|
||||
}).map((item) => ({
|
||||
value: item.value,
|
||||
insertText: item.insertText,
|
||||
suggestionKind: item.kind,
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>{item.label}</span>
|
||||
<span style={{ color: darkMode ? 'rgba(255,255,255,0.46)' : 'rgba(0,0,0,0.42)', fontSize: 12 }}>{item.detail}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
}, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFilter) {
|
||||
return;
|
||||
@@ -4032,9 +4066,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return clauses.join(' OR ');
|
||||
}, [pkColumns, tableName]);
|
||||
|
||||
const buildCurrentPageSql = useCallback((dbType: string) => {
|
||||
const buildCurrentPageSql = useCallback((dbType: string) => {
|
||||
if (!tableName || !pagination) return '';
|
||||
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
||||
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
|
||||
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
|
||||
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
@@ -4045,7 +4080,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
return sql;
|
||||
}, [tableName, pagination, filterConditions, sortInfo, pkColumns]);
|
||||
}, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
|
||||
|
||||
// Context Menu Export
|
||||
const handleExportSelected = useCallback(async (format: string, record: any) => {
|
||||
@@ -4277,7 +4312,25 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const removeFilter = (id: number) => {
|
||||
setFilterConditions(prev => prev.filter(c => c.id !== id));
|
||||
};
|
||||
const applyQuickWhereCondition = useCallback((condition: string = quickWhereDraft): boolean => {
|
||||
const normalized = normalizeQuickWhereCondition(condition);
|
||||
const validation = validateQuickWhereCondition(normalized);
|
||||
if (!validation.ok) {
|
||||
void message.warning(validation.message);
|
||||
return false;
|
||||
}
|
||||
setQuickWhereDraft(normalized);
|
||||
if (onApplyQuickWhereCondition) onApplyQuickWhereCondition(normalized);
|
||||
return true;
|
||||
}, [quickWhereDraft, onApplyQuickWhereCondition]);
|
||||
|
||||
const clearQuickWhereCondition = useCallback(() => {
|
||||
setQuickWhereDraft('');
|
||||
if (onApplyQuickWhereCondition) onApplyQuickWhereCondition('');
|
||||
}, [onApplyQuickWhereCondition]);
|
||||
|
||||
const applyFilters = () => {
|
||||
if (!applyQuickWhereCondition()) return;
|
||||
if (onApplyFilter) onApplyFilter(filterConditions);
|
||||
};
|
||||
|
||||
@@ -5149,6 +5202,73 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<div
|
||||
data-grid-quick-where="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
marginBottom: 10,
|
||||
borderRadius: Math.max(10, panelRadius - 2),
|
||||
border: `1px solid ${panelFrameColor}`,
|
||||
background: darkMode ? 'rgba(255,255,255,0.035)' : 'rgba(255,255,255,0.72)',
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
minWidth: 58,
|
||||
height: 28,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 999,
|
||||
background: darkMode ? 'rgba(24,144,255,0.18)' : 'rgba(24,144,255,0.10)',
|
||||
border: `1px solid ${darkMode ? 'rgba(24,144,255,0.32)' : 'rgba(24,144,255,0.22)'}`,
|
||||
color: selectionAccentHex,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.03em',
|
||||
}}
|
||||
>
|
||||
WHERE
|
||||
</span>
|
||||
<AutoComplete
|
||||
value={quickWhereDraft}
|
||||
options={quickWhereSuggestionOptions}
|
||||
onChange={setQuickWhereDraft}
|
||||
onSelect={(value, option) => {
|
||||
setQuickWhereDraft(resolveWhereConditionSelectedValue({
|
||||
selectedValue: value,
|
||||
currentInput: quickWhereDraft,
|
||||
insertText: (option as any)?.insertText,
|
||||
}));
|
||||
}}
|
||||
style={{ flex: '1 1 320px', minWidth: 220 }}
|
||||
popupMatchSelectWidth={420}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
allowClear
|
||||
placeholder={dbType === 'mongodb' ? '输入 MongoDB JSON 查询对象,例如 {"status":"A"}' : '输入 WHERE 后面的条件,例如 status = 1 AND name LIKE \'A%\''}
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault();
|
||||
applyQuickWhereCondition();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</AutoComplete>
|
||||
<Button size="small" type="primary" onClick={() => applyQuickWhereCondition()}>
|
||||
应用 WHERE
|
||||
</Button>
|
||||
<Button size="small" onClick={clearQuickWhereCondition} disabled={!quickWhereDraft && !quickWhereCondition}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
|
||||
{filterConditions.map((cond, condIndex) => (
|
||||
@@ -5316,6 +5436,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<Button type="primary" onClick={applyFilters} size="small">应用</Button>
|
||||
<Button size="small" icon={<ClearOutlined />} onClick={() => {
|
||||
setFilterConditions([]);
|
||||
clearQuickWhereCondition();
|
||||
if (onApplyFilter) onApplyFilter([]);
|
||||
if (onSort) onSort('', '');
|
||||
}}>清除</Button>
|
||||
|
||||
@@ -10,6 +10,11 @@ import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveA
|
||||
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
buildEffectiveFilterConditions,
|
||||
normalizeQuickWhereCondition,
|
||||
validateQuickWhereCondition,
|
||||
} from '../utils/dataGridWhereFilter';
|
||||
|
||||
type ViewerPaginationState = {
|
||||
current: number;
|
||||
@@ -135,6 +140,7 @@ const reverseOrderBySQL = (orderBySQL: string): string => {
|
||||
type ViewerFilterSnapshot = {
|
||||
showFilter: boolean;
|
||||
conditions: FilterCondition[];
|
||||
quickWhereCondition: string;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
|
||||
@@ -165,11 +171,12 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
|
||||
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
|
||||
if (!cached) {
|
||||
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
|
||||
return { showFilter: false, conditions: [], quickWhereCondition: '', currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
|
||||
}
|
||||
return {
|
||||
showFilter: cached.showFilter === true,
|
||||
conditions: normalizeViewerFilterConditions(cached.conditions),
|
||||
quickWhereCondition: normalizeQuickWhereCondition(cached.quickWhereCondition),
|
||||
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
|
||||
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
|
||||
sortInfo: Array.isArray(cached.sortInfo)
|
||||
@@ -226,6 +233,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
|
||||
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
|
||||
const [quickWhereCondition, setQuickWhereCondition] = useState<string>(initialViewerSnapshot.quickWhereCondition);
|
||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
||||
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
||||
@@ -239,6 +247,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
viewerFilterSnapshotsByTab.set(normalizedTabId, {
|
||||
showFilter,
|
||||
conditions: normalizeViewerFilterConditions(filterConditions),
|
||||
quickWhereCondition: normalizeQuickWhereCondition(quickWhereCondition),
|
||||
currentPage: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
sortInfo,
|
||||
@@ -246,12 +255,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
scrollLeft: scrollSnapshotRef.current.left,
|
||||
...overrides,
|
||||
});
|
||||
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
|
||||
}, [showFilter, filterConditions, quickWhereCondition, pagination.current, pagination.pageSize, sortInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||
setShowFilter(snapshot.showFilter);
|
||||
setFilterConditions(snapshot.conditions);
|
||||
setQuickWhereCondition(snapshot.quickWhereCondition);
|
||||
setSortInfo(snapshot.sortInfo);
|
||||
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||
initialLoadRef.current = false;
|
||||
@@ -259,7 +269,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
|
||||
useEffect(() => {
|
||||
persistViewerSnapshot(tab.id);
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
}, [persistViewerSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -399,6 +409,14 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
const dbType = resolveDataSourceType(config);
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
|
||||
const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition);
|
||||
const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition);
|
||||
if (!quickWhereValidation.ok) {
|
||||
message.error(quickWhereValidation.message);
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
return;
|
||||
}
|
||||
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, normalizedQuickWhereCondition);
|
||||
|
||||
const dbName = tab.dbName || '';
|
||||
const tableName = tab.tableName || '';
|
||||
@@ -406,7 +424,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
let mongoFilter: Record<string, unknown> | undefined;
|
||||
if (isMongoDB) {
|
||||
try {
|
||||
mongoFilter = buildMongoFilter(filterConditions);
|
||||
mongoFilter = buildMongoFilter(effectiveFilterConditions);
|
||||
} catch (e: any) {
|
||||
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
@@ -416,7 +434,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
|
||||
const whereSQL = isMongoDB
|
||||
? JSON.stringify(mongoFilter || {})
|
||||
: buildWhereSQL(dbType, filterConditions);
|
||||
: buildWhereSQL(dbType, effectiveFilterConditions);
|
||||
const countSql = isMongoDB
|
||||
? buildMongoCountCommand(tableName, mongoFilter || {})
|
||||
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
@@ -824,7 +842,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
});
|
||||
}
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
|
||||
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
|
||||
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
|
||||
// 主键信息只会在首次加载后更新一次,避免循环查询。
|
||||
|
||||
@@ -852,13 +870,23 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
|
||||
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
|
||||
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
|
||||
const handleApplyQuickWhereCondition = useCallback((condition: string) => {
|
||||
const normalized = normalizeQuickWhereCondition(condition);
|
||||
const validation = validateQuickWhereCondition(normalized);
|
||||
if (!validation.ok) {
|
||||
message.error(validation.message);
|
||||
return;
|
||||
}
|
||||
setQuickWhereCondition(normalized);
|
||||
}, []);
|
||||
|
||||
const exportSqlWithFilter = useMemo(() => {
|
||||
const tableName = String(tab.tableName || '').trim();
|
||||
const dbType = resolveDataSourceType(currentConnConfig);
|
||||
if (!tableName || !dbType) return '';
|
||||
|
||||
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
||||
const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition);
|
||||
const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions);
|
||||
if (!whereSQL) return '';
|
||||
|
||||
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
@@ -869,7 +897,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
return sql;
|
||||
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, sortInfo, pkColumns]);
|
||||
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
const action = resolveDataViewerAutoFetchAction({
|
||||
@@ -886,7 +914,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
return;
|
||||
}
|
||||
fetchData(1, pagination.pageSize);
|
||||
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter
|
||||
|
||||
return (
|
||||
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -909,6 +937,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
onToggleFilter={handleToggleFilter}
|
||||
onApplyFilter={handleApplyFilter}
|
||||
appliedFilterConditions={filterConditions}
|
||||
quickWhereCondition={quickWhereCondition}
|
||||
onApplyQuickWhereCondition={handleApplyQuickWhereCondition}
|
||||
readOnly={forceReadOnly}
|
||||
sortInfoExternal={sortInfo}
|
||||
exportSqlWithFilter={exportSqlWithFilter || undefined}
|
||||
|
||||
113
frontend/src/utils/dataGridWhereFilter.test.ts
Normal file
113
frontend/src/utils/dataGridWhereFilter.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyWhereConditionSuggestion,
|
||||
buildEffectiveFilterConditions,
|
||||
buildQuickWhereFilterCondition,
|
||||
normalizeQuickWhereCondition,
|
||||
resolveWhereConditionSuggestions,
|
||||
resolveWhereConditionSelectedValue,
|
||||
validateQuickWhereCondition,
|
||||
} from './dataGridWhereFilter';
|
||||
|
||||
describe('dataGridWhereFilter', () => {
|
||||
it('normalizes pasted WHERE clauses to condition bodies', () => {
|
||||
expect(normalizeQuickWhereCondition(' WHERE status = 1; ')).toBe('status = 1');
|
||||
expect(normalizeQuickWhereCondition('\nwhere name like \'A%\'\n')).toBe("name like 'A%'");
|
||||
});
|
||||
|
||||
it('rejects multi statement or commented quick where conditions', () => {
|
||||
expect(validateQuickWhereCondition('status = 1')).toEqual({ ok: true });
|
||||
expect(validateQuickWhereCondition('status = 1; drop table users')).toEqual({
|
||||
ok: false,
|
||||
message: 'WHERE 条件不能包含分号或 SQL 注释',
|
||||
});
|
||||
expect(validateQuickWhereCondition('status = 1 -- bypass')).toEqual({
|
||||
ok: false,
|
||||
message: 'WHERE 条件不能包含分号或 SQL 注释',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges structured filters with a quick custom where condition', () => {
|
||||
const effective = buildEffectiveFilterConditions(
|
||||
[{ id: 1, column: 'status', op: '=', value: 'A', logic: 'AND' }],
|
||||
'amount > 100',
|
||||
);
|
||||
|
||||
expect(effective).toEqual([
|
||||
{ id: 1, column: 'status', op: '=', value: 'A', logic: 'AND' },
|
||||
{
|
||||
id: -1,
|
||||
enabled: true,
|
||||
logic: 'AND',
|
||||
column: '',
|
||||
op: 'CUSTOM',
|
||||
value: 'amount > 100',
|
||||
value2: '',
|
||||
},
|
||||
]);
|
||||
expect(buildQuickWhereFilterCondition('')).toBeNull();
|
||||
});
|
||||
|
||||
it('suggests columns, operators and keywords for quick where editing', () => {
|
||||
const columnSuggestions = resolveWhereConditionSuggestions({
|
||||
input: 'sta',
|
||||
columnNames: ['status', 'created_at'],
|
||||
dbType: 'mysql',
|
||||
});
|
||||
expect(columnSuggestions[0]).toMatchObject({
|
||||
label: 'status',
|
||||
kind: 'column',
|
||||
value: '`status`',
|
||||
});
|
||||
|
||||
const operatorSuggestions = resolveWhereConditionSuggestions({
|
||||
input: 'status ',
|
||||
columnNames: ['status'],
|
||||
dbType: 'mysql',
|
||||
});
|
||||
expect(operatorSuggestions.map((item) => item.label)).toContain('LIKE');
|
||||
|
||||
const quotedOperatorSuggestions = resolveWhereConditionSuggestions({
|
||||
input: '`username` ',
|
||||
columnNames: ['username'],
|
||||
dbType: 'mysql',
|
||||
});
|
||||
expect(quotedOperatorSuggestions.find((item) => item.label === '=')?.value).toBe('`username` = ');
|
||||
|
||||
const keywordSuggestions = resolveWhereConditionSuggestions({
|
||||
input: 'status = 1 a',
|
||||
columnNames: ['status'],
|
||||
dbType: 'mysql',
|
||||
});
|
||||
expect(keywordSuggestions.map((item) => item.label)).toContain('AND');
|
||||
});
|
||||
|
||||
it('applies a suggestion to the current trailing token', () => {
|
||||
expect(applyWhereConditionSuggestion('status = 1 a', 'AND ')).toBe('status = 1 AND ');
|
||||
expect(applyWhereConditionSuggestion('', '`user`')).toBe('`user`');
|
||||
});
|
||||
|
||||
it('keeps a completed quoted column intact when applying an operator suggestion', () => {
|
||||
expect(applyWhereConditionSuggestion('`字段名`', '= ')).toBe('`字段名` = ');
|
||||
expect(applyWhereConditionSuggestion('`字段名` ', '= ')).toBe('`字段名` = ');
|
||||
expect(applyWhereConditionSuggestion('"字段名"', 'LIKE ')).toBe('"字段名" LIKE ');
|
||||
});
|
||||
|
||||
it('uses the selected autocomplete value once without appending it again', () => {
|
||||
expect(
|
||||
resolveWhereConditionSelectedValue({
|
||||
selectedValue: '`username`',
|
||||
currentInput: '`username`',
|
||||
insertText: '`username`',
|
||||
}),
|
||||
).toBe('`username`');
|
||||
expect(
|
||||
resolveWhereConditionSelectedValue({
|
||||
selectedValue: '`username` = ',
|
||||
currentInput: '`username` = ',
|
||||
insertText: '= ',
|
||||
}),
|
||||
).toBe('`username` = ');
|
||||
});
|
||||
});
|
||||
242
frontend/src/utils/dataGridWhereFilter.ts
Normal file
242
frontend/src/utils/dataGridWhereFilter.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { quoteIdentPart, type FilterCondition } from './sql';
|
||||
|
||||
export type WhereConditionSuggestionKind = 'column' | 'operator' | 'keyword';
|
||||
|
||||
export type WhereConditionSuggestion = {
|
||||
label: string;
|
||||
value: string;
|
||||
insertText: string;
|
||||
detail: string;
|
||||
kind: WhereConditionSuggestionKind;
|
||||
};
|
||||
|
||||
const QUICK_WHERE_CONDITION_ID = -1;
|
||||
|
||||
const WHERE_KEYWORDS = [
|
||||
'AND',
|
||||
'OR',
|
||||
'NOT',
|
||||
'IS',
|
||||
'NULL',
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
'IN',
|
||||
'LIKE',
|
||||
'BETWEEN',
|
||||
'EXISTS',
|
||||
];
|
||||
|
||||
const WHERE_OPERATORS = [
|
||||
'=',
|
||||
'!=',
|
||||
'<>',
|
||||
'>',
|
||||
'>=',
|
||||
'<',
|
||||
'<=',
|
||||
'LIKE',
|
||||
'NOT LIKE',
|
||||
'IN',
|
||||
'BETWEEN',
|
||||
'IS NULL',
|
||||
'IS NOT NULL',
|
||||
];
|
||||
|
||||
const toTrimmedString = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeSuggestionPrefix = (value: string): string => {
|
||||
const text = String(value || '');
|
||||
if (!text || /\s$/.test(text)) return '';
|
||||
|
||||
const identifierMatch = text.match(/([A-Za-z_][A-Za-z0-9_$]*)$/);
|
||||
if (identifierMatch) return identifierMatch[1];
|
||||
|
||||
const isBoundary = (char: string | undefined) => !char || /[\s([,{=<>!]/.test(char);
|
||||
const boundaryIndex = Math.max(
|
||||
text.lastIndexOf(' '),
|
||||
text.lastIndexOf('\t'),
|
||||
text.lastIndexOf('\n'),
|
||||
text.lastIndexOf('('),
|
||||
text.lastIndexOf('['),
|
||||
text.lastIndexOf(','),
|
||||
text.lastIndexOf('{'),
|
||||
text.lastIndexOf('='),
|
||||
text.lastIndexOf('<'),
|
||||
text.lastIndexOf('>'),
|
||||
text.lastIndexOf('!'),
|
||||
);
|
||||
|
||||
for (const quote of ['`', '"']) {
|
||||
const start = text.lastIndexOf(quote);
|
||||
if (start < 0 || !isBoundary(text[start - 1])) continue;
|
||||
const tokenStart = boundaryIndex + 1;
|
||||
const tokenHead = text.slice(tokenStart, start);
|
||||
if (tokenHead.includes(quote)) continue;
|
||||
return text.slice(start);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const shouldSuggestOperators = (input: string): boolean => {
|
||||
return /\s$/.test(input) && /(?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]+"|`[^`]+`)\s$/.test(input);
|
||||
};
|
||||
|
||||
const toOperatorInsertText = (operator: string): string => {
|
||||
if (operator === 'IN') return 'IN ()';
|
||||
if (operator === 'BETWEEN') return 'BETWEEN AND ';
|
||||
return `${operator} `;
|
||||
};
|
||||
|
||||
export const normalizeQuickWhereCondition = (value: unknown): string => {
|
||||
let text = toTrimmedString(value);
|
||||
text = text.replace(/^where\b/i, '').trim();
|
||||
text = text.replace(/;+\s*$/, '').trim();
|
||||
return text;
|
||||
};
|
||||
|
||||
export const validateQuickWhereCondition = (
|
||||
value: unknown,
|
||||
): { ok: true } | { ok: false; message: string } => {
|
||||
const text = normalizeQuickWhereCondition(value);
|
||||
if (!text) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (/[;]/.test(text) || /--|\/\*/.test(text)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'WHERE 条件不能包含分号或 SQL 注释',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
export const buildQuickWhereFilterCondition = (
|
||||
value: unknown,
|
||||
): FilterCondition | null => {
|
||||
const text = normalizeQuickWhereCondition(value);
|
||||
if (!text) return null;
|
||||
return {
|
||||
id: QUICK_WHERE_CONDITION_ID,
|
||||
enabled: true,
|
||||
logic: 'AND',
|
||||
column: '',
|
||||
op: 'CUSTOM',
|
||||
value: text,
|
||||
value2: '',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildEffectiveFilterConditions = (
|
||||
conditions: FilterCondition[] | undefined,
|
||||
quickWhereCondition: unknown,
|
||||
): FilterCondition[] => {
|
||||
const baseConditions = Array.isArray(conditions) ? conditions : [];
|
||||
const quickCondition = buildQuickWhereFilterCondition(quickWhereCondition);
|
||||
if (!quickCondition) {
|
||||
return baseConditions;
|
||||
}
|
||||
return [...baseConditions, quickCondition];
|
||||
};
|
||||
|
||||
export const applyWhereConditionSuggestion = (
|
||||
input: string,
|
||||
insertText: string,
|
||||
): string => {
|
||||
const text = String(input || '');
|
||||
const prefix = normalizeSuggestionPrefix(text);
|
||||
if (!prefix) {
|
||||
if (text && !/\s$/.test(text) && !/[([,{=<>!]$/.test(text)) {
|
||||
return `${text} ${insertText}`;
|
||||
}
|
||||
return `${text}${insertText}`;
|
||||
}
|
||||
return `${text.slice(0, text.length - prefix.length)}${insertText}`;
|
||||
};
|
||||
|
||||
export const resolveWhereConditionSelectedValue = ({
|
||||
selectedValue,
|
||||
currentInput,
|
||||
insertText,
|
||||
}: {
|
||||
selectedValue: unknown;
|
||||
currentInput: unknown;
|
||||
insertText?: unknown;
|
||||
}): string => {
|
||||
const selectedText = String(selectedValue ?? '');
|
||||
if (selectedText) {
|
||||
return selectedText;
|
||||
}
|
||||
const insertTextValue = String(insertText ?? '');
|
||||
if (!insertTextValue) {
|
||||
return String(currentInput ?? '');
|
||||
}
|
||||
return applyWhereConditionSuggestion(String(currentInput ?? ''), insertTextValue);
|
||||
};
|
||||
|
||||
export const resolveWhereConditionSuggestions = ({
|
||||
input,
|
||||
columnNames,
|
||||
dbType,
|
||||
}: {
|
||||
input: string;
|
||||
columnNames: string[];
|
||||
dbType: string;
|
||||
}): WhereConditionSuggestion[] => {
|
||||
const text = String(input || '');
|
||||
const prefix = normalizeSuggestionPrefix(text).replace(/^["`]/, '').toLowerCase();
|
||||
const options: WhereConditionSuggestion[] = [];
|
||||
|
||||
if (shouldSuggestOperators(text)) {
|
||||
WHERE_OPERATORS.forEach((operator) => {
|
||||
const insertText = toOperatorInsertText(operator);
|
||||
options.push({
|
||||
label: operator,
|
||||
insertText,
|
||||
value: applyWhereConditionSuggestion(text, insertText),
|
||||
detail: '操作符',
|
||||
kind: 'operator',
|
||||
});
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
(columnNames || [])
|
||||
.map((column) => toTrimmedString(column))
|
||||
.filter(Boolean)
|
||||
.filter((column) => !prefix || column.toLowerCase().startsWith(prefix))
|
||||
.slice(0, 30)
|
||||
.forEach((column) => {
|
||||
const insertText = quoteIdentPart(dbType, column);
|
||||
options.push({
|
||||
label: column,
|
||||
insertText,
|
||||
value: applyWhereConditionSuggestion(text, insertText),
|
||||
detail: '字段',
|
||||
kind: 'column',
|
||||
});
|
||||
});
|
||||
|
||||
WHERE_KEYWORDS
|
||||
.filter((keyword) => !prefix || keyword.toLowerCase().startsWith(prefix))
|
||||
.forEach((keyword) => {
|
||||
const insertText = `${keyword} `;
|
||||
options.push({
|
||||
label: keyword,
|
||||
insertText,
|
||||
value: applyWhereConditionSuggestion(text, insertText),
|
||||
detail: '关键字',
|
||||
kind: 'keyword',
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
Reference in New Issue
Block a user