Skip to content

Commit 09251c4

Browse files
committed
feat: implement pagination for TableView and query result limiting
- Add reusable Pagination UI component with keyboard shortcuts - Update TableView to support pagination with configurable page size - Add automatic query limiting (100 rows) for safety in QueryWorkspace - Show warning with Radix icon when queries are limited with option to run unlimited - Update backend to return total row count for paginated queries - Add query parser utility to detect and modify SQL queries - Update export functionality to handle current page vs all data - Reset limit override when query or tab changes
1 parent 3731c96 commit 09251c4

File tree

10 files changed

+402
-17
lines changed

10 files changed

+402
-17
lines changed

src/main/database/clickhouse.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,18 +292,21 @@ class ClickHouseManager extends BaseDatabaseManager {
292292
// ClickHouse uses backticks for identifiers
293293
const qualifiedTable = database ? `\`${database}\`.\`${table}\`` : `\`${table}\``
294294

295-
let sql = `SELECT * FROM ${qualifiedTable}`
295+
let baseQuery = `FROM ${qualifiedTable}`
296296

297297
// Add WHERE clause if filters exist
298298
if (filters && filters.length > 0) {
299299
const whereClauses = filters
300300
.map((filter) => this.buildClickHouseWhereClause(filter))
301301
.filter(Boolean)
302302
if (whereClauses.length > 0) {
303-
sql += ` WHERE ${whereClauses.join(' AND ')}`
303+
baseQuery += ` WHERE ${whereClauses.join(' AND ')}`
304304
}
305305
}
306306

307+
// Build the main SELECT query
308+
let sql = `SELECT * ${baseQuery}`
309+
307310
// Add ORDER BY clause
308311
if (orderBy && orderBy.length > 0) {
309312
const orderClauses = orderBy.map((o) => `\`${o.column}\` ${o.direction.toUpperCase()}`)
@@ -318,7 +321,26 @@ class ClickHouseManager extends BaseDatabaseManager {
318321
sql += ` OFFSET ${offset}`
319322
}
320323

321-
return this.query(connectionId, sql, sessionId)
324+
// Execute the main query
325+
const result = await this.query(connectionId, sql, sessionId)
326+
327+
// If successful and we have pagination, get the total count
328+
if (result.success && (limit || offset)) {
329+
try {
330+
const countSql = `SELECT count() as total ${baseQuery}`
331+
const countResult = await this.query(connectionId, countSql)
332+
333+
if (countResult.success && countResult.data && countResult.data[0]) {
334+
result.totalRows = Number(countResult.data[0].total)
335+
result.hasMore = offset + (result.data?.length || 0) < result.totalRows
336+
}
337+
} catch (error) {
338+
// If count fails, continue without it
339+
console.warn('Failed to get total count:', error)
340+
}
341+
}
342+
343+
return result
322344
}
323345

324346
private buildClickHouseWhereClause(filter: TableFilter): string {

src/main/database/interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface QueryResult {
4545
affectedRows?: number
4646
isDDL?: boolean
4747
isDML?: boolean
48+
totalRows?: number // Total rows available (for pagination)
49+
hasMore?: boolean // Indicates if there are more rows beyond current result set
4850
}
4951

5052
export interface InsertResult extends QueryResult {

src/renderer/components/QueryWorkspace/QueryWorkspace.tsx

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@ import { QueryTabs } from '../QueryTabs/QueryTabs'
77
import { TableView } from '../TableView/TableView'
88
import { AIAssistant } from '../AIAssistant'
99
import { SqlEditor } from './SqlEditor'
10+
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
1011

1112
import { exportToCSV, exportToJSON } from '../../utils/exportData'
1213
import { Tab, QueryTab, TableTab, QueryExecutionResult } from '../../types/tabs'
14+
import {
15+
isSelectQuery,
16+
hasLimitClause,
17+
addLimitToQuery,
18+
mightReturnLargeResultSet
19+
} from '../../utils/queryParser'
1320
import './QueryWorkspace.css'
1421

22+
23+
const DEFAULT_LIMIT = 100
24+
1525
interface QueryWorkspaceProps {
1626
connectionId: string
1727
onOpenTableTab?: (database: string, tableName: string) => void
@@ -33,7 +43,9 @@ export function QueryWorkspace({ connectionId, onOpenTableTab }: QueryWorkspaceP
3343
const [selectedText, setSelectedText] = useState('')
3444
const [showAIPanel, setShowAIPanel] = useState(false)
3545
const editorRef = useRef<any>(null)
36-
const executeQueryRef = useRef<() => void>()
46+
const executeQueryRef = useRef<() => void>(() => {})
47+
const [showLimitWarning, setShowLimitWarning] = useState(false)
48+
const [queryLimitOverride, setQueryLimitOverride] = useState(false)
3749

3850
const activeTab = tabs.find((tab) => tab.id === activeTabId)
3951
const activeResult = activeTab ? results[activeTab.id] : null
@@ -96,6 +108,8 @@ export function QueryWorkspace({ connectionId, onOpenTableTab }: QueryWorkspaceP
96108

97109
const handleSelectTab = useCallback((tabId: string) => {
98110
setActiveTabId(tabId)
111+
setShowLimitWarning(false)
112+
setQueryLimitOverride(false)
99113
}, [])
100114

101115
const handleUpdateTabTitle = useCallback(
@@ -132,7 +146,7 @@ export function QueryWorkspace({ connectionId, onOpenTableTab }: QueryWorkspaceP
132146
}, [openTableTab, onOpenTableTab])
133147

134148
const executeQuery = useCallback(
135-
async (queryToExecute: string) => {
149+
async (queryToExecute: string, forceUnlimited = false) => {
136150
if (!activeTab || activeTab.type !== 'query') return
137151

138152
if (!queryToExecute.trim()) return
@@ -141,7 +155,22 @@ export function QueryWorkspace({ connectionId, onOpenTableTab }: QueryWorkspaceP
141155
setIsExecuting(true)
142156
const startTime = Date.now()
143157

144-
const queryResult = await window.api.database.query(connectionId, queryToExecute.trim())
158+
let finalQuery = queryToExecute.trim()
159+
160+
// Check if we should add a limit
161+
if (
162+
isSelectQuery(finalQuery) &&
163+
!hasLimitClause(finalQuery) &&
164+
!forceUnlimited &&
165+
!queryLimitOverride
166+
) {
167+
if (mightReturnLargeResultSet(finalQuery)) {
168+
setShowLimitWarning(true)
169+
finalQuery = addLimitToQuery(finalQuery, DEFAULT_LIMIT)
170+
}
171+
}
172+
173+
const queryResult = await window.api.database.query(connectionId, finalQuery)
145174
const executionTime = Date.now() - startTime
146175

147176
const result: QueryExecutionResult = {
@@ -164,7 +193,7 @@ export function QueryWorkspace({ connectionId, onOpenTableTab }: QueryWorkspaceP
164193
setIsExecuting(false)
165194
}
166195
},
167-
[activeTab, results, connectionId]
196+
[activeTab, results, connectionId, queryLimitOverride]
168197
)
169198

170199
const handleExecuteQuery = useCallback(async () => {
@@ -346,12 +375,15 @@ export function QueryWorkspace({ connectionId, onOpenTableTab }: QueryWorkspaceP
346375
<SqlEditor
347376
connectionId={connectionId}
348377
value={activeTab.query}
349-
onChange={(value) =>
378+
onChange={(value) => {
350379
handleUpdateTabContent(activeTab.id, {
351380
query: value || '',
352381
isDirty: true
353382
})
354-
}
383+
// Reset limit override when query changes
384+
setQueryLimitOverride(false)
385+
setShowLimitWarning(false)
386+
}}
355387
onMount={handleEditorDidMount}
356388
onSelectionChange={setSelectedText}
357389
height="100%"
@@ -391,6 +423,9 @@ export function QueryWorkspace({ connectionId, onOpenTableTab }: QueryWorkspaceP
391423
<>
392424
<Badge size="1" variant="soft">
393425
{activeResult.rowCount || activeResult.data.length} rows
426+
{activeResult.data.length === 1000 &&
427+
!hasLimitClause(editorRef.current?.getValue() || '') &&
428+
' (limited)'}
394429
</Badge>
395430
{activeResult.executionTime && (
396431
<Badge size="1" variant="soft" color="gray">
@@ -421,6 +456,45 @@ export function QueryWorkspace({ connectionId, onOpenTableTab }: QueryWorkspaceP
421456
)}
422457
</Flex>
423458

459+
{/* Limit warning */}
460+
{showLimitWarning && activeResult?.success && (
461+
<Box
462+
p="2"
463+
style={{
464+
backgroundColor: 'var(--amber-3)',
465+
borderBottom: '1px solid var(--amber-6)'
466+
}}
467+
>
468+
<Flex align="center" justify="between">
469+
<Flex align="center" gap="2">
470+
<ExclamationTriangleIcon color="var(--amber-11)" />
471+
<Text size="1" color="amber" weight="medium">
472+
Query limited to {DEFAULT_LIMIT} rows for safety
473+
</Text>
474+
<Text size="1" color="gray">
475+
Remove LIMIT to see all results
476+
</Text>
477+
</Flex>
478+
<Flex gap="2">
479+
<Button
480+
size="1"
481+
variant="soft"
482+
onClick={() => {
483+
setShowLimitWarning(false)
484+
setQueryLimitOverride(true)
485+
handleExecuteQuery()
486+
}}
487+
>
488+
Run without limit
489+
</Button>
490+
<Button size="1" variant="ghost" onClick={() => setShowLimitWarning(false)}>
491+
Dismiss
492+
</Button>
493+
</Flex>
494+
</Flex>
495+
</Box>
496+
)}
497+
424498
<Box className="results-content" style={{ flex: 1 }}>
425499
{activeResult ? (
426500
activeResult.success ? (

0 commit comments

Comments
 (0)