From cc6fe2100a728dfa3d63b8bb647247c883b97b8e Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 13 Aug 2026 19:51:06 +1000 Subject: [PATCH] refactor(studio): centralize query sources (#49027) ## Summary - define application-owned database and logs source contracts, defaults, validation, labels, and execution endpoints - extract controlled database and logs parameter controls for reuse outside SQL snippets - adapt the SQL editor to the shared source model without changing snippet behavior - standardize source icons at 16px with a 2px stroke - keep relative logs ranges aligned with the existing date picker units ## To test 1. Open an existing query in the SQL Editor and run it against the database. 2. Switch the query source to Logs, change the time range, and confirm the query still runs as expected. ## Why Explorer queries and notebook query cells need to select an execution source without coupling that source to SQL snippets. This provides the shared registry and controlled UI foundation for those consumers. ## Impact Existing SQL snippets retain their current database/logs routing and session behavior. The registry documents the SQL editor legacy database-selector adapter while new consumers own their identifier inline. The shared Logs date picker remains unchanged; query ranges support its existing minute, hour, and day units. This PR does not add the Explorer query tab itself. ## Validation - pnpm --filter studio typecheck - focused Vitest coverage for the registry, canonical log-range utilities, SQL execution adapters, source filtering, retention locking, custom ranges, and preset selection - pnpm --filter studio run lint:ratchet Component and state tests cover this change per the Studio testing guidance; no E2E test is added. ## Summary by CodeRabbit * **New Features** * Added a unified query-source menu for database queries and logs. * Added custom log time-range selection with calendar support and retention-aware upgrade prompts. * Added consistent source icons and improved database selection handling. * Added support for relative and absolute log time ranges. * **Bug Fixes** * Improved log-range validation, defaults, and current-time handling. * Updated query execution to use the correct source-specific endpoints. * **Tests** * Expanded coverage for query sources, log ranges, menus, and retention behavior. --- .../DatabaseParametersSubMenu.tsx} | 31 +-- .../QuerySources/LogTimeRange.utils.test.ts | 114 ++++++++ .../QuerySources/LogTimeRange.utils.ts | 97 +++++++ .../LogsCustomRangeDialog.tsx | 0 .../LogsTimeRangeSubMenu.test.tsx | 57 ++++ .../LogsTimeRangeSubMenu.tsx} | 27 +- .../QuerySources/QuerySourceIcon.tsx | 15 ++ .../QuerySources/useLogsCustomRange.ts | 35 +++ .../QuerySourceMenu/QuerySourceMenu.tsx | 121 ++++----- .../interfaces/SQLEditor/querySource.test.ts | 252 +----------------- .../interfaces/SQLEditor/querySource.ts | 130 +-------- .../SQLEditor/useLogsSqlExecution.test.tsx | 5 +- .../SQLEditor/useLogsSqlExecution.ts | 13 +- .../SQLEditor/useRunSource.test.tsx | 11 +- .../interfaces/SQLEditor/useRunSource.ts | 10 +- .../query-source-registry.test.ts | 116 ++++++++ .../query-sources/query-source-registry.ts | 152 +++++++++++ apps/studio/data/sql/execute-sql-mutation.ts | 3 +- .../sql-editor/sql-editor-session-state.ts | 8 +- .../SQLEditor/QuerySourceMenu.test.tsx | 68 +++-- .../features/logs/Logs.Datepickers.test.tsx | 4 +- 21 files changed, 735 insertions(+), 534 deletions(-) rename apps/studio/components/interfaces/{SQLEditor/UtilityPanel/QuerySourceMenu/DatabaseSubMenu.tsx => QuerySources/DatabaseParametersSubMenu.tsx} (74%) create mode 100644 apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts create mode 100644 apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts rename apps/studio/components/interfaces/{SQLEditor/UtilityPanel/QuerySourceMenu => QuerySources}/LogsCustomRangeDialog.tsx (100%) create mode 100644 apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx rename apps/studio/components/interfaces/{SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx => QuerySources/LogsTimeRangeSubMenu.tsx} (80%) create mode 100644 apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx create mode 100644 apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts create mode 100644 apps/studio/data/query-sources/query-source-registry.test.ts create mode 100644 apps/studio/data/query-sources/query-source-registry.ts diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/DatabaseSubMenu.tsx b/apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx similarity index 74% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/DatabaseSubMenu.tsx rename to apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx index 59458d69a99..f946f4e03f8 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/DatabaseSubMenu.tsx +++ b/apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import { Check, Plus } from 'lucide-react' import Link from 'next/link' import { @@ -12,9 +12,6 @@ import { import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' -import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' /** The label a database row/summary shows: the primary, or a replica by region + id. */ function databaseLabel(identifier: string, region: string, projectRef: string | undefined) { @@ -22,35 +19,27 @@ function databaseLabel(identifier: string, region: string, projectRef: string | return `Read replica (${formatDatabaseRegion(region)} - ${formatDatabaseID(identifier)})` } -export const DatabaseSubMenu = ({ id }: { id: string }) => { +export const DatabaseParametersSubMenu = ({ + identifier, + onIdentifierChange, +}: { + identifier?: string + onIdentifierChange: (identifier: string) => void +}) => { const { ref: projectRef } = useParams() - const sessionSnap = useSqlEditorSessionSnapshot() - const dbSelector = useDatabaseSelectorStateSnapshot() const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas']) - const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(projectRef ?? ''), - '' - ) - const { data } = useReadReplicasQuery({ projectRef }) const databases = (data ?? []) .slice() .sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : 0)) .sort((database) => (database.identifier === projectRef ? -1 : 0)) - const selectedDatabaseId = - lastSelectedDb.length > 0 ? lastSelectedDb : (dbSelector.selectedDatabaseId ?? projectRef) + const selectedDatabaseId = identifier ?? projectRef const selectedDatabase = databases.find((db) => db.identifier === selectedDatabaseId) const newReplicaURL = `/project/${projectRef}/database/replication?destinationType=Read+Replica` - const handleSelect = (databaseId: string) => { - dbSelector.setSelectedDatabaseId(databaseId) - setLastSelectedDb(databaseId) - sessionSnap.resetResult(id) - } - return ( @@ -71,7 +60,7 @@ export const DatabaseSubMenu = ({ id }: { id: string }) => { key={database.identifier} className="justify-between" disabled={isUnhealthy} - onClick={() => handleSelect(database.identifier)} + onClick={() => onIdentifierChange(database.identifier)} > {databaseLabel(database.identifier, database.region, projectRef)} {database.identifier === selectedDatabaseId && } diff --git a/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts new file mode 100644 index 00000000000..f12ec9cef66 --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.test.ts @@ -0,0 +1,114 @@ +import dayjs from 'dayjs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + customDateRangeToLogTimeRange, + datePickerValueToLogTimeRange, + logTimeRangesEqual, + logTimeRangeToDatePickerValue, + resolveLogTimeRange, +} from './LogTimeRange.utils' +import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import { + DEFAULT_LOG_TIME_RANGE, + type LogTimeRange, +} from '@/data/query-sources/query-source-registry' + +describe('LogTimeRange.utils', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-08T12:00:00.000Z')) + }) + + afterEach(() => vi.useRealTimers()) + + it.each([ + ['30m', 30, 'minute'], + ['2h', 2, 'hour'], + ['7d', 7, 'day'], + ] as const)('round-trips the picker helper for %s', (_, amount, unit) => { + const helper = generateDynamicHelper(amount, unit) + const pickerValue = { + from: helper.calcFrom(), + to: helper.calcTo(), + isHelper: true, + text: helper.text, + } + const range: LogTimeRange = { type: 'relative', amount, unit } + + expect(datePickerValueToLogTimeRange(pickerValue)).toEqual(range) + expect(logTimeRangeToDatePickerValue(range)).toEqual(pickerValue) + }) + + it('falls back to the default when a custom value has no valid start', () => { + expect(datePickerValueToLogTimeRange({ from: '', to: '', isHelper: false })).toEqual( + DEFAULT_LOG_TIME_RANGE + ) + }) + + it('uses now when an absolute helper has an empty end', () => { + expect( + datePickerValueToLogTimeRange({ + from: '2025-01-01T00:00:00.000Z', + to: '', + isHelper: true, + text: 'Custom', + }) + ).toEqual({ + type: 'absolute', + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-08T12:00:00.000Z', + }) + }) + + it('compares relative and absolute ranges structurally', () => { + expect( + logTimeRangesEqual( + { type: 'relative', amount: 1, unit: 'hour' }, + { type: 'relative', amount: 1, unit: 'hour' } + ) + ).toBe(true) + expect( + logTimeRangesEqual( + { type: 'relative', amount: 1, unit: 'hour' }, + { type: 'relative', amount: 1, unit: 'day' } + ) + ).toBe(false) + expect( + logTimeRangesEqual( + { type: 'absolute', from: '2025-01-01T00:00:00.000Z', to: '2025-01-02T00:00:00.000Z' }, + { type: 'absolute', from: '2025-01-01T00:00:00.000Z', to: '2025-01-02T00:00:00.000Z' } + ) + ).toBe(true) + }) + + it('resolves a relative range against the current time', () => { + expect(resolveLogTimeRange({ type: 'relative', amount: 2, unit: 'day' })).toEqual({ + from: dayjs().subtract(2, 'day').toISOString(), + to: dayjs().toISOString(), + }) + }) + + it('passes an absolute range through unchanged', () => { + const range: LogTimeRange = { + type: 'absolute', + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-02T00:00:00.000Z', + } + expect(resolveLogTimeRange(range)).toEqual({ from: range.from, to: range.to }) + }) + + it('clamps a custom range ending today to now', () => { + const from = new Date('2025-01-07T09:00:00.000Z') + expect( + customDateRangeToLogTimeRange({ + from, + to: new Date('2025-01-08T09:00:00.000Z'), + }) + ).toEqual({ + type: 'absolute', + from: dayjs(from).startOf('day').toISOString(), + to: '2025-01-08T12:00:00.000Z', + }) + }) +}) diff --git a/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts new file mode 100644 index 00000000000..4dd9f9ff100 --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/LogTimeRange.utils.ts @@ -0,0 +1,97 @@ +import dayjs from 'dayjs' + +import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' +import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange' +import { + DEFAULT_LOG_TIME_RANGE, + type LogTimeRange, +} from '@/data/query-sources/query-source-registry' +import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime' + +export type RelativeTimeUnit = Extract['unit'] + +const nowIsoDateTime = (): IsoDateTimeString => dayjs().toISOString() as IsoDateTimeString + +function parseRelativeHelperLabel( + text: string | undefined +): { amount: number; unit: RelativeTimeUnit } | null { + if (!text) return null + const match = text + .trim() + .toLowerCase() + .match(/^last\s+(?:(\d+)\s+)?(minute|hour|day)s?$/) + if (!match) return null + + const amount = match[1] ? parseInt(match[1], 10) : 1 + if (!Number.isFinite(amount) || amount <= 0) return null + + const unit = match[2] + if (unit !== 'minute' && unit !== 'hour' && unit !== 'day') return null + return { amount, unit } +} + +export function datePickerValueToLogTimeRange(value: DatePickerValue): LogTimeRange { + if (value.isHelper) { + const relative = parseRelativeHelperLabel(value.text) + if (relative) return { type: 'relative', ...relative } + } + + const from = isoDateTimeString(value.from) + if (from === null) return DEFAULT_LOG_TIME_RANGE + const to = isoDateTimeString(value.to) ?? nowIsoDateTime() + return { type: 'absolute', from, to } +} + +export function logTimeRangeToDatePickerValue(range: LogTimeRange): DatePickerValue { + if (range.type === 'relative') { + const helper = generateDynamicHelper(range.amount, range.unit) + return { + from: helper.calcFrom(), + to: helper.calcTo(), + isHelper: true, + text: helper.text, + } + } + return { from: range.from, to: range.to, isHelper: false } +} + +export function customDateRangeToLogTimeRange({ + from, + to, + now = new Date(), +}: { + from: Date + to: Date + now?: Date +}): Extract { + const nowValue = dayjs(now) + const requestedTo = dayjs(to).endOf('day') + + return { + type: 'absolute', + from: dayjs(from).startOf('day').toISOString(), + to: requestedTo.isAfter(nowValue) ? nowValue.toISOString() : requestedTo.toISOString(), + } +} + +export function logTimeRangesEqual(a: LogTimeRange, b: LogTimeRange): boolean { + if (a.type === 'relative' && b.type === 'relative') { + return a.amount === b.amount && a.unit === b.unit + } + if (a.type === 'absolute' && b.type === 'absolute') { + return a.from === b.from && a.to === b.to + } + return false +} + +export function resolveLogTimeRange(range: LogTimeRange): ResolvedLogDateRange { + if (range.type === 'relative') { + const now = dayjs() + return { + from: now.subtract(range.amount, range.unit).toISOString(), + to: now.toISOString(), + } + } + return { from: range.from, to: range.to } +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/LogsCustomRangeDialog.tsx b/apps/studio/components/interfaces/QuerySources/LogsCustomRangeDialog.tsx similarity index 100% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/LogsCustomRangeDialog.tsx rename to apps/studio/components/interfaces/QuerySources/LogsCustomRangeDialog.tsx diff --git a/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx new file mode 100644 index 00000000000..80d4c0fac1c --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.test.tsx @@ -0,0 +1,57 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' +import { describe, expect, it, vi } from 'vitest' + +import { LogsTimeRangeSubMenu } from './LogsTimeRangeSubMenu' +import { customRender } from '@/tests/lib/custom-render' + +mockAnimationsApi() + +vi.mock('@/hooks/misc/useCheckEntitlements', () => ({ + useCheckEntitlements: () => ({ getEntitlementNumericValue: () => 1 }), +})) + +const renderSubMenu = ({ + onRangeChange = vi.fn(), + onOpenCustomRange = vi.fn(), + onShowUpgrade = vi.fn(), +} = {}) => { + customRender( + + Open + + + + + ) + + return { onRangeChange, onOpenCustomRange, onShowUpgrade } +} + +describe('LogsTimeRangeSubMenu', () => { + it('opens the upgrade prompt instead of applying a range beyond retention', async () => { + const { onRangeChange, onShowUpgrade } = renderSubMenu() + + await userEvent.hover(await screen.findByText('Time range')) + await userEvent.click(await screen.findByText('Last 7 days')) + + expect(onShowUpgrade).toHaveBeenCalledOnce() + expect(onRangeChange).not.toHaveBeenCalled() + }) + + it('exposes the custom-range action', async () => { + const { onOpenCustomRange } = renderSubMenu() + + await userEvent.hover(await screen.findByText('Time range')) + await userEvent.click(await screen.findByText('Custom range…')) + + expect(onOpenCustomRange).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx similarity index 80% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx rename to apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx index f633d4a8a58..9830e206a14 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx +++ b/apps/studio/components/interfaces/QuerySources/LogsTimeRangeSubMenu.tsx @@ -8,42 +8,37 @@ import { DropdownMenuSubTrigger, } from 'ui' -import { - datePickerValueToLogDateRange, - logDateRangesEqual, - type LogDateRange, -} from '../../querySource' +import { datePickerValueToLogTimeRange, logTimeRangesEqual } from './LogTimeRange.utils' import { EXPLORER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants' import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import type { LogTimeRange } from '@/data/query-sources/query-source-registry' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' -import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' -export const TimeRangeSubMenu = ({ - id, +export const LogsTimeRangeSubMenu = ({ range, + onRangeChange, onOpenCustomRange, onShowUpgrade, }: { - id: string - range: LogDateRange + range: LogTimeRange + onRangeChange: (range: LogTimeRange) => void onOpenCustomRange: () => void onShowUpgrade: () => void }) => { - const sessionSnap = useSqlEditorSessionSnapshot() const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') const entitledToLogDays = getEntitlementNumericValue() - const isCustomRange = range.kind === 'absolute' + const isCustomRange = range.type === 'absolute' const presets = EXPLORER_DATEPICKER_HELPERS.map((helper) => ({ helper, - range: datePickerValueToLogDateRange({ + range: datePickerValueToLogTimeRange({ from: helper.calcFrom(), to: helper.calcTo(), isHelper: true, text: helper.text, }), })) - const selectedPreset = presets.find((preset) => logDateRangesEqual(range, preset.range)) + const selectedPreset = presets.find((preset) => logTimeRangesEqual(range, preset.range)) return ( @@ -59,7 +54,7 @@ export const TimeRangeSubMenu = ({ {presets.map(({ helper, range: presetRange }) => { - const isSelected = !isCustomRange && logDateRangesEqual(range, presetRange) + const isSelected = !isCustomRange && logTimeRangesEqual(range, presetRange) const isLocked = maybeShowUpgradePromptIfNotEntitled(helper.calcFrom(), entitledToLogDays) return ( @@ -68,7 +63,7 @@ export const TimeRangeSubMenu = ({ className="justify-between" onClick={() => { if (isLocked) return onShowUpgrade() - sessionSnap.setLogRange(id, presetRange) + onRangeChange(presetRange) }} > {helper.text} diff --git a/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx b/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx new file mode 100644 index 00000000000..a1d635866ba --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/QuerySourceIcon.tsx @@ -0,0 +1,15 @@ +import { Database, ScrollText } from 'lucide-react' + +import type { QuerySourceId } from '@/data/query-sources/query-source-registry' + +export const QuerySourceIcon = ({ + source, + className, +}: { + source: QuerySourceId + className?: string +}) => { + const props = { className, size: 16, strokeWidth: 2 } + + return source === 'logs' ? : +} diff --git a/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts b/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts new file mode 100644 index 00000000000..43e103d82c2 --- /dev/null +++ b/apps/studio/components/interfaces/QuerySources/useLogsCustomRange.ts @@ -0,0 +1,35 @@ +import { useState } from 'react' + +import { customDateRangeToLogTimeRange } from './LogTimeRange.utils' +import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import type { LogTimeRange } from '@/data/query-sources/query-source-registry' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' + +export function useLogsCustomRange({ + onRangeChange, +}: { + onRangeChange: (range: LogTimeRange) => void +}) { + const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) + const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) + const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') + const entitledToLogDays = getEntitlementNumericValue() + + const handleApplyCustomRange = ({ from, to }: { from: Date; to: Date }) => { + const range = customDateRangeToLogTimeRange({ from, to }) + if (maybeShowUpgradePromptIfNotEntitled(range.from, entitledToLogDays)) { + setShowUpgradePrompt(true) + return + } + + onRangeChange(range) + } + + return { + isCustomRangeOpen, + setIsCustomRangeOpen, + showUpgradePrompt, + setShowUpgradePrompt, + handleApplyCustomRange, + } +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx index 9e08a904d50..d741dc3535e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx @@ -1,8 +1,6 @@ -import { useParams } from 'common' -import dayjs from 'dayjs' -import { Check, ChevronDown, Database, ScrollText } from 'lucide-react' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { Check, ChevronDown } from 'lucide-react' import { useRouter } from 'next/router' -import { useState } from 'react' import { Button, DropdownMenu, @@ -12,32 +10,23 @@ import { DropdownMenuTrigger, } from 'ui' -import { - datePickerValueToLogDateRange, - type QuerySource, - type SqlSnippetSource, -} from '../../querySource' -import { DatabaseSubMenu } from './DatabaseSubMenu' -import { LogsCustomRangeDialog } from './LogsCustomRangeDialog' +import { type QuerySource, type SqlSnippetSource } from '../../querySource' import { resolveSourceSwitch } from './QuerySourceMenu.utils' import { RowLimitSubMenu } from './RowLimitSubMenu' import { RunAsSubMenu } from './RunAsSubMenu' -import { TimeRangeSubMenu } from './TimeRangeSubMenu' -import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/DatabaseParametersSubMenu' +import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog' +import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu' +import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon' +import { useLogsCustomRange } from '@/components/interfaces/QuerySources/useLogsCustomRange' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' -import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { QUERY_SOURCE_LABELS, QUERY_SOURCES } from '@/data/query-sources/query-source-registry' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { IS_PLATFORM } from '@/lib/constants' +import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' -const SOURCE_LABEL: Record = { - database: 'Database', - logs: 'Logs', -} - -const SourceIcon = ({ source, ...props }: { source: SqlSnippetSource; className?: string }) => - source === 'logs' ? : - type QuerySourceMenuProps = { id: string runSource: QuerySource @@ -67,18 +56,33 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo const router = useRouter() const snapV2 = useSqlEditorV2StateSnapshot() const sessionSnap = useSqlEditorSessionSnapshot() + const databaseSelector = useDatabaseSelectorStateSnapshot() + const [lastSelectedDatabase, setLastSelectedDatabase] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref ?? ''), + '' + ) - const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) - const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) - - const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') - const entitledToLogDays = getEntitlementNumericValue() + const { + isCustomRangeOpen, + setIsCustomRangeOpen, + showUpgradePrompt, + setShowUpgradePrompt, + handleApplyCustomRange, + } = useLogsCustomRange({ onRangeChange: (range) => sessionSnap.setLogRange(id, range) }) const currentSource = runSource.type const isLogs = currentSource === 'logs' // A snippet materializes in the store on its first keystroke; until then a // `/sql/new` tab is a blank scaffold with nothing to preserve. const isBlankNewTab = snapV2.snippets[id] === undefined + const databaseIdentifier = + lastSelectedDatabase.length > 0 + ? lastSelectedDatabase + : (databaseSelector.selectedDatabaseId ?? ref) + + const selectableSources = QUERY_SOURCES.filter( + (source) => source.type !== 'logs' || canCreateLogsSnippet || isLogs + ) const switchSource = (target: SqlSnippetSource) => { const next = resolveSourceSwitch({ ref, target, currentSource, isBlankNewTab }) @@ -86,20 +90,10 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo router[next.method](next.url) } - const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => { - const fromIso = dayjs(from).startOf('day').toISOString() - if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) { - setShowUpgradePrompt(true) - return - } - sessionSnap.setLogRange( - id, - datePickerValueToLogDateRange({ - from: fromIso, - to: dayjs(to).endOf('day').toISOString(), - isHelper: false, - }) - ) + const updateDatabaseIdentifier = (identifier: string) => { + databaseSelector.setSelectedDatabaseId(identifier) + setLastSelectedDatabase(identifier) + sessionSnap.resetResult(id) } return ( @@ -108,55 +102,48 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo - { - e.preventDefault() - switchSource('database') - }} - > - - - Database - - {!isLogs && } - - {(canCreateLogsSnippet || isLogs) && ( + {selectableSources.map((source) => ( { e.preventDefault() - switchSource('logs') + switchSource(source.id) }} > - - Logs + + {QUERY_SOURCE_LABELS[source.id]} - {isLogs && } + {currentSource === source.id && } - )} + ))} {runSource.type === 'logs' ? ( - sessionSnap.setLogRange(id, range)} onOpenCustomRange={() => setIsCustomRangeOpen(true)} onShowUpgrade={() => setShowUpgradePrompt(true)} /> ) : ( <> - {IS_PLATFORM && } + {IS_PLATFORM && ( + + )} @@ -169,7 +156,7 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts index 6c68f56e2db..93f38dcfa72 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts @@ -1,43 +1,11 @@ -import dayjs from 'dayjs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { - datePickerValueToLogDateRange, - DEFAULT_LOG_DATE_RANGE, getSnippetSource, isLogsSource, - logDateRangesEqual, - logDateRangeToDatePickerValue, - resolveLogRunRange, resolveSnippetSource, sqlSourceToFenceLanguage, - type LogDateRange, } from './querySource' -import { - EXPLORER_DATEPICKER_HELPERS, - getDefaultHelper, -} from '@/components/interfaces/Settings/Logs/Logs.constants' -import { generateHelpersFromInput } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' -import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' -import type { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types' -import { isoDateTimeString } from '@/lib/iso-datetime' - -/** Build the `DatePickerValue` the Logs picker submits when a helper is selected. */ -const valueFromHelper = (helper: DatetimeHelper): DatePickerValue => ({ - from: helper.calcFrom(), - to: helper.calcTo(), - isHelper: true, - text: helper.text, -}) - -/** The single dynamic helper produced from typed input like "2h" / "30m". */ -const dynamicHelper = (input: string): DatetimeHelper => { - const generated = generateHelpersFromInput(input) - if (!generated || generated.length !== 1) { - throw new Error(`Expected a single dynamic helper for "${input}"`) - } - return generated[0] -} describe('querySource.ts:getSnippetSource', () => { it('maps log_sql to the logs source', () => { @@ -91,221 +59,3 @@ describe('querySource.ts:resolveSnippetSource', () => { expect(resolveSnippetSource(undefined, 'nonsense')).toBe('database') }) }) - -describe('querySource.ts:datePickerValueToLogDateRange', () => { - it('parses every static preset into a relative range', () => { - const cases: Array<[string, { amount: number; unit: 'minute' | 'hour' | 'day' }]> = [ - ['Last hour', { amount: 1, unit: 'hour' }], - ['Last 3 hours', { amount: 3, unit: 'hour' }], - ['Last 24 hours', { amount: 24, unit: 'hour' }], - ['Last 3 days', { amount: 3, unit: 'day' }], - ['Last 7 days', { amount: 7, unit: 'day' }], - ] - - for (const [text, last] of cases) { - const helper = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === text) - expect(helper, `preset "${text}" exists`).toBeDefined() - expect(datePickerValueToLogDateRange(valueFromHelper(helper!))).toEqual({ - kind: 'relative', - last, - }) - } - }) - - it('parses dynamic helpers ("2h", "30m", "7d") into relative ranges', () => { - expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('2h')))).toEqual({ - kind: 'relative', - last: { amount: 2, unit: 'hour' }, - }) - expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('30m')))).toEqual({ - kind: 'relative', - last: { amount: 30, unit: 'minute' }, - }) - expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('7d')))).toEqual({ - kind: 'relative', - last: { amount: 7, unit: 'day' }, - }) - }) - - it('treats a preset (calcTo() === "") as relative — the empty end means "now"', () => { - const lastHour = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) - const value = valueFromHelper(lastHour) - expect(value.to).toBe('') - expect(datePickerValueToLogDateRange(value)).toEqual({ - kind: 'relative', - last: { amount: 1, unit: 'hour' }, - }) - }) - - it('degrades an unparseable helper to an absolute range using from/now (never empty)', () => { - vi.useFakeTimers() - const now = new Date('2025-01-01T12:00:00.000Z') - vi.setSystemTime(now) - - const from = '2024-12-31T00:00:00.000Z' - const result = datePickerValueToLogDateRange({ - from, - to: '', - isHelper: true, - text: 'Some custom label', - }) - - expect(result).toEqual({ - kind: 'absolute', - from, - to: dayjs(now).toISOString(), - }) - }) - - it('maps a custom (non-helper) pick to an absolute range with both endpoints', () => { - const from = '2025-01-01T00:00:00.000Z' - const to = '2025-01-02T00:00:00.000Z' - expect(datePickerValueToLogDateRange({ from, to, isHelper: false })).toEqual({ - kind: 'absolute', - from, - to, - }) - }) - - it('falls back to the default range when the value has no usable from', () => { - expect(datePickerValueToLogDateRange({ from: '', to: '', isHelper: false })).toEqual( - DEFAULT_LOG_DATE_RANGE - ) - }) -}) - -describe('querySource.ts:logDateRangeToDatePickerValue', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')) - }) - - it('renders a relative range exactly as the picker would emit the helper', () => { - const value = logDateRangeToDatePickerValue({ - kind: 'relative', - last: { amount: 1, unit: 'hour' }, - }) - expect(value).toEqual({ - from: dayjs().subtract(1, 'hour').toISOString(), - to: dayjs().toISOString(), - isHelper: true, - text: 'Last 1 hour', - }) - }) - - it('pluralizes the label for amounts greater than one', () => { - expect( - logDateRangeToDatePickerValue({ kind: 'relative', last: { amount: 3, unit: 'day' } }).text - ).toBe('Last 3 days') - }) - - it('round-trips through datePickerValueToLogDateRange', () => { - const range: LogDateRange = { kind: 'relative', last: { amount: 30, unit: 'minute' } } - expect(datePickerValueToLogDateRange(logDateRangeToDatePickerValue(range))).toEqual(range) - }) - - it('passes an absolute range through as a non-helper value', () => { - const range: LogDateRange = { - kind: 'absolute', - from: isoDateTimeString('2025-01-01T00:00:00.000Z')!, - to: isoDateTimeString('2025-01-02T00:00:00.000Z')!, - } - expect(logDateRangeToDatePickerValue(range)).toEqual({ - from: '2025-01-01T00:00:00.000Z', - to: '2025-01-02T00:00:00.000Z', - isHelper: false, - }) - }) -}) - -describe('querySource.ts:resolveLogRunRange', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('re-resolves a relative range against the current time', () => { - vi.useFakeTimers() - const now = new Date('2025-06-15T08:30:00.000Z') - vi.setSystemTime(now) - - expect(resolveLogRunRange({ kind: 'relative', last: { amount: 2, unit: 'hour' } })).toEqual({ - from: dayjs(now).subtract(2, 'hour').toISOString(), - to: dayjs(now).toISOString(), - }) - }) - - it('re-resolves the same relative range differently as time advances', () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2025-06-15T08:00:00.000Z')) - const first = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } }) - - vi.setSystemTime(new Date('2025-06-15T10:00:00.000Z')) - const second = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } }) - - expect(first).not.toEqual(second) - expect(second.to).toBe(dayjs('2025-06-15T10:00:00.000Z').toISOString()) - }) - - it('passes an absolute range through unchanged', () => { - const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! - const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! - expect(resolveLogRunRange({ kind: 'absolute', from, to })).toEqual({ - from: '2025-01-01T00:00:00.000Z', - to: '2025-01-02T00:00:00.000Z', - }) - }) -}) - -describe('querySource.ts:logDateRangesEqual', () => { - it('matches relative ranges on amount + unit regardless of label formatting', () => { - const lastHourPreset = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === 'Last hour')! - const presetRange = datePickerValueToLogDateRange({ - from: lastHourPreset.calcFrom(), - to: lastHourPreset.calcTo(), - isHelper: true, - text: lastHourPreset.text, - }) - - expect( - logDateRangesEqual(presetRange, { kind: 'relative', last: { amount: 1, unit: 'hour' } }) - ).toBe(true) - }) - - it('does not match relative ranges with a different amount or unit', () => { - expect( - logDateRangesEqual( - { kind: 'relative', last: { amount: 1, unit: 'hour' } }, - { kind: 'relative', last: { amount: 3, unit: 'hour' } } - ) - ).toBe(false) - expect( - logDateRangesEqual( - { kind: 'relative', last: { amount: 1, unit: 'hour' } }, - { kind: 'relative', last: { amount: 1, unit: 'day' } } - ) - ).toBe(false) - }) - - it('matches absolute ranges on their ISO endpoints', () => { - const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! - const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! - expect(logDateRangesEqual({ kind: 'absolute', from, to }, { kind: 'absolute', from, to })).toBe( - true - ) - }) - - it('never matches a relative range against an absolute one', () => { - const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! - const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! - expect( - logDateRangesEqual( - { kind: 'relative', last: { amount: 1, unit: 'hour' } }, - { - kind: 'absolute', - from, - to, - } - ) - ).toBe(false) - }) -}) diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.ts b/apps/studio/components/interfaces/SQLEditor/querySource.ts index 4a1174f8375..49f57612446 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.ts @@ -1,11 +1,5 @@ -import dayjs from 'dayjs' - -import type { Unit } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' -import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' -import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' -import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange' import type { Snippet } from '@/data/content/sql-folders-query' -import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime' +import { type LogTimeRange, type QuerySourceId } from '@/data/query-sources/query-source-registry' /** * Domain view of where a snippet's query runs. Derived from the content TYPE: @@ -14,7 +8,7 @@ import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime' * immutable — switching backends means creating a new snippet, not toggling this * value. */ -export type SqlSnippetSource = 'database' | 'logs' +export type SqlSnippetSource = QuerySourceId /** * The single reader every surface (AI, reports, tabs, nav, execution) uses to @@ -61,128 +55,10 @@ export function resolveSnippetSource( return snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam) } -/** `now` as a branded ISO datetime — `toISOString()` is always valid ISO-8601. */ -function nowIsoDateTime(): IsoDateTimeString { - return dayjs().toISOString() as IsoDateTimeString -} - -/** - * The units a relative log range is expressed in. Aliases the Logs date picker's - * `Unit` so the two stay in lockstep rather than drifting as parallel unions. - */ -export type RelativeTimeUnit = Unit - -/** - * A log query's time range. Relative ranges are structural (amount + unit) and - * re-resolve against `now` at every run — so a saved "last hour" always means the - * hour before the run, not the hour before the snippet was opened. Absolute ranges - * carry validated ISO datetimes and pass through unchanged. - */ -export type LogDateRange = - | { kind: 'relative'; last: { amount: number; unit: RelativeTimeUnit } } - | { kind: 'absolute'; from: IsoDateTimeString; to: IsoDateTimeString } - -/** The range a freshly opened logs snippet starts with: the last hour. */ -export const DEFAULT_LOG_DATE_RANGE: LogDateRange = { - kind: 'relative', - last: { amount: 1, unit: 'hour' }, -} - /** * The runtime query source for a snippet, pairing the database/logs discriminant * with the extra state each backend needs to run. A logs run carries the active * time range (session state, re-resolved at every run); a database run needs * nothing beyond the connection the execution pipeline already resolves. */ -export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: LogDateRange } - -/** - * Parse a date-picker helper's label (e.g. "Last hour", "Last 3 hours", "Last 30 - * minutes") into a relative amount/unit. Covers both the static presets in - * `EXPLORER_DATEPICKER_HELPERS` and the dynamic helpers `generateHelpersFromInput` - * produces from typed input like "2h"/"30m". A label with no number means one unit - * ("Last hour"). Returns null for any other label. - */ -function parseRelativeHelperLabel( - text: string | undefined -): { amount: number; unit: RelativeTimeUnit } | null { - if (!text) return null - const match = text - .trim() - .toLowerCase() - .match(/^last\s+(?:(\d+)\s+)?(minute|hour|day)s?$/) - if (!match) return null - const amount = match[1] ? parseInt(match[1], 10) : 1 - if (!Number.isFinite(amount) || amount <= 0) return null - const unit = match[2] - if (unit !== 'minute' && unit !== 'hour' && unit !== 'day') return null - return { amount, unit } -} - -/** - * Convert a Logs date-picker value into a `LogDateRange`. Helper picks (presets and - * dynamic "2h"/"30m" helpers) become relative ranges by parsing the helper label; - * a preset's `calcTo()` resolves to `''` (meaning "now"), which the relative variant - * models implicitly. Everything else — custom calendar picks, or a helper whose label - * we can't parse — becomes an absolute range with validated ISO datetimes, degrading - * via `from`/now rather than rejecting an empty string. A value with no usable `from` - * falls back to the default range. - */ -export function datePickerValueToLogDateRange(value: DatePickerValue): LogDateRange { - if (value.isHelper) { - const relative = parseRelativeHelperLabel(value.text) - if (relative) return { kind: 'relative', last: relative } - } - - const from = isoDateTimeString(value.from) - if (from === null) return DEFAULT_LOG_DATE_RANGE - const to = isoDateTimeString(value.to) ?? nowIsoDateTime() - return { kind: 'absolute', from, to } -} - -/** - * Render a `LogDateRange` back into a Logs date-picker value for display. Relative - * ranges reuse the picker's own `generateDynamicHelper` to derive the resolved - * `from`/`to` and matching "Last N unit(s)" label, so the value is byte-for-byte - * what the picker itself would emit for that helper. Absolute ranges pass their - * datetimes through. - */ -export function logDateRangeToDatePickerValue(range: LogDateRange): DatePickerValue { - if (range.kind === 'relative') { - const helper = generateDynamicHelper(range.last.amount, range.last.unit) - return { from: helper.calcFrom(), to: helper.calcTo(), isHelper: true, text: helper.text } - } - return { from: range.from, to: range.to, isHelper: false } -} - -/** - * Structural equality for two log date ranges. Relative ranges match on amount + - * unit, NOT display text — "Last hour" and "Last 1 hour" render differently but - * are the same range, so comparing labels is unreliable. Absolute ranges match on - * their (validated) ISO endpoints. - */ -export function logDateRangesEqual(a: LogDateRange, b: LogDateRange): boolean { - if (a.kind === 'relative' && b.kind === 'relative') { - return a.last.amount === b.last.amount && a.last.unit === b.last.unit - } - if (a.kind === 'absolute' && b.kind === 'absolute') { - return a.from === b.from && a.to === b.to - } - return false -} - -/** - * Resolve a `LogDateRange` to concrete ISO endpoints for a run. Relative ranges - * re-resolve against `now` (so "last hour" is always the hour before the run); - * absolute ranges pass through. Reuses the Logs `ResolvedLogDateRange` shape. - */ -export function resolveLogRunRange(range: LogDateRange): ResolvedLogDateRange { - if (range.kind === 'relative') { - const now = dayjs() - return { - from: now.subtract(range.last.amount, range.last.unit).toISOString(), - to: now.toISOString(), - } - } - return { from: range.from, to: range.to } -} +export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: LogTimeRange } diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx index 38e3fb8634b..97bd6bdd789 100644 --- a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx @@ -99,8 +99,9 @@ describe('useLogsSqlExecution', () => { it('resolves a relative session range to a from/to window around now', async () => { const captured = mockLogsAllOtel([]) sqlEditorSessionState.setLogRange(SNIPPET_ID, { - kind: 'relative', - last: { amount: 2, unit: 'hour' }, + type: 'relative', + amount: 2, + unit: 'hour', }) const { result } = renderLogsExecution() diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts index 4c1c006bfd7..ddbd7fd3175 100644 --- a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts @@ -1,10 +1,13 @@ import { useFlag, useParams } from 'common' import { useCallback } from 'react' -import { DEFAULT_LOG_DATE_RANGE, resolveLogRunRange } from './querySource' +import { resolveLogTimeRange } from '@/components/interfaces/QuerySources/LogTimeRange.utils' import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' -import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' import { type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' +import { + DEFAULT_LOG_TIME_RANGE, + QUERY_SOURCE_REGISTRY, +} from '@/data/query-sources/query-source-registry' import { useTrack } from '@/lib/telemetry/track' import { getSqlEditorSessionSnapshot, @@ -46,11 +49,11 @@ export function useLogsSqlExecution({ id }: UseLogsSqlExecutionArgs) { // Re-read imperatively so a range picked immediately before the run is // honored; relative ranges re-resolve against `now` here. - const range = resolveLogRunRange( - getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_DATE_RANGE + const range = resolveLogTimeRange( + getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_TIME_RANGE ) - mutate({ projectRef, sql, range, endpoint: logsAllEndpointUrl(true) }) + mutate({ projectRef, sql, range, endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint }) track('sql_editor_query_run_button_clicked', { source: 'logs' }) }, diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx index 820caa97c00..3d63dece309 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { DEFAULT_LOG_DATE_RANGE } from './querySource' import { useRunSource } from './useRunSource' +import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry' import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' import { renderSqlEditorHook, @@ -31,22 +31,23 @@ describe('useRunSource', () => { const { result } = renderSqlEditorHook(() => useRunSource(id)) - expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_DATE_RANGE }) + expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_TIME_RANGE }) }) it('resolves a logs snippet to its session-stored range when one is set', () => { const id = 'logs-snippet-custom-range' seedSnippet({ id, source: 'logs' }) sqlEditorSessionState.setLogRange(id, { - kind: 'relative', - last: { amount: 2, unit: 'hour' }, + type: 'relative', + amount: 2, + unit: 'hour', }) const { result } = renderSqlEditorHook(() => useRunSource(id)) expect(result.current).toEqual({ type: 'logs', - dateRange: { kind: 'relative', last: { amount: 2, unit: 'hour' } }, + dateRange: { type: 'relative', amount: 2, unit: 'hour' }, }) }) }) diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts index 95c83d782a5..b9362d48279 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts @@ -1,12 +1,8 @@ import { useParams } from 'common' import { useMemo } from 'react' -import { - DEFAULT_LOG_DATE_RANGE, - isLogsSource, - resolveSnippetSource, - type QuerySource, -} from './querySource' +import { isLogsSource, resolveSnippetSource, type QuerySource } from './querySource' +import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' @@ -33,7 +29,7 @@ export function useRunSource(id: string): QuerySource { return useMemo(() => { if (isLogsSource(source)) { - return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_DATE_RANGE } + return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_TIME_RANGE } } return { type: 'database' } }, [source, logRange]) diff --git a/apps/studio/data/query-sources/query-source-registry.test.ts b/apps/studio/data/query-sources/query-source-registry.test.ts new file mode 100644 index 00000000000..03c2045655b --- /dev/null +++ b/apps/studio/data/query-sources/query-source-registry.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' + +import { + cellSourceSchema, + createDefaultCellSource, + getQuerySource, + logTimeRangeSchema, + QUERY_SOURCES, +} from './query-source-registry' + +describe('query source registry', () => { + it('registers database and logs sources with their execution endpoints', () => { + expect(QUERY_SOURCES.map(({ id }) => id)).toEqual(['database', 'logs']) + expect(getQuerySource('database').endpoint).toBe('/platform/pg-meta/{ref}/query') + expect(getQuerySource('logs').endpoint).toBe( + '/platform/projects/{ref}/analytics/endpoints/logs.all.otel' + ) + }) + + it('creates independent, valid default cell bindings', () => { + const first = createDefaultCellSource('logs') + const second = createDefaultCellSource('logs') + + expect(cellSourceSchema.parse(first)).toEqual({ + id: 'logs', + type: 'logs', + parameters: { + time_range: { type: 'relative', amount: 1, unit: 'hour' }, + }, + }) + expect(first.parameters.time_range).not.toBe(second.parameters.time_range) + expect(cellSourceSchema.parse(createDefaultCellSource('database'))).toEqual({ + id: 'database', + type: 'database', + parameters: {}, + }) + }) + + it('rejects parameters that do not match the selected source type', () => { + expect(() => + cellSourceSchema.parse({ + id: 'logs', + type: 'logs', + parameters: { identifier: 'replica-1' }, + }) + ).toThrow() + + expect(() => + cellSourceSchema.parse({ + id: 'database', + type: 'database', + parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } }, + }) + ).toThrow() + + expect(() => + cellSourceSchema.parse({ + id: 'logs', + type: 'logs', + parameters: { time_range: { type: 'relative', amount: 2, unit: 'week' } }, + }) + ).toThrow() + }) + + it('rejects absolute ranges that do not move forward in time', () => { + expect( + logTimeRangeSchema.safeParse({ + type: 'absolute', + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-02T00:00:00.000Z', + }).success + ).toBe(true) + + const equal = logTimeRangeSchema.safeParse({ + type: 'absolute', + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-01T00:00:00.000Z', + }) + expect(equal.success).toBe(false) + expect(equal.error?.issues[0].path).toEqual(['to']) + + expect( + logTimeRangeSchema.safeParse({ + type: 'absolute', + from: '2025-01-02T00:00:00.000Z', + to: '2025-01-01T00:00:00.000Z', + }).success + ).toBe(false) + + expect(() => + cellSourceSchema.parse({ + id: 'logs', + type: 'logs', + parameters: { + time_range: { + type: 'absolute', + from: '2025-01-02T00:00:00.000Z', + to: '2025-01-01T00:00:00.000Z', + }, + }, + }) + ).toThrow() + }) + + it('reports an invalid endpoint against its own field rather than the ordering rule', () => { + const result = logTimeRangeSchema.safeParse({ + type: 'absolute', + from: 'not-a-date', + to: '2025-01-01T00:00:00.000Z', + }) + + expect(result.success).toBe(false) + expect(result.error?.issues).toHaveLength(1) + expect(result.error?.issues[0].path).toEqual(['from']) + }) +}) diff --git a/apps/studio/data/query-sources/query-source-registry.ts b/apps/studio/data/query-sources/query-source-registry.ts new file mode 100644 index 00000000000..eb8124a4f58 --- /dev/null +++ b/apps/studio/data/query-sources/query-source-registry.ts @@ -0,0 +1,152 @@ +import dayjs from 'dayjs' +import * as z from 'zod' + +import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' +import { isoDateTimeString } from '@/lib/iso-datetime' + +export type LogTimeRange = + | { + type: 'relative' + amount: number + unit: 'minute' | 'hour' | 'day' + } + | { + type: 'absolute' + from: string + to: string + } + +export type DatabaseSource = { + id: 'database' + type: 'database' + endpoint: '/platform/pg-meta/{ref}/query' + parameters: { + /** + * Query-owned database selection. The SQL editor still adapts its legacy + * global/local-storage selector into this shape; new consumers persist the + * identifier directly with their query. + */ + identifier?: string + } +} + +export type LogsSource = { + id: 'logs' + type: 'logs' + endpoint: ReturnType + parameters: { + time_range: LogTimeRange + } +} + +/** Sources are registered by Studio; query surfaces only store a source binding. */ +export type Source = DatabaseSource | LogsSource + +export type CellSourceOf = Pick + +export type CellSource = CellSourceOf | CellSourceOf + +export const DEFAULT_LOG_TIME_RANGE: LogTimeRange = { + type: 'relative', + amount: 1, + unit: 'hour', +} + +export const QUERY_SOURCE_REGISTRY = { + database: { + id: 'database', + type: 'database', + endpoint: '/platform/pg-meta/{ref}/query', + parameters: {}, + }, + logs: { + id: 'logs', + type: 'logs', + endpoint: logsAllEndpointUrl(true), + parameters: { time_range: DEFAULT_LOG_TIME_RANGE }, + }, +} as const satisfies Record + +export type QuerySourceId = keyof typeof QUERY_SOURCE_REGISTRY + +export const QUERY_SOURCES = Object.values(QUERY_SOURCE_REGISTRY) satisfies Source[] + +export const QUERY_SOURCE_LABELS: Record = { + database: 'Database', + logs: 'Logs', +} + +const isoDateTimeSchema = z.string().refine((value) => isoDateTimeString(value) !== null, { + message: 'must be a valid ISO-8601 datetime', +}) + +export const logTimeRangeSchema = z + .discriminatedUnion('type', [ + z + .object({ + type: z.literal('relative'), + amount: z.number().int().positive(), + unit: z.enum(['minute', 'hour', 'day']), + }) + .strict(), + z + .object({ + type: z.literal('absolute'), + from: isoDateTimeSchema, + to: isoDateTimeSchema, + }) + .strict(), + ]) + .refine( + (range) => { + if (range.type !== 'absolute') return true + + const from = dayjs(range.from) + const to = dayjs(range.to) + // An unparseable endpoint is already reported against its own field; the + // ordering rule stays quiet so it doesn't add a second, misleading issue. + if (!from.isValid() || !to.isValid()) return true + + return to.isAfter(from) + }, + { + message: 'must be later than the start of the range', + path: ['to'], + } + ) + +export const cellSourceSchema = z.discriminatedUnion('type', [ + z + .object({ + id: z.literal('database'), + type: z.literal('database'), + parameters: z.object({ identifier: z.string().optional() }).strict(), + }) + .strict(), + z + .object({ + id: z.literal('logs'), + type: z.literal('logs'), + parameters: z.object({ time_range: logTimeRangeSchema }).strict(), + }) + .strict(), +]) + +export function createDefaultCellSource(id: 'database'): CellSourceOf +export function createDefaultCellSource(id: 'logs'): CellSourceOf +export function createDefaultCellSource(id: QuerySourceId): CellSource +export function createDefaultCellSource(id: QuerySourceId): CellSource { + const source = QUERY_SOURCE_REGISTRY[id] + + if (source.type === 'logs') { + return { + id: source.id, + type: source.type, + parameters: { time_range: { ...source.parameters.time_range } }, + } + } + + return { id: source.id, type: source.type, parameters: { ...source.parameters } } +} + +export const getQuerySource = (id: QuerySourceId): Source => QUERY_SOURCE_REGISTRY[id] diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index 3718fac1f59..d1c2122cbf6 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -12,6 +12,7 @@ import { createNodeTree, } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser' import { handleError as handleErrorFetchers, post } from '@/data/fetchers' +import { QUERY_SOURCE_REGISTRY } from '@/data/query-sources/query-source-registry' import { MB } from '@/lib/constants' import { sqlEventParser } from '@/lib/sql-event-parser' import { useTrack } from '@/lib/telemetry/track' @@ -159,7 +160,7 @@ export async function executeSql( const key = queryKey?.filter((seg) => typeof seg === 'string' || typeof seg === 'number').join('-') ?? '' - const result = await post('/platform/pg-meta/{ref}/query', { + const result = await post(QUERY_SOURCE_REGISTRY.database.endpoint, { ...options, body: { query: sql, disable_statement_timeout: isStatementTimeoutDisabled }, params: { diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.ts b/apps/studio/state/sql-editor/sql-editor-session-state.ts index 38aeafc030c..b7813f58333 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.ts @@ -1,6 +1,6 @@ import { proxy, ref, snapshot, useSnapshot } from 'valtio' -import type { LogDateRange } from '@/components/interfaces/SQLEditor/querySource' +import type { LogTimeRange } from '@/data/query-sources/query-source-registry' /** * Ephemeral, per-session SQL editor state that is NOT persisted: query results, @@ -35,11 +35,11 @@ export const sqlEditorSessionState = proxy({ * The logs time range for a logs snippet, keyed by snippet id. Session state — * never written to snippet content — so it works on read-only shared snippets * and resets on reload. An unset snippet has no entry; read sites fall back to - * `DEFAULT_LOG_DATE_RANGE`. + * `DEFAULT_LOG_TIME_RANGE`. */ - logRange: {} as { [snippetId: string]: LogDateRange }, + logRange: {} as { [snippetId: string]: LogTimeRange }, - setLogRange: (id: string, range: LogDateRange) => { + setLogRange: (id: string, range: LogTimeRange) => { sqlEditorSessionState.logRange[id] = range }, diff --git a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx index 6985f0ad822..d1ef963eb63 100644 --- a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx +++ b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx @@ -1,42 +1,58 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { mockAnimationsApi } from 'jsdom-testing-mocks' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' -import { DEFAULT_LOG_DATE_RANGE } from '@/components/interfaces/SQLEditor/querySource' import { QuerySourceMenu } from '@/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu' +import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock } from '@/tests/lib/msw' // QuerySourceMenu renders a Radix dropdown (+ nested dialog), both of which use Web Animations. mockAnimationsApi() -addAPIMock({ - method: 'get', - path: '/platform/projects/:ref', - response: { - id: 1, - ref: 'default', - organization_id: 1, - name: 'Test Project', - status: 'ACTIVE_HEALTHY', - cloud_provider: 'AWS', - region: 'us-east-1', - db_host: 'db.default.supabase.co', - restUrl: 'https://default.supabase.co/rest/v1/', - inserted_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - subscription_id: 'sub_123', - is_branch_enabled: false, - is_physical_backups_enabled: false, - high_availability: false, - integration_source: null, - connectionString: 'postgresql://postgres@localhost:5432/postgres', - is_hibernating: false, - }, +beforeEach(() => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { + id: 1, + ref: 'default', + organization_id: 1, + name: 'Test Project', + status: 'ACTIVE_HEALTHY', + cloud_provider: 'AWS', + region: 'us-east-1', + db_host: 'db.default.supabase.co', + restUrl: 'https://default.supabase.co/rest/v1/', + inserted_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + subscription_id: 'sub_123', + is_branch_enabled: false, + is_physical_backups_enabled: false, + high_availability: false, + integration_source: null, + connectionString: 'postgresql://postgres@localhost:5432/postgres', + is_hibernating: false, + }, + }) }) describe('QuerySourceMenu', () => { + it('hides logs when creating logs queries is unavailable', async () => { + customRender( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' })) + + expect(screen.queryByText('Logs')).not.toBeInTheDocument() + }) + it('keeps the dropdown open across a source switch, so the new source’s controls appear without reopening it', async () => { // Selecting a source doesn't mutate `runSource` in place — it navigates to a // fresh tab, and the parent re-renders this component with the new source once @@ -57,7 +73,7 @@ describe('QuerySourceMenu', () => { rerender( ) diff --git a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx index 09e328d5f4e..c9bff95574f 100644 --- a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx +++ b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx @@ -96,7 +96,7 @@ describe('generateDynamicHelper', () => { }) describe('generateDynamicHelpers', () => { - test('generates 3 helpers for minutes, hours, days', () => { + test('generates helpers for every supported relative unit', () => { const helpers = generateDynamicHelpers(5) expect(helpers).toHaveLength(3) expect(helpers[0].text).toBe('Last 5 minutes') @@ -112,7 +112,7 @@ describe('generateHelpersFromInput', () => { expect(generateHelpersFromInput('2yoie')).toBeNull() }) - test('returns 3 helpers for number only input', () => { + test('returns a helper for every unit for number only input', () => { const helpers = generateHelpersFromInput('25') expect(helpers).toHaveLength(3) expect(helpers![0].text).toBe('Last 25 minutes')