feat(studio): toggle pg_graphql introspection from GraphiQL (#46170)

## Summary

- pg_graphql 1.6+ disables schema introspection by default, which breaks
GraphiQL's docs explorer and field autocomplete. This PR adds an in-app
notice + confirmation flow so users can opt into (or later opt out of)
introspection without leaving the GraphQL tab.
- Introspection state is read from, and written to, the `@graphql(...)`
directive embedded in the target schema's Postgres comment (`public` by
default). Other directive options the user has set are preserved when
the introspection key is toggled.
- Ships `parseSchemaComment` / `buildSchemaCommentWith` helpers (with
unit tests) and a `useSetIntrospection` mutation hook, plus collapsible
disabled-state and dismissible enabled-state notices rendered above
GraphiQL. GraphiQL is re-mounted after a toggle so it re-runs
introspection.

## Test plan

- [ ] On a project with pg_graphql >= 1.6 and introspection disabled:
disabled-state notice appears, confirm modal shows the SQL that will
run, enabling re-mounts GraphiQL and populates the docs explorer.
- [ ] On a project with introspection enabled: small enabled-state
banner appears, disabling clears the docs explorer and updates the
schema comment.
- [ ] Existing `@graphql({...})` options (e.g. `inflect_names`,
`max_rows`) survive a toggle; malformed directive text is replaced and a
warning is shown in the confirm modal.
- [ ] On pg_graphql < 1.6 (or extension not installed): no notice
renders, GraphiQL behaves as before.
- [ ] Collapsed-disabled-notice state persists per project via local
storage.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * GraphQL introspection toggle with enable/disable confirmation modal.
* Notices showing current introspection state with controls to change
it.
* GraphiQL automatically remounts and updates when introspection status
changes.
* Per-project persisted collapsed/expanded state for the introspection
notice.
* Background detection of introspection support and schema comment
handling for targeted schemas.

* **Tests**
* Comprehensive tests for parsing/building schema comment directives and
version behavior.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46170?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-05-21 07:07:35 -04:00
1 parent 16a09b7ff9
commit 72cebe3976
12 files changed
+1041 -10

No files matched your search

@@ -8,16 +8,21 @@ import { useParams } from 'common'
import { GraphiQL, HISTORY_PLUGIN } from 'graphiql'
import { User as IconUser } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { LogoLoader } from 'ui'
import { DEFAULT_INTROSPECTION_SCHEMA } from './constants'
import styles from './graphiql.module.css'
import { IntrospectionDisabledNotice } from './IntrospectionDisabledNotice'
import { IntrospectionEnabledNotice } from './IntrospectionEnabledNotice'
import { usePgGraphqlIntrospectionStatus } from './usePgGraphqlIntrospectionStatus'
import { getTheme } from '@/components/interfaces/App/MonacoThemeProvider'
import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector'
import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query'
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { API_URL, IS_PLATFORM } from '@/lib/constants'
import { getRoleImpersonationJWT } from '@/lib/role-impersonation'
import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state'
@@ -59,6 +64,7 @@ export const GraphiQLTab = () => {
const { ref: projectRef } = useParams()
const currentTheme = resolvedTheme?.includes('dark') ? 'dark' : 'light'
const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
const { data: project } = useSelectedProjectQuery()
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const jwtSecret = config?.jwt_secret
@@ -70,6 +76,16 @@ export const GraphiQLTab = () => {
'field.jwt_secret'
)
const { notice, schemaComment } = usePgGraphqlIntrospectionStatus({
projectRef,
connectionString: project?.connectionString,
schema: DEFAULT_INTROSPECTION_SCHEMA,
})
// Bumped to force GraphiQL to re-mount and re-run introspection after the
// introspection setting changes in either direction.
const [graphiqlKey, setGraphiqlKey] = useState(0)
const plugins = useMemo<GraphiQLPlugin[]>(
() => (canReadJWTSecret ? [HISTORY_PLUGIN, ROLE_IMPERSONATION_PLUGIN] : [HISTORY_PLUGIN]),
[canReadJWTSecret]
@@ -118,20 +134,41 @@ export const GraphiQLTab = () => {
return customFetcher
}, [projectRef, getImpersonatedRoleState, jwtSecret, accessToken])
const handleIntrospectionChanged = useCallback(() => {
setGraphiqlKey((k) => k + 1)
}, [])
if (IS_PLATFORM && !accessToken) {
return <LogoLoader />
}
return (
<>
<div className="flex flex-col h-full">
<GraphiQLMonacoTheme resolvedTheme={currentTheme} />
<GraphiQL
fetcher={fetcher}
forcedTheme={currentTheme}
editorTheme={MONACO_THEME}
className={styles.root}
plugins={plugins}
/>
</>
{notice === 'opt-in' && (
<IntrospectionDisabledNotice
schema={DEFAULT_INTROSPECTION_SCHEMA}
currentSchemaComment={schemaComment}
onEnabled={handleIntrospectionChanged}
/>
)}
{notice === 'opt-out' && (
<IntrospectionEnabledNotice
schema={DEFAULT_INTROSPECTION_SCHEMA}
currentSchemaComment={schemaComment}
onDisabled={handleIntrospectionChanged}
/>
)}
<div className="flex-1 min-h-0">
<GraphiQL
key={graphiqlKey}
fetcher={fetcher}
forcedTheme={currentTheme}
editorTheme={MONACO_THEME}
className={styles.root}
plugins={plugins}
/>
</div>
</div>
)
}
@@ -0,0 +1,119 @@
import { type SafeSqlFragment } from '@supabase/pg-meta'
import type { ReactNode } from 'react'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
type IntrospectionMode = 'enable' | 'disable'
interface ModeCopy {
title: string
confirmLabel: string
confirmLabelLoading: string
persistenceBullet: (schema: string) => ReactNode
securityBullet: ReactNode
}
const COPY: Record<IntrospectionMode, ModeCopy> = {
enable: {
title: 'Enable GraphQL introspection?',
confirmLabel: 'Enable introspection',
confirmLabelLoading: 'Enabling...',
persistenceBullet: (_schema) => <>This setting persists until explicitly disabled.</>,
securityBullet: (
<>
External actors will be able to introspect your schema using the <code>anon</code> key.
</>
),
},
disable: {
title: 'Disable GraphQL introspection?',
confirmLabel: 'Disable introspection',
confirmLabelLoading: 'Disabling...',
persistenceBullet: (_schema) => <>This setting persists until explicitly re-enabled.</>,
securityBullet: (
<>
External actors will no longer be able to introspect your schema via the <code>anon</code>{' '}
key. GraphiQL's docs explorer and autocomplete will stop working until introspection is
re-enabled.
</>
),
},
}
interface IntrospectionConfirmModalProps {
mode: IntrospectionMode
visible: boolean
schema: string
sql: SafeSqlFragment
otherExistingKeys: string[]
existingDirectiveIsMalformed: boolean
isPending: boolean
onCancel: () => void
onConfirm: () => void
}
export const IntrospectionConfirmModal = ({
mode,
visible,
schema,
sql,
otherExistingKeys,
existingDirectiveIsMalformed,
isPending,
onCancel,
onConfirm,
}: IntrospectionConfirmModalProps) => {
const copy = COPY[mode]
const hasPreservedOptions = otherExistingKeys.length > 0
return (
<ConfirmationModal
visible={visible}
size="large"
title={copy.title}
confirmLabel={copy.confirmLabel}
confirmLabelLoading={copy.confirmLabelLoading}
cancelLabel="Cancel"
loading={isPending}
onCancel={onCancel}
onConfirm={onConfirm}
>
<div className="space-y-4 text-sm">
<ul className="list-disc space-y-1 pl-5 text-foreground-light">
<li>{copy.persistenceBullet(schema)}</li>
<li>{copy.securityBullet}</li>
{hasPreservedOptions && (
<li>
Existing <code>@graphql(...)</code> options on this schema will be preserved:{' '}
<PreservedOptionKeys keys={otherExistingKeys} />.
</li>
)}
{existingDirectiveIsMalformed && (
<li className="text-warning">
The existing <code>@graphql(...)</code> directive on this schema could not be parsed
and will be replaced by the statement below.
</li>
)}
</ul>
<div>
<p className="text-foreground-light mb-2">The following statement will be executed:</p>
<CodeBlock language="sql" className="text-xs" hideLineNumbers>
{sql}
</CodeBlock>
</div>
</div>
</ConfirmationModal>
)
}
const PreservedOptionKeys = ({ keys }: { keys: string[] }) => (
<>
{keys.map((k, i) => (
<span key={k}>
{i > 0 && ', '}
<code>{k}</code>
</span>
))}
</>
)
@@ -0,0 +1,107 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { PG_GRAPHQL_CONFIG_DOCS_URL } from './constants'
import { IntrospectionConfirmModal } from './IntrospectionConfirmModal'
import { useSetIntrospection } from './useSetIntrospection'
import { InlineLink } from '@/components/ui/InlineLink'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
interface IntrospectionDisabledNoticeProps {
schema: string
currentSchemaComment: string | null | undefined
onEnabled: () => void
}
export const IntrospectionDisabledNotice = ({
schema,
currentSchemaComment,
onEnabled,
}: IntrospectionDisabledNoticeProps) => {
const { ref: projectRef } = useParams()
const [showConfirm, setShowConfirm] = useState(false)
const [isCollapsed, setIsCollapsed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED(projectRef ?? ''),
false
)
const { apply, isPending, sql, existingDirectiveIsMalformed, otherExistingKeys } =
useSetIntrospection({
schema,
currentSchemaComment,
enabled: true,
onMutationSuccess: () => setShowConfirm(false),
onInvalidated: onEnabled,
})
return (
<>
{isCollapsed ? (
<div className="flex items-center justify-between gap-3 border-b bg-surface-100 px-4 py-2 text-xs text-foreground-light">
<span>
GraphQL introspection is disabled — docs explorer and autocomplete are unavailable.
</span>
<div className="flex items-center gap-1">
<Button type="default" size="tiny" onClick={() => setShowConfirm(true)}>
Enable introspection
</Button>
<Button
type="text"
size="tiny"
icon={<ChevronDown />}
onClick={() => setIsCollapsed(false)}
aria-label="Show introspection notice details"
/>
</div>
</div>
) : (
<div className="relative">
<Admonition
type="default"
title="GraphQL introspection is disabled for this project"
className="m-0 rounded-none border-x-0 border-t-0"
>
<p>
GraphiQL relies on introspection to populate the docs explorer and field autocomplete.
With <code>pg_graphql</code> 1.6+, introspection is disabled by default so that
schemas aren't enumerable via the API. You can still run queries — only schema
discovery is affected.{' '}
<InlineLink href={PG_GRAPHQL_CONFIG_DOCS_URL} target="_blank" rel="noreferrer">
Learn more
</InlineLink>
.
</p>
<div className="mt-3">
<Button type="default" onClick={() => setShowConfirm(true)}>
Enable introspection
</Button>
</div>
</Admonition>
<Button
className="absolute right-2 top-2"
type="text"
size="tiny"
icon={<ChevronUp />}
onClick={() => setIsCollapsed(true)}
aria-label="Collapse introspection notice"
/>
</div>
)}
<IntrospectionConfirmModal
mode="enable"
visible={showConfirm}
schema={schema}
sql={sql}
otherExistingKeys={otherExistingKeys}
existingDirectiveIsMalformed={existingDirectiveIsMalformed}
isPending={isPending}
onCancel={() => setShowConfirm(false)}
onConfirm={apply}
/>
</>
)
}
@@ -0,0 +1,54 @@
import { useState } from 'react'
import { Button } from 'ui'
import { IntrospectionConfirmModal } from './IntrospectionConfirmModal'
import { useSetIntrospection } from './useSetIntrospection'
interface IntrospectionEnabledNoticeProps {
schema: string
currentSchemaComment: string | null | undefined
onDisabled: () => void
}
export const IntrospectionEnabledNotice = ({
schema,
currentSchemaComment,
onDisabled,
}: IntrospectionEnabledNoticeProps) => {
const [showConfirm, setShowConfirm] = useState(false)
const { apply, isPending, sql, existingDirectiveIsMalformed, otherExistingKeys } =
useSetIntrospection({
schema,
currentSchemaComment,
enabled: false,
onMutationSuccess: () => setShowConfirm(false),
onInvalidated: onDisabled,
})
return (
<>
<div className="flex items-center justify-between gap-3 border-b bg-surface-100 px-4 py-2 text-xs text-foreground-light">
<span>
GraphQL introspection is enabled for this project, so schemas are discoverable through the
API.
</span>
<Button type="default" size="tiny" onClick={() => setShowConfirm(true)}>
Disable introspection
</Button>
</div>
<IntrospectionConfirmModal
mode="disable"
visible={showConfirm}
schema={schema}
sql={sql}
otherExistingKeys={otherExistingKeys}
existingDirectiveIsMalformed={existingDirectiveIsMalformed}
isPending={isPending}
onCancel={() => setShowConfirm(false)}
onConfirm={apply}
/>
</>
)
}
@@ -0,0 +1,10 @@
export const PG_GRAPHQL_EXTENSION_NAME = 'pg_graphql'
/**
* The Postgres schema we check (and rewrite) the `@graphql(...)` directive on.
* `public` is the conventional user-data schema in Supabase projects, and
* pg_graphql treats each schema's introspection setting independently.
*/
export const DEFAULT_INTROSPECTION_SCHEMA = 'public'
export const PG_GRAPHQL_CONFIG_DOCS_URL = 'https://supabase.com/docs/guides/graphql#supabase-studio'
@@ -0,0 +1,313 @@
import { describe, expect, it } from 'vitest'
import {
buildSchemaCommentWith,
isIntrospectionEnabled,
isPgGraphqlIntrospectionOptIn,
parseSchemaComment,
} from './pgGraphqlSchemaComment'
describe('parseSchemaComment', () => {
it('returns no directive for null', () => {
expect(parseSchemaComment(null)).toEqual({
options: {},
hasDirective: false,
isMalformed: false,
prefix: '',
suffix: '',
})
})
it('returns no directive for undefined', () => {
expect(parseSchemaComment(undefined)).toEqual({
options: {},
hasDirective: false,
isMalformed: false,
prefix: '',
suffix: '',
})
})
it('returns no directive for empty string', () => {
expect(parseSchemaComment('')).toEqual({
options: {},
hasDirective: false,
isMalformed: false,
prefix: '',
suffix: '',
})
})
it('parses a directive with no options', () => {
expect(parseSchemaComment('@graphql({})')).toEqual({
options: {},
hasDirective: true,
isMalformed: false,
prefix: '',
suffix: '',
})
})
it('parses a directive with introspection: true', () => {
expect(parseSchemaComment('@graphql({"introspection": true})')).toMatchObject({
options: { introspection: true },
hasDirective: true,
isMalformed: false,
})
})
it('parses a directive with introspection: false', () => {
expect(parseSchemaComment('@graphql({"introspection": false})')).toMatchObject({
options: { introspection: false },
hasDirective: true,
isMalformed: false,
})
})
it('parses multiple option keys', () => {
expect(
parseSchemaComment(
'@graphql({"introspection": true, "inflect_names": true, "max_rows": 100})'
)
).toMatchObject({
options: { introspection: true, inflect_names: true, max_rows: 100 },
hasDirective: true,
})
})
it('preserves surrounding text', () => {
const result = parseSchemaComment(
'a user-written prefix @graphql({"introspection": true}) and a suffix'
)
expect(result).toMatchObject({
options: { introspection: true },
hasDirective: true,
prefix: 'a user-written prefix ',
suffix: ' and a suffix',
})
})
it('treats a comment without a directive as prefix-only', () => {
expect(parseSchemaComment('Just a plain comment.')).toEqual({
options: {},
hasDirective: false,
isMalformed: false,
prefix: 'Just a plain comment.',
suffix: '',
})
})
it('handles whitespace between @graphql and the opening paren', () => {
expect(parseSchemaComment('@graphql ({"introspection": true})')).toMatchObject({
options: { introspection: true },
hasDirective: true,
})
})
it('handles whitespace inside the directive parens', () => {
expect(parseSchemaComment('@graphql( {"introspection": true} )')).toMatchObject({
options: { introspection: true },
hasDirective: true,
})
})
it('handles JSON with nested objects and arrays', () => {
const comment =
'@graphql({"introspection": true, "schema": {"nested": {"deep": [1, 2, 3]}, "list": []}})'
expect(parseSchemaComment(comment)).toMatchObject({
options: {
introspection: true,
schema: { nested: { deep: [1, 2, 3] }, list: [] },
},
hasDirective: true,
})
})
it('handles string values containing braces and parens', () => {
const comment = '@graphql({"label": "value with } { ( ) braces"})'
expect(parseSchemaComment(comment)).toMatchObject({
options: { label: 'value with } { ( ) braces' },
hasDirective: true,
})
})
it('handles escaped quotes inside string values', () => {
const comment = '@graphql({"label": "she said \\"hi\\""})'
expect(parseSchemaComment(comment)).toMatchObject({
options: { label: 'she said "hi"' },
hasDirective: true,
})
})
it('treats invalid JSON as malformed', () => {
expect(parseSchemaComment('@graphql({not valid json})')).toMatchObject({
options: {},
hasDirective: true,
isMalformed: true,
})
})
it('treats trailing-comma JSON as malformed', () => {
expect(parseSchemaComment('@graphql({"introspection": true,})')).toMatchObject({
options: {},
hasDirective: true,
isMalformed: true,
})
})
it('ignores incomplete @graphql with no closing paren', () => {
expect(parseSchemaComment('@graphql({"introspection": true}')).toEqual({
options: {},
hasDirective: false,
isMalformed: false,
prefix: '@graphql({"introspection": true}',
suffix: '',
})
})
it('ignores @graphql followed by something other than {', () => {
expect(parseSchemaComment('@graphql(true)')).toEqual({
options: {},
hasDirective: false,
isMalformed: false,
prefix: '@graphql(true)',
suffix: '',
})
})
it('only matches the first @graphql directive', () => {
const comment = '@graphql({"introspection": true}) tail @graphql({"max_rows": 5})'
const result = parseSchemaComment(comment)
expect(result.options).toEqual({ introspection: true })
expect(result.hasDirective).toBe(true)
expect(result.prefix).toBe('')
expect(result.suffix).toBe(' tail @graphql({"max_rows": 5})')
})
})
describe('buildSchemaCommentWith', () => {
it('produces a directive when comment is null', () => {
expect(buildSchemaCommentWith(null, { introspection: true })).toBe(
'@graphql({"introspection":true})'
)
})
it('produces a directive when comment is undefined', () => {
expect(buildSchemaCommentWith(undefined, { introspection: true })).toBe(
'@graphql({"introspection":true})'
)
})
it('produces a directive when comment is empty', () => {
expect(buildSchemaCommentWith('', { introspection: true })).toBe(
'@graphql({"introspection":true})'
)
})
it('appends a directive after existing non-directive text', () => {
expect(buildSchemaCommentWith('User notes about this schema', { introspection: true })).toBe(
'User notes about this schema @graphql({"introspection":true})'
)
})
it('avoids double-spacing when existing text already ends in a space', () => {
expect(buildSchemaCommentWith('User notes ', { introspection: true })).toBe(
'User notes @graphql({"introspection":true})'
)
})
it('replaces only the directive when one exists, preserving surrounding text', () => {
expect(
buildSchemaCommentWith('prefix @graphql({"inflect_names": true}) suffix', {
introspection: true,
})
).toBe('prefix @graphql({"inflect_names":true,"introspection":true}) suffix')
})
it('merges new keys with existing keys', () => {
expect(
buildSchemaCommentWith('@graphql({"inflect_names": true, "max_rows": 100})', {
introspection: true,
})
).toBe('@graphql({"inflect_names":true,"max_rows":100,"introspection":true})')
})
it('overrides existing keys with new values', () => {
expect(
buildSchemaCommentWith('@graphql({"introspection": false, "max_rows": 100})', {
introspection: true,
})
).toBe('@graphql({"introspection":true,"max_rows":100})')
})
it('preserves nested object values when merging', () => {
expect(
buildSchemaCommentWith('@graphql({"schema": {"foo": "bar"}, "max_rows": 100})', {
introspection: true,
})
).toBe('@graphql({"schema":{"foo":"bar"},"max_rows":100,"introspection":true})')
})
it('discards malformed directive content and writes a clean directive', () => {
expect(
buildSchemaCommentWith('prefix @graphql({not valid json}) suffix', { introspection: true })
).toBe('prefix @graphql({"introspection":true}) suffix')
})
it('supports disabling introspection', () => {
expect(
buildSchemaCommentWith('@graphql({"introspection": true, "max_rows": 100})', {
introspection: false,
})
).toBe('@graphql({"introspection":false,"max_rows":100})')
})
})
describe('isIntrospectionEnabled', () => {
it('returns true only when introspection is the boolean true', () => {
expect(isIntrospectionEnabled({ introspection: true })).toBe(true)
})
it.each([
[{}],
[{ introspection: false }],
[{ introspection: 'true' }],
[{ introspection: 1 }],
[{ introspection: null }],
[{ other: true }],
])('returns false for %j', (options) => {
expect(isIntrospectionEnabled(options as Record<string, unknown>)).toBe(false)
})
})
describe('isPgGraphqlIntrospectionOptIn', () => {
it.each([
['1.6.0', true],
['1.6.1', true],
['1.7.0', true],
['2.0.0', true],
['1.5.9', false],
['1.5.0', false],
['1.0.0', false],
['0.9.0', false],
])('version %s returns %s', (version, expected) => {
expect(isPgGraphqlIntrospectionOptIn(version)).toBe(expected)
})
it('handles versions without patch', () => {
expect(isPgGraphqlIntrospectionOptIn('1.6')).toBe(true)
expect(isPgGraphqlIntrospectionOptIn('1.5')).toBe(false)
})
it('ignores pre-release / build suffixes', () => {
expect(isPgGraphqlIntrospectionOptIn('1.6.0-rc.1')).toBe(true)
expect(isPgGraphqlIntrospectionOptIn('1.5.9-rc.1')).toBe(false)
})
it.each([[null], [undefined], [''], ['not-a-version'], ['x.y.z']])(
'returns false for unparseable input %j',
(version) => {
expect(isPgGraphqlIntrospectionOptIn(version)).toBe(false)
}
)
})
@@ -0,0 +1,183 @@
/**
* Helpers for parsing and updating the pg_graphql configuration directive that
* lives inside a Postgres schema comment.
*
* pg_graphql reads its per-schema configuration from a directive of the form:
*
* @graphql({"introspection": true, "inflect_names": true})
*
* embedded anywhere in the schema comment. There is at most one such directive
* per schema; if the user has set arbitrary other comment text alongside it,
* we preserve that text when rewriting the directive.
*/
export type GraphqlOptions = Record<string, unknown>
export type ParsedSchemaComment = {
/** Options parsed from the directive. Empty object when no directive exists. */
options: GraphqlOptions
/** True if a recognizable `@graphql(...)` directive was found. */
hasDirective: boolean
/** True if a directive was found but its JSON body could not be parsed. */
isMalformed: boolean
/** Text before the directive (empty when there is none). */
prefix: string
/** Text after the directive (empty when there is none). */
suffix: string
}
type DirectiveLocation = {
/** Index of the `@` that starts the directive. */
start: number
/** Index after the matching `)`. */
end: number
/** Index of the opening `{` of the JSON body. */
jsonStart: number
/** Index after the matching `}` of the JSON body. */
jsonEnd: number
}
/**
* Locate a single `@graphql(...)` directive in the given text. Returns null if
* no syntactically well-formed directive is found.
*
* The matcher walks JSON strings character-by-character so that braces inside
* string values (e.g. `"label": "}{"`) don't confuse the balance counter.
*/
const findDirective = (text: string): DirectiveLocation | null => {
const directiveMatch = /@graphql\s*\(/.exec(text)
if (!directiveMatch) return null
const start = directiveMatch.index
let i = start + directiveMatch[0].length
// Skip whitespace between `(` and the opening `{`.
while (i < text.length && /\s/.test(text[i])) i++
if (text[i] !== '{') return null
const jsonStart = i
let depth = 0
let inString = false
let escape = false
for (; i < text.length; i++) {
const c = text[i]
if (escape) {
escape = false
continue
}
if (inString) {
if (c === '\\') escape = true
else if (c === '"') inString = false
continue
}
if (c === '"') {
inString = true
} else if (c === '{') {
depth++
} else if (c === '}') {
depth--
if (depth === 0) {
const jsonEnd = i + 1
// Skip whitespace between `}` and the closing `)`.
let j = jsonEnd
while (j < text.length && /\s/.test(text[j])) j++
if (text[j] !== ')') return null
return { start, end: j + 1, jsonStart, jsonEnd }
}
}
}
return null
}
export const parseSchemaComment = (comment: string | null | undefined): ParsedSchemaComment => {
const text = comment ?? ''
const location = findDirective(text)
if (!location) {
return {
options: {},
hasDirective: false,
isMalformed: false,
prefix: text,
suffix: '',
}
}
const json = text.slice(location.jsonStart, location.jsonEnd)
let options: GraphqlOptions = {}
let isMalformed = false
try {
const parsed = JSON.parse(json)
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
options = parsed as GraphqlOptions
} else {
isMalformed = true
}
} catch {
isMalformed = true
}
return {
options,
hasDirective: true,
isMalformed,
prefix: text.slice(0, location.start),
suffix: text.slice(location.end),
}
}
/**
* Produce an updated schema comment string with `overrides` merged into the
* directive's options. Any surrounding comment text is preserved. When the
* existing directive is malformed, its options are discarded and replaced by
* `overrides` alone.
*/
export const buildSchemaCommentWith = (
comment: string | null | undefined,
overrides: GraphqlOptions
): string => {
const parsed = parseSchemaComment(comment)
const baseOptions = parsed.isMalformed ? {} : parsed.options
const merged: GraphqlOptions = { ...baseOptions, ...overrides }
const directive = `@graphql(${JSON.stringify(merged)})`
if (!parsed.hasDirective) {
// Preserve any prior text; insert the directive at the end with a single
// space separator if the prior text is non-empty.
const existing = parsed.prefix
if (existing.length === 0) return directive
return existing.endsWith(' ') ? `${existing}${directive}` : `${existing} ${directive}`
}
return `${parsed.prefix}${directive}${parsed.suffix}`
}
/**
* Returns true when the parsed options explicitly set `introspection: true`.
* Every other value (including missing, `false`, or non-boolean) is treated as
* "introspection not enabled" so callers can show the opt-in notice.
*/
export const isIntrospectionEnabled = (options: GraphqlOptions): boolean => {
return options.introspection === true
}
/**
* Returns true when the installed pg_graphql version is >= 1.6.0, which is the
* first version that disables introspection by default.
*
* Accepts standard `MAJOR.MINOR.PATCH` strings; pre-release / build suffixes
* are ignored. Returns false on unparseable input so older / unknown
* installations fall back to the legacy "introspection on by default" behavior.
*/
export const isPgGraphqlIntrospectionOptIn = (version: string | null | undefined): boolean => {
if (!version) return false
const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
if (!match) return false
const major = Number(match[1])
const minor = Number(match[2])
if (Number.isNaN(major) || Number.isNaN(minor)) return false
if (major > 1) return true
if (major < 1) return false
return minor >= 6
}
@@ -0,0 +1,78 @@
import { useMemo } from 'react'
import { PG_GRAPHQL_EXTENSION_NAME } from './constants'
import {
isIntrospectionEnabled,
isPgGraphqlIntrospectionOptIn,
parseSchemaComment,
} from './pgGraphqlSchemaComment'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useSchemaCommentQuery } from '@/data/pg-graphql/schema-comment-query'
type UsePgGraphqlIntrospectionStatusArgs = {
projectRef: string | undefined
connectionString: string | null | undefined
schema: string
enabled?: boolean
}
/**
* Which introspection notice (if any) should be shown for a pg_graphql >= 1.6 project:
* - `opt-in`: introspection is currently off — prompt to enable.
* - `opt-out`: introspection is currently on — surface a control to disable.
* - `null`: nothing to show (version doesn't require opt-in, or still loading).
*/
export type IntrospectionNotice = 'opt-in' | 'opt-out' | null
export type PgGraphqlIntrospectionStatus = {
/** True while either underlying query is still loading. */
isLoading: boolean
notice: IntrospectionNotice
/** The raw schema comment string, or null when there is no comment yet. */
schemaComment: string | null | undefined
}
export const usePgGraphqlIntrospectionStatus = ({
projectRef,
connectionString,
schema,
enabled = true,
}: UsePgGraphqlIntrospectionStatusArgs): PgGraphqlIntrospectionStatus => {
const { data: pgGraphqlVersion, isLoading: isVersionLoading } = useDatabaseExtensionsQuery<
string | null
>(
{ projectRef, connectionString },
{
enabled,
select: (extensions) =>
extensions.find((ext) => ext.name === PG_GRAPHQL_EXTENSION_NAME)?.installed_version ?? null,
}
)
// Only fetch the schema comment when the installed version actually requires
// the opt-in. Older versions enable introspection by default, so the comment
// is irrelevant for the notice.
const versionRequiresOptIn = isPgGraphqlIntrospectionOptIn(pgGraphqlVersion)
const {
data: schemaComment,
isLoading: isCommentLoading,
isError: isCommentError,
} = useSchemaCommentQuery(
{ projectRef, connectionString, schema },
{ enabled: enabled && versionRequiresOptIn }
)
const notice = useMemo<IntrospectionNotice>(() => {
if (!versionRequiresOptIn) return null
if (isCommentLoading || isCommentError) return null
const parsed = parseSchemaComment(schemaComment)
return isIntrospectionEnabled(parsed.options) ? 'opt-out' : 'opt-in'
}, [versionRequiresOptIn, schemaComment, isCommentLoading, isCommentError])
return {
isLoading: isVersionLoading || (versionRequiresOptIn && isCommentLoading),
notice,
schemaComment,
}
}
@@ -0,0 +1,67 @@
import { ident, literal, safeSql } from '@supabase/pg-meta'
import { useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { buildSchemaCommentWith, parseSchemaComment } from './pgGraphqlSchemaComment'
import { pgGraphqlKeys } from '@/data/pg-graphql/keys'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
interface UseSetIntrospectionParams {
schema: string
currentSchemaComment: string | null | undefined
/** Target state — true enables introspection, false disables it. */
enabled: boolean
/** Fires synchronously when the mutation succeeds, before query invalidation — use to close the confirmation modal. */
onMutationSuccess: () => void
/** Fires after dependent queries are invalidated — use to trigger remounts that depend on fresh data. */
onInvalidated: () => void
}
export const useSetIntrospection = ({
schema,
currentSchemaComment,
enabled,
onMutationSuccess,
onInvalidated,
}: UseSetIntrospectionParams) => {
const { data: project } = useSelectedProjectQuery()
const queryClient = useQueryClient()
const parsed = parseSchemaComment(currentSchemaComment)
const nextComment = buildSchemaCommentWith(currentSchemaComment, { introspection: enabled })
const sql = safeSql`comment on schema ${ident(schema)} is ${literal(nextComment)};`
// If the existing directive was unparseable we'd be silently discarding the
// user's prior options. Surface that so the UI can warn before confirming.
const existingDirectiveIsMalformed = parsed.hasDirective && parsed.isMalformed
const otherExistingKeys = Object.keys(parsed.options).filter((k) => k !== 'introspection')
const pastVerb = enabled ? 'enabled' : 'disabled'
const presentVerb = enabled ? 'enable' : 'disable'
const { mutate, isPending } = useExecuteSqlMutation({
onSuccess: async (_data, variables) => {
toast.success(`Introspection ${pastVerb} on schema "${schema}".`)
onMutationSuccess()
await queryClient.invalidateQueries({
queryKey: pgGraphqlKeys.schemaComment(variables.projectRef, schema),
})
onInvalidated()
},
onError: (error) => {
toast.error(`Failed to ${presentVerb} introspection: ${error.message}`)
},
})
const apply = () => {
if (!project?.ref) return
mutate({
projectRef: project.ref,
connectionString: project.connectionString,
sql,
})
}
return { apply, isPending, sql, existingDirectiveIsMalformed, otherExistingKeys }
}
+4
View File
@@ -0,0 +1,4 @@
export const pgGraphqlKeys = {
schemaComment: (projectRef: string | undefined, schema: string) =>
['projects', projectRef, 'pg-graphql', 'schema-comment', schema] as const,
}
@@ -0,0 +1,57 @@
import { literal, safeSql } from '@supabase/pg-meta'
import { useQuery } from '@tanstack/react-query'
import { pgGraphqlKeys } from './keys'
import { executeSql } from '@/data/sql/execute-sql-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
import type { UseCustomQueryOptions } from '@/types'
export type SchemaCommentVariables = {
projectRef?: string
connectionString?: string | null
schema: string
}
export type SchemaCommentData = string | null
export type SchemaCommentError = Error
const getSchemaCommentSql = (schema: string) =>
safeSql`select obj_description(${literal(schema)}::regnamespace, 'pg_namespace') as comment;`
export async function getSchemaComment(
{ projectRef, connectionString, schema }: SchemaCommentVariables,
signal?: AbortSignal
): Promise<SchemaCommentData> {
const sql = getSchemaCommentSql(schema)
const { result } = await executeSql(
{
projectRef,
connectionString,
sql,
queryKey: ['pg-graphql', 'schema-comment', schema],
},
signal
)
const row = Array.isArray(result) ? result[0] : null
const comment = row?.comment
return typeof comment === 'string' ? comment : null
}
export const useSchemaCommentQuery = <TData = SchemaCommentData>(
{ projectRef, connectionString, schema }: SchemaCommentVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<SchemaCommentData, SchemaCommentError, TData> = {}
) => {
const { data: project } = useSelectedProjectQuery()
const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
return useQuery<SchemaCommentData, SchemaCommentError, TData>({
queryKey: pgGraphqlKeys.schemaComment(projectRef, schema),
queryFn: ({ signal }) => getSchemaComment({ projectRef, connectionString, schema }, signal),
enabled: enabled && projectRef !== undefined && isActive,
...options,
})
}
@@ -54,6 +54,8 @@ export const LOCAL_STORAGE_KEYS = {
LOG_EXPLORER_SPLIT_SIZE: 'supabase_log-explorer-split-size',
GRAPHIQL_RLS_BYPASS_WARNING: 'graphiql-rls-bypass-warning-dismissed',
GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED: (ref: string) =>
`graphql-introspection-notice-collapsed-${ref}`,
CLS_DIFF_WARNING: 'cls-diff-warning-dismissed',
CLS_SELECT_STAR_WARNING: 'cls-select-star-warning-dismissed',
QUERY_PERF_SHOW_BOTTOM_SECTION: 'supabase-query-perf-show-bottom-section',