diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.tsx index ee94abf4ea8..00027068624 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -19,7 +19,6 @@ export const QueryTab = () => { const querySnap = useExplorerQueryStateSnapshot() const roleImpersonationState = useLocalRoleImpersonationState() - const [rowLimit, setRowLimit] = useState(100) const [restoredQueryKey, setRestoredQueryKey] = useState() const stateDraft = id ? querySnap.drafts[id] : undefined @@ -84,7 +83,7 @@ export const QueryTab = () => { : { ...toQuerySourceBinding(draft), uncheckedSql: draft.uncheckedSql, - rowLimit, + rowLimit: draft.rowLimit, } return ( @@ -103,7 +102,7 @@ export const QueryTab = () => { onSqlChange={(sql) => explorerQueryState.updateDraft({ id, sql })} onSourceChange={(source) => explorerQueryState.updateDraft({ id, source })} onResultChange={handleResultChange} - onRowLimitChange={setRowLimit} + onRowLimitChange={(rowLimit) => explorerQueryState.updateDraft({ id, rowLimit })} /> ) } diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts index e23f7f9fbdb..5a8c954e391 100644 --- a/apps/studio/state/explorer-query.test.ts +++ b/apps/studio/state/explorer-query.test.ts @@ -202,6 +202,97 @@ describe('explorer query drafts', () => { expect(persisted[`query-${MAX_PERSISTED_EXPLORER_QUERY_DRAFTS}`]).toBeDefined() }) + it('persists and restores a per-draft row limit independently of other drafts', () => { + const storage = createMemoryStorage() + const state = createExplorerQueryState(storage) + + state.createDraft({ id: 'query-1', projectRef: 'project-a' }) + state.createDraft({ id: 'query-2', projectRef: 'project-a' }) + state.updateDraft({ id: 'query-1', rowLimit: 500 }) + + expect(state.drafts['query-1']).toMatchObject({ rowLimit: 500 }) + expect(state.drafts['query-2']).toMatchObject({ rowLimit: 100 }) + + const restored = createExplorerQueryState(storage) + expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(restored.restoreDraft({ id: 'query-2', projectRef: 'project-a' })).toBe(true) + expect(restored.drafts['query-1']).toMatchObject({ rowLimit: 500 }) + expect(restored.drafts['query-2']).toMatchObject({ rowLimit: 100 }) + }) + + it('defaults the row limit for drafts persisted before row limits existed', () => { + const storage = createMemoryStorage() + storage.setItem( + LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), + JSON.stringify({ + 'query-1': { name: 'Legacy query', sql: 'select 1', updatedAt: 1 }, + }) + ) + + const state = createExplorerQueryState(storage) + expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 }) + }) + + it('accepts every row limit the row limit menu can produce', () => { + const storage = createMemoryStorage() + + for (const rowLimit of [-1, 100, 500, 1000]) { + storage.setItem( + LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), + JSON.stringify({ + 'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit }, + }) + ) + + const state = createExplorerQueryState(storage) + expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(state.drafts['query-1']).toMatchObject({ rowLimit }) + } + }) + + it('normalizes a fractional persisted row limit to the default', () => { + const storage = createMemoryStorage() + storage.setItem( + LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), + JSON.stringify({ + 'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 100.5 }, + }) + ) + + const state = createExplorerQueryState(storage) + expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 }) + }) + + it('normalizes an out-of-range persisted row limit to the default', () => { + const storage = createMemoryStorage() + storage.setItem( + LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), + JSON.stringify({ + 'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 999999 }, + }) + ) + + const state = createExplorerQueryState(storage) + expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100 }) + }) + + it('normalizes a non-numeric persisted row limit to the default without dropping the draft', () => { + const storage = createMemoryStorage() + storage.setItem( + LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), + JSON.stringify({ + 'query-1': { name: 'Query', sql: 'select 1', updatedAt: 1, rowLimit: 'unlimited' }, + }) + ) + + const state = createExplorerQueryState(storage) + expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(state.drafts['query-1']).toMatchObject({ rowLimit: 100, uncheckedSql: 'select 1' }) + }) + it('removes the persisted draft and its session result when its tab closes', () => { const storage = createMemoryStorage() const state = createExplorerQueryState(storage) diff --git a/apps/studio/state/explorer-query.ts b/apps/studio/state/explorer-query.ts index ae1f1972c61..5e6f6cb6898 100644 --- a/apps/studio/state/explorer-query.ts +++ b/apps/studio/state/explorer-query.ts @@ -3,7 +3,9 @@ import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { proxy, ref, snapshot, useSnapshot } from 'valtio' import { z } from 'zod' +import { DEFAULT_CELL_ROW_LIMIT } from '@/components/interfaces/Explorer/QueryCell/QueryCell.utils' import { type QueryResult } from '@/components/interfaces/Explorer/types' +import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants' import { type DatabaseSourceParameters, type LogsSourceParameters, @@ -33,6 +35,7 @@ export type DatabaseQueryDraft = ExplorerQueryDraftBase & DatabaseSourceParameters & { _tag: 'database' uncheckedSql: UntrustedSqlFragment + rowLimit: number } export type LogsQueryDraft = ExplorerQueryDraftBase & @@ -57,6 +60,7 @@ type PersistedExplorerQueryDraft = { source: QuerySourceBinding sql: string updatedAt: number + rowLimit?: number } type PersistedExplorerQueryDrafts = Record @@ -72,8 +76,23 @@ const persistedDraftSchema = z.object({ sql: z.string(), updatedAt: z.number(), source: z.unknown().optional(), + rowLimit: z.unknown().optional(), }) +const VALID_ROW_LIMITS = ROWS_PER_PAGE_OPTIONS.map((option) => option.value) + +/** + * Falls back to the default whenever a persisted row limit isn't one of the values the row + * limit menu can actually produce — e.g. a fractional or out-of-range number from corrupted + * or hand-edited storage. `undefined` (never persisted) passes through unchanged; `toDraft` + * applies the default for that case. + */ +const rowLimitSchema = z + .number() + .refine((value) => VALID_ROW_LIMITS.includes(value)) + .catch(DEFAULT_CELL_ROW_LIMIT) + .optional() + /** * Rebuilds a draft from its persisted form, branding the SQL for the backend the binding * names. The single place a stored string re-enters the type system as untrusted SQL, which @@ -104,6 +123,7 @@ const toDraft = ({ _tag: 'database', database_identifier: persisted.source.database_identifier, uncheckedSql: untrustedSql(persisted.sql), + rowLimit: persisted.rowLimit ?? DEFAULT_CELL_ROW_LIMIT, } } @@ -125,6 +145,8 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => { ? parsedSource.data : createDefaultSourceBinding('database') + const rowLimit = rowLimitSchema.parse(draft.data.rowLimit) + return [ [ id, @@ -133,6 +155,7 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => { source, sql: draft.data.sql, updatedAt: draft.data.updatedAt, + rowLimit, }, ], ] @@ -172,6 +195,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage source: toQuerySourceBinding(draft), sql: draft.uncheckedSql, updatedAt: draft.updatedAt, + rowLimit: draft._tag === 'database' ? draft.rowLimit : undefined, } writePersistedDrafts(storage, draft.projectRef, persisted) } @@ -186,12 +210,14 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage name = 'Untitled query', sql = '', source = createDefaultSourceBinding('database'), + rowLimit = DEFAULT_CELL_ROW_LIMIT, }: { id: string projectRef: string name?: string sql?: string source?: QuerySourceBinding + rowLimit?: number }) => { const draft = toDraft({ id, @@ -201,6 +227,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage source: querySourceBindingSchema.parse(source), sql, updatedAt: Date.now(), + rowLimit, }, }) @@ -237,11 +264,13 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage name, source, sql, + rowLimit, }: { id: string name?: string source?: QuerySourceBinding sql?: string + rowLimit?: number }) => { const draft = state.drafts[id] if (!draft) return @@ -249,6 +278,8 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage const nextSource = source === undefined ? undefined : querySourceBindingSchema.parse(source) if (nextSource !== undefined && nextSource._tag !== draft._tag) delete state.results[id] + const currentRowLimit = draft._tag === 'database' ? draft.rowLimit : undefined + state.drafts[id] = toDraft({ id, projectRef: draft.projectRef, @@ -257,6 +288,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage source: nextSource ?? toQuerySourceBinding(draft), sql: sql ?? draft.uncheckedSql, updatedAt: Date.now(), + rowLimit: rowLimit ?? currentRowLimit, }, }) @@ -273,7 +305,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage const pending = pendingPersistence.get(id) if (pending) clearTimeout(pending.timeout) - if (name !== undefined || source !== undefined) persist() + if (name !== undefined || source !== undefined || rowLimit !== undefined) persist() else { const timeout = setTimeout(persist, EXPLORER_QUERY_PERSIST_DELAY) pendingPersistence.set(id, { timeout, persist })