diff --git a/apps/docs/content/guides/database/extensions/pg_net.mdx b/apps/docs/content/guides/database/extensions/pg_net.mdx index 7d0a3a399c9d1..4897f21c5d78d 100644 --- a/apps/docs/content/guides/database/extensions/pg_net.mdx +++ b/apps/docs/content/guides/database/extensions/pg_net.mdx @@ -34,8 +34,8 @@ It eliminates the need for servers to continuously poll for database changes and ```sql -- Example: enable the "pg_net" extension. -create extension pg_net; --- Note: The extension creates its own schema/namespace named "net" to avoid naming conflicts. +create extension pg_net with schema "extensions"; +-- Note: The extension creates its own schema/namespace named "net" to avoid naming conflicts. Registering it in the extensions schema avoids exposing it in public and satisfies the Security Advisor check. -- Example: disable the "pg_net" extension drop extension if exists pg_net; diff --git a/apps/docs/content/guides/platform/temporary-access.mdx b/apps/docs/content/guides/platform/temporary-access.mdx index 2862e3bdcba6d..0c9eec50002c0 100644 --- a/apps/docs/content/guides/platform/temporary-access.mdx +++ b/apps/docs/content/guides/platform/temporary-access.mdx @@ -9,6 +9,12 @@ Enabling temporary access only applies to connections to Postgres and Supavisor +[Enforce SSL](/docs/guides/platform/ssl-enforcement) on incoming connections must be enabled before temporary access can be used. + + + + + Projects need to be at least on Postgres 17.6.1.081 (or higher) to enable temporary access. You can find the Postgres version of your project on the [General Settings](/dashboard/project/_/settings/general) page. If your project is on an older version, you will need to [upgrade](/docs/guides/platform/upgrading) to use this feature. @@ -27,11 +33,11 @@ export SUPABASE_MANAGEMENT_API_TOKEN="your-access-token" export PROJECT_REF="your-project-ref" # Get current temporary access status -curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-access" \ +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/jit-access" \ -H "Authorization: Bearer $SUPABASE_MANAGEMENT_API_TOKEN" # Enable temporary access -curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-access" \ +curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/jit-access" \ -H "Authorization: Bearer $SUPABASE_MANAGEMENT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -39,7 +45,7 @@ curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-acce }' # Disable temporary access -curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/database/jit-access" \ +curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/jit-access" \ -H "Authorization: Bearer $SUPABASE_MANAGEMENT_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ diff --git a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx index 1a806cb00dce4..00a2e74f641d3 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx @@ -17,10 +17,15 @@ On managed Supabase platform, Edge Functions are deployed across multiple region The default `hello` function is located at `volumes/functions/hello/index.ts`. You can invoke it immediately after starting your stack: ```sh -curl http:///functions/v1/hello +curl http:///functions/v1/hello \ + --header 'apiKey: ' ``` -This returns `"Hello from Edge Functions!"`. +This returns: + +```json +{ "message": "Hello from Edge Functions!" } +``` ## Create a new function @@ -34,16 +39,20 @@ touch volumes/functions/my-function/index.ts Add the following code to `index.ts`: ```typescript -Deno.serve(async (req: Request) => { - const { name } = await req.json() - const message = `Hello, ${name}!` +import { withSupabase } from '@supabase/server' - return new Response(JSON.stringify({ message }), { - headers: { 'Content-Type': 'application/json' }, - }) -}) +export default { + fetch: withSupabase({ auth: 'none' }, async (req) => { + const { name } = await req.json() + const message = `Hello, ${name}!` + + return Response.json({ message }) + }), +} ``` +The `auth` option controls who can call the function: `'none'` accepts every request, `'user'` requires a valid user JWT, and `'publishable'` / `'secret'` require an API key. See the [Edge Functions auth guide](/docs/guides/functions/auth) for details. + ### Step 2: Restart the functions service to pick up the new function ```sh @@ -134,25 +143,30 @@ The functions service is pre-configured with the following environment variables | `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...}` | New secret API key | | `SUPABASE_JWKS` | `{"keys":[{...}]}` | JWKS used to verify JWTs issued by Auth | -Here's an example function that queries a table using `@supabase/supabase-js`: +Here's an example function that queries a table using the admin client provided by `@supabase/server`: ```typescript -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' - -Deno.serve(async () => { - const supabase = createClient( - Deno.env.get('SUPABASE_URL')!, - Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! - ) +import { withSupabase } from '@supabase/server' - const { data, error } = await supabase.from('todos').select('*') +export default { + fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => { + // ctx.supabaseAdmin bypasses RLS. This function requires a secret + // API key, so only server-to-server callers can reach it. + const { data, error } = await ctx.supabaseAdmin.from('todos').select('*') - return new Response(JSON.stringify({ data, error }), { - headers: { 'Content-Type': 'application/json' }, - }) -}) + return Response.json({ data, error }) + }), +} ``` +`withSupabase` reads `SUPABASE_URL`, the API keys, and `SUPABASE_JWKS` from the environment variables above. You don't need to wire up `createClient` yourself. + + + +`auth: 'user'` verifies caller JWTs against `SUPABASE_JWKS`. If you're on a legacy setup without it configured, see [New API Keys and Asymmetric Authentication](/docs/guides/self-hosting/self-hosted-auth-keys). + + + ### Internal vs external URLs This is a key distinction that affects how you build URLs in your functions: diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 2818e55edbc45..43b8ef31d1a4e 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -283,7 +283,7 @@ export function useSyncTableEditorStateFromLocalStorageWithUrl({ }, [urlParams, table, projectRef]) } -export const handleCellKeyDown = ( +export const handleCellKeyDown = = SupaRow>( args: CellKeyDownArgs, event: CellKeyboardEvent, context?: { diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index dcc00c7a296db..e58c8df34bc12 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -24,7 +24,7 @@ import { useIsETLPrivateAlpha } from '../useIsETLPrivateAlpha' import { DestinationForm } from './DestinationForm' import { DestinationType } from './DestinationPanel.types' import { DestinationTypeSelection } from './DestinationTypeSelection' -import { ReadReplicaForm } from './ReadReplicaForm' +import { ReadReplicaForm } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { DocsButton } from '@/components/ui/DocsButton' import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query' diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index ea4482fff97fc..82ea355177425 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -30,7 +30,6 @@ import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DestinationRow } from './DestinationRow' import { DisablePipelinesDialog } from './DisablePipelinesDialog' import { EnablePipelinesModal } from './EnablePipelinesCallout' -import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow' import { REPLICA_STATUS } from './Replication.constants' import { useIsETLBigQueryPrivateAlpha, @@ -39,6 +38,7 @@ import { useIsETLIcebergPrivateAlpha, useIsETLSnowflakePrivateAlpha, } from './useIsETLPrivateAlpha' +import { ReadReplicaRow } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaRow' import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' diff --git a/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts b/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts index ddefacbe243d3..2663b675fa33a 100644 --- a/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts +++ b/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts @@ -1,7 +1,3 @@ -import { components } from 'api-types' - -import { PROJECT_STATUS } from '@/lib/constants' - export const STATUS_REFRESH_FREQUENCY_MS: number = 10000 // 10 seconds export enum PipelineStatusName { @@ -13,10 +9,5 @@ export enum PipelineStatusName { UNKNOWN = 'unknown', } -export const REPLICA_STATUS: { - [key: string]: components['schemas']['DatabaseStatusResponse']['status'] -} = { - ...PROJECT_STATUS, - INIT_READ_REPLICA: 'INIT_READ_REPLICA', - INIT_READ_REPLICA_FAILED: 'INIT_READ_REPLICA_FAILED', -} +/** @deprecated Import from Settings/Infrastructure/ReadReplicas/ReadReplicas.constants */ +export { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants' diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx index 7e3f1e2e371d9..b4d1afe51a395 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx @@ -6,9 +6,9 @@ import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { DestinationIcon } from '../DestinationIcon' import { getStatusName } from '../Pipeline.utils' -import { getStatusLabel } from '../ReadReplicas/ReadReplicas.utils' import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' import { getReplicationDestinationType } from './Nodes.utils' +import { getStatusLabel } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { formatDatabaseID } from '@/data/read-replicas/replicas.utils' import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query' diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx new file mode 100644 index 0000000000000..0f6ed7b2be2b9 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx @@ -0,0 +1,115 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FeatureFlagContext } from 'common' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ExplorerQuerySourceMenu } from './ExplorerQuerySourceMenu' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +mockAnimationsApi() + +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('ExplorerQuerySourceMenu', () => { + const renderWithFlags = ( + source: Parameters[0]['source'], + flags: Record + ) => + customRender( + + + + ) + + it('emits a complete default binding when the query changes source', async () => { + const onSourceChange = vi.fn() + + customRender( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Logs' })) + await userEvent.click(screen.getByText('Database')) + + expect(onSourceChange).toHaveBeenCalledWith({ _tag: 'database' }) + }) + + it('emits the selected log time range as source parameters', async () => { + const onSourceChange = vi.fn() + + customRender( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Logs' })) + await userEvent.hover(screen.getByText('Time range')) + await userEvent.click(await screen.findByText('Last 3 hours')) + + expect(onSourceChange).toHaveBeenCalledWith({ + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 3, unit: 'hour' }, + }) + }) + + it('does not offer logs when source flags are disabled for a database query', async () => { + renderWithFlags({ _tag: 'database' }, { sqlEditorLogsSource: false, otelLegacyLogs: false }) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' })) + + expect(screen.queryByText('Logs')).not.toBeInTheDocument() + }) + + it('keeps logs available when an existing query already uses it', async () => { + renderWithFlags( + { + _tag: 'logs', + time_range: { _tag: 'relative_time_range', amount: 1, unit: 'hour' }, + }, + { sqlEditorLogsSource: false, otelLegacyLogs: false } + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Logs' })) + + expect(screen.getAllByText('Logs')).toHaveLength(2) + expect(screen.getByText('Database')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx index 89102a59e4b51..534de0c0e6cb6 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx @@ -1,7 +1,5 @@ import { useFlag, useParams } from 'common' -import dayjs from 'dayjs' import { Check, ChevronDown } from 'lucide-react' -import { useState } from 'react' import { Button, DropdownMenu, @@ -15,25 +13,28 @@ import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/ import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog' import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu' import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon' -import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import { useLogsCustomRange } from '@/components/interfaces/QuerySources/useLogsCustomRange' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' import { - createDefaultCellSource, + createDefaultSourceBinding, QUERY_SOURCE_LABELS, QUERY_SOURCES, - type CellSource, + type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' -import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' export type ExplorerQuerySourceMenuProps = { - source: CellSource - onSourceChange: (source: CellSource) => void + source: QuerySourceBinding + onSourceChange: (source: QuerySourceBinding) => void } /** * Source binding and parameter controls shared by standalone Explorer queries * and notebook query-cell toolbars. The consumer owns the binding; this menu - * only emits complete, validated-by-construction `CellSource` values. + * only emits complete, validated-by-construction `QuerySourceBinding` values. + * + * Selecting a different backend emits that backend's default binding — deciding + * what happens to the query body is the consumer's call, since a notebook cell + * has SQL to preserve or discard and a fresh draft does not. */ export const ExplorerQuerySourceMenu = ({ source, @@ -42,38 +43,23 @@ export const ExplorerQuerySourceMenu = ({ const { ref } = useParams() const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') const isOtelLogsEnabled = useFlag('otelLegacyLogs') - 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: (time_range) => onSourceChange({ _tag: 'logs', time_range }), + }) const availableSources = QUERY_SOURCES.filter( (candidate) => - candidate.type !== 'logs' || + candidate._tag !== 'logs' || (isLogsSourceEnabled && isOtelLogsEnabled) || - source.type === 'logs' + source._tag === 'logs' ) - const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => { - const fromIso = dayjs(from).startOf('day').toISOString() - if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) { - setShowUpgradePrompt(true) - return - } - - onSourceChange({ - id: 'logs', - type: 'logs', - parameters: { - time_range: { - type: 'absolute', - from: fromIso, - to: dayjs(to).endOf('day').toISOString(), - }, - }, - }) - } - return ( <> @@ -81,56 +67,46 @@ export const ExplorerQuerySourceMenu = ({ {availableSources.map((candidate) => ( { event.preventDefault() - if (candidate.id !== source.id) { - onSourceChange(createDefaultCellSource(candidate.id)) + if (candidate._tag !== source._tag) { + onSourceChange(createDefaultSourceBinding(candidate._tag)) } }} > - - {QUERY_SOURCE_LABELS[candidate.id]} + + {QUERY_SOURCE_LABELS[candidate._tag]} - {source.id === candidate.id && } + {source._tag === candidate._tag && } ))} - {source.type === 'database' ? ( + {source._tag === 'database' ? ( - onSourceChange({ - id: 'database', - type: 'database', - parameters: { identifier }, - }) + identifier={source.database_identifier ?? ref} + onIdentifierChange={(database_identifier) => + onSourceChange({ _tag: 'database', database_identifier }) } /> ) : ( - onSourceChange({ - id: 'logs', - type: 'logs', - parameters: { time_range: timeRange }, - }) - } + range={source.time_range} + onRangeChange={(time_range) => onSourceChange({ _tag: 'logs', time_range })} onOpenCustomRange={() => setIsCustomRangeOpen(true)} onShowUpgrade={() => setShowUpgradePrompt(true)} /> @@ -138,12 +114,12 @@ export const ExplorerQuerySourceMenu = ({ - {source.type === 'logs' && ( + {source._tag === 'logs' && ( <> diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.test.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.test.tsx new file mode 100644 index 0000000000000..916214c59e023 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.test.tsx @@ -0,0 +1,30 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { afterEach, describe, expect, it } from 'vitest' + +import { ExplorerQueryTabCoordinator } from './ExplorerQueryTabCoordinator' +import { explorerQueryState } from '@/state/explorer-query' +import { createTabsState, TabsStateContext } from '@/state/tabs' +import { customRender } from '@/tests/lib/custom-render' + +const QUERY_ID = 'pagehide-query' + +afterEach(() => { + explorerQueryState.removeDraft({ id: QUERY_ID, projectRef: 'default' }) +}) + +describe('ExplorerQueryTabCoordinator', () => { + it('flushes pending query edits when the page is hidden', () => { + const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('default') + explorerQueryState.createDraft({ id: QUERY_ID, projectRef: 'default' }) + explorerQueryState.updateDraft({ id: QUERY_ID, sql: 'select 1' }) + + customRender( + + + + ) + window.dispatchEvent(new Event('pagehide')) + + expect(JSON.parse(localStorage.getItem(key) ?? '{}')[QUERY_ID].sql).toBe('select 1') + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx index 14e8e58284106..51848b4f8625d 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx @@ -15,12 +15,14 @@ export const ExplorerQueryTabCoordinator = () => { useEffect(() => { return tabs.registerTabTypeHandler('query', { confirmClose: (queryTabs) => { - const populatedDraftCount = queryTabs.filter((tab) => { + for (const tab of queryTabs) { const queryId = tab.metadata?.queryId - if (!ref || !queryId) return false - - explorerQueryState.restoreDraft({ id: queryId, projectRef: ref }) + if (ref && queryId) explorerQueryState.restoreDraft({ id: queryId, projectRef: ref }) + } + const populatedDraftCount = queryTabs.filter((tab) => { + const queryId = tab.metadata?.queryId + if (!queryId) return false return explorerQueryState.drafts[queryId]?.uncheckedSql.trim().length > 0 }).length @@ -41,5 +43,22 @@ export const ExplorerQueryTabCoordinator = () => { }) }, [ref, tabs]) + useEffect(() => { + if (!ref) return + + const flushProjectDrafts = () => explorerQueryState.flushPendingPersistence({ projectRef: ref }) + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') flushProjectDrafts() + } + + window.addEventListener('pagehide', flushProjectDrafts) + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.removeEventListener('pagehide', flushProjectDrafts) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [ref]) + return null } diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 57c22e307aa37..0348eaf1942a8 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -13,8 +13,8 @@ import { } from '@/data/content/notebooks/notebook-schema' import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' import { - createDefaultCellSource, - type CellSource, + getQuerySourceBinding, + type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' @@ -39,10 +39,7 @@ export const QueryCell = ({ cell }: QueryCellProps) => { const { id, title: cellTitle, view, chart, unchecked_sql } = cell const rowLimit = 'row_limit' in cell ? cell.row_limit : undefined - const source = - cell._tag === 'database_cell' - ? createDefaultCellSource('database') - : createDefaultCellSource('logs') + const source = getQuerySourceBinding(cell) const [sql, setSql] = useState(unchecked_sql) const [result, setResult] = useState() @@ -53,7 +50,7 @@ export const QueryCell = ({ cell }: QueryCellProps) => { chart: chart ? { ...chart, y_columns: [...chart.y_columns] } : undefined, } - const handleSourceChange = (source: CellSource) => { + const handleSourceChange = (source: QuerySourceBinding) => { const notebookId = currentNotebook?.notebook.id if (!notebookId) return @@ -61,30 +58,35 @@ export const QueryCell = ({ cell }: QueryCellProps) => { id: notebookId, cellId: id, updater: (candidate) => { - if (source.type === 'database' && candidate._tag === 'log_cell') { + if (source._tag === 'database' && candidate._tag === 'log_cell') { const { _tag, time_range, unchecked_sql, ...rest } = candidate return { ...rest, _tag: 'database_cell' as const, row_limit: 100, + database_identifier: source.database_identifier, unchecked_sql: untrustedSql(unchecked_sql), } } - if (source.type === 'logs' && candidate._tag === 'database_cell') { - const { _tag, row_limit, unchecked_sql, ...rest } = candidate + if (source._tag === 'logs' && candidate._tag === 'database_cell') { + const { _tag, row_limit, database_identifier, unchecked_sql, ...rest } = candidate return { ...rest, _tag: 'log_cell' as const, - time_range: { - _tag: 'relative_time_range' as const, - unit: 'hour' as const, - amount: 1, - }, + time_range: source.time_range, unchecked_sql: untrustedLogSql(unchecked_sql), } } + if (source._tag === 'database' && candidate._tag === 'database_cell') { + return { ...candidate, database_identifier: source.database_identifier } + } + + if (source._tag === 'logs' && candidate._tag === 'log_cell') { + return { ...candidate, time_range: source.time_range } + } + return candidate }, }) diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index 8c79ef0077794..fba1aaa5503eb 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -24,18 +24,18 @@ import { DisplaySettingsButton } from './QueryCell/DisplaySettingsButton' import { QueryResultChart } from './QueryCell/QueryResultChart' import { QueryResultTable } from './QueryResultTable' import { type QueryDisplay, type QueryResult } from './types' -import { applyAutoLimit } from '@/components/interfaces/SQLEditor/SQLEditor.utils' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { isValidConnString } from '@/data/fetchers' import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql' import { - createDefaultCellSource, + createDefaultSourceBinding, QUERY_SOURCE_REGISTRY, - type CellSource, + type QuerySourceBinding, } from '@/data/query-sources/query-source-registry' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' +import { applyAutoLimit } from '@/data/sql/utils' import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -44,7 +44,7 @@ export type QueryEditorProps = { variant: 'embedded' | 'viewport' title: string sql: string - source?: CellSource + source?: QuerySourceBinding result?: QueryResult rowLimit?: number display?: QueryDisplay @@ -52,7 +52,7 @@ export type QueryEditorProps = { onTitleChange: (title: string) => void onSqlChange: (sql: string) => void onSqlCommit?: (sql: string) => void - onSourceChange?: (source: CellSource) => void + onSourceChange?: (source: QuerySourceBinding) => void onResultChange: (result: QueryResult) => void onDisplayChange?: (display: QueryDisplay) => void } @@ -87,12 +87,12 @@ export const QueryEditor = ({ const view = display?.view ?? 'table' const columns = Object.keys(result?.rows?.[0] ?? {}) - const sourceBinding = source ?? createDefaultCellSource('database') + const sourceBinding = source ?? createDefaultSourceBinding('database') const [showQuery, setShowQuery] = useState(true) const databaseIdentifier = - sourceBinding.type === 'database' ? sourceBinding.parameters.identifier : undefined + sourceBinding._tag === 'database' ? sourceBinding.database_identifier : undefined const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery( { projectRef: project?.ref }, @@ -124,7 +124,7 @@ export const QueryEditor = ({ onSqlCommit?.(sql) - if (sourceBinding.type === 'logs') { + if (sourceBinding._tag === 'logs') { if (!isOtelLogsEnabled) { onResultChange({ error: { message: "Querying logs isn't available for this project yet." }, @@ -135,7 +135,7 @@ export const QueryEditor = ({ executeLogsSql({ projectRef: project.ref, sql: acceptUntrustedLogsSql(untrustedLogSql(sqlToRun)), - range: resolveLogTimeRange(sourceBinding.parameters.time_range), + range: resolveLogTimeRange(sourceBinding.time_range), endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint, }) return diff --git a/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx b/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx index ba88cb1d27424..abf405d0e7827 100644 --- a/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx @@ -4,13 +4,13 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' -import { Results } from '../SQLEditor/UtilityPanel/Results' -import { getSqlErrorLines } from '../SQLEditor/UtilityPanel/UtilityTabResults.utils' import { type QueryResult } from './types' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import CopyButton from '@/components/ui/CopyButton' +import { DataGridResults } from '@/components/ui/DataGridResults' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' +import { getSqlErrorLines } from '@/data/sql/utils' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL } from '@/lib/constants' @@ -186,5 +186,5 @@ const QueryError = ({ // [Joshen] Eventually migrate the Results component here from SQL Editor const QueryResults = ({ rows }: { rows: NonNullable }) => { - return + return } diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx new file mode 100644 index 0000000000000..61c303017ada8 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx @@ -0,0 +1,171 @@ +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { QueryTab } from './QueryTab' +import type { ReadReplicasData } from '@/data/read-replicas/replicas-query' +import { explorerQueryState } from '@/state/explorer-query' +import { createTabsState, TabsStateContext } from '@/state/tabs' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' +import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' + +const testContext = vi.hoisted(() => ({ + flags: { otelLegacyLogs: true } as Record, + params: { ref: 'default', id: 'query-test' } as { ref?: string; id?: string }, +})) + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_PLATFORM: true, + useParams: () => testContext.params, + useFlag: (flag: string) => testContext.flags[flag] ?? false, + } +}) + +vi.mock('@/components/ui/CodeEditor/CodeEditor', () => ({ + CodeEditor: ({ value }: { value: string }) => ( +