From 30301cd63791b8f41849c372e4b2e329745c775b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 26 Apr 2026 20:06:15 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(data-grid):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=BF=AB=E9=80=9F=20WHERE=20=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E4=B8=8E=E8=A1=A5=E5=85=A8=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增表格筛选面板快速 WHERE 条件输入 - 支持字段、操作符和关键字自动补全 - 查询、分页统计与筛选导出合并快速 WHERE 条件 - 修复补全过程中的字段引号丢失和重复追加问题 Refs #354 --- .../src/components/DataGrid.layout.test.tsx | 24 ++ frontend/src/components/DataGrid.tsx | 131 +++++++++- frontend/src/components/DataViewer.tsx | 48 +++- .../src/utils/dataGridWhereFilter.test.ts | 113 ++++++++ frontend/src/utils/dataGridWhereFilter.ts | 242 ++++++++++++++++++ 5 files changed, 544 insertions(+), 14 deletions(-) create mode 100644 frontend/src/utils/dataGridWhereFilter.test.ts create mode 100644 frontend/src/utils/dataGridWhereFilter.ts diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index d2795c4..9ae88f6 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -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( + {}} + />, + ); + + expect(markup).toContain('data-grid-quick-where="true"'); + expect(markup).toContain('WHERE'); + expect(markup).toContain('输入 WHERE 后面的条件'); + }); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 0c05295..d3ac6cc 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -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 = ({ 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 = ({ // Filter State const [filterConditions, setFilterConditions] = useState([]); const [nextFilterId, setNextFilterId] = useState(1); + const [quickWhereDraft, setQuickWhereDraft] = useState(() => normalizeQuickWhereCondition(quickWhereCondition)); const filterPanelRef = useRef(null); useEffect(() => { @@ -2208,6 +2219,29 @@ const DataGrid: React.FC = ({ 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: ( +
+ {item.label} + {item.detail} +
+ ), + })); + }, [allTableColumnNames, displayColumnNames, quickWhereDraft, dbType, darkMode]); + useEffect(() => { if (!showFilter) { return; @@ -4032,9 +4066,10 @@ const DataGrid: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ display: 'flex', flexDirection: 'column', }}> +
+ + WHERE + + { + setQuickWhereDraft(resolveWhereConditionSelectedValue({ + selectedValue: value, + currentInput: quickWhereDraft, + insertText: (option as any)?.insertText, + })); + }} + style={{ flex: '1 1 320px', minWidth: 220 }} + popupMatchSelectWidth={420} + > + { + if (!event.shiftKey) { + event.preventDefault(); + applyQuickWhereCondition(); + } + }} + /> + + + +
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
{filterConditions.map((cond, condIndex) => ( @@ -5316,6 +5436,7 @@ const DataGrid: React.FC = ({ diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index bcdf1bf..cb259c1 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -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(initialViewerSnapshot.showFilter); const [filterConditions, setFilterConditions] = useState(initialViewerSnapshot.conditions); + const [quickWhereCondition, setQuickWhereCondition] = useState(initialViewerSnapshot.quickWhereCondition); const duckdbSafeSelectCacheRef = useRef>({}); 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 | 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 (
@@ -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} diff --git a/frontend/src/utils/dataGridWhereFilter.test.ts b/frontend/src/utils/dataGridWhereFilter.test.ts new file mode 100644 index 0000000..d8771c3 --- /dev/null +++ b/frontend/src/utils/dataGridWhereFilter.test.ts @@ -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` = '); + }); +}); diff --git a/frontend/src/utils/dataGridWhereFilter.ts b/frontend/src/utils/dataGridWhereFilter.ts new file mode 100644 index 0000000..de44983 --- /dev/null +++ b/frontend/src/utils/dataGridWhereFilter.ts @@ -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; +};