diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx index 4f353b8700b..e6525e61d85 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx @@ -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( () => (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 } return ( - <> +
- - + {notice === 'opt-in' && ( + + )} + {notice === 'opt-out' && ( + + )} +
+ +
+
) } diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionConfirmModal.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionConfirmModal.tsx new file mode 100644 index 00000000000..1d3267a8e13 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionConfirmModal.tsx @@ -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 = { + 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 anon 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 anon{' '} + 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 ( + +
+
    +
  • {copy.persistenceBullet(schema)}
  • +
  • {copy.securityBullet}
  • + {hasPreservedOptions && ( +
  • + Existing @graphql(...) options on this schema will be preserved:{' '} + . +
  • + )} + {existingDirectiveIsMalformed && ( +
  • + The existing @graphql(...) directive on this schema could not be parsed + and will be replaced by the statement below. +
  • + )} +
+ +
+

The following statement will be executed:

+ + {sql} + +
+
+
+ ) +} + +const PreservedOptionKeys = ({ keys }: { keys: string[] }) => ( + <> + {keys.map((k, i) => ( + + {i > 0 && ', '} + {k} + + ))} + +) diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionDisabledNotice.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionDisabledNotice.tsx new file mode 100644 index 00000000000..cb57b58e8de --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionDisabledNotice.tsx @@ -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 ? ( +
+ + GraphQL introspection is disabled — docs explorer and autocomplete are unavailable. + +
+ +
+
+ ) : ( +
+ +

+ GraphiQL relies on introspection to populate the docs explorer and field autocomplete. + With pg_graphql 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.{' '} + + Learn more + + . +

+
+ +
+
+
+ )} + + setShowConfirm(false)} + onConfirm={apply} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionEnabledNotice.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionEnabledNotice.tsx new file mode 100644 index 00000000000..6c0d6441202 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionEnabledNotice.tsx @@ -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 ( + <> +
+ + GraphQL introspection is enabled for this project, so schemas are discoverable through the + API. + + +
+ + setShowConfirm(false)} + onConfirm={apply} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/constants.ts b/apps/studio/components/interfaces/Integrations/GraphQL/constants.ts new file mode 100644 index 00000000000..e5e4ef531d7 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/constants.ts @@ -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' diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/pgGraphqlSchemaComment.test.ts b/apps/studio/components/interfaces/Integrations/GraphQL/pgGraphqlSchemaComment.test.ts new file mode 100644 index 00000000000..cf5360219c1 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/pgGraphqlSchemaComment.test.ts @@ -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)).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) + } + ) +}) diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/pgGraphqlSchemaComment.ts b/apps/studio/components/interfaces/Integrations/GraphQL/pgGraphqlSchemaComment.ts new file mode 100644 index 00000000000..80a3aa5fc16 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/pgGraphqlSchemaComment.ts @@ -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 + +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 +} diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/usePgGraphqlIntrospectionStatus.ts b/apps/studio/components/interfaces/Integrations/GraphQL/usePgGraphqlIntrospectionStatus.ts new file mode 100644 index 00000000000..67fde958c36 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/usePgGraphqlIntrospectionStatus.ts @@ -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(() => { + 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, + } +} diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/useSetIntrospection.ts b/apps/studio/components/interfaces/Integrations/GraphQL/useSetIntrospection.ts new file mode 100644 index 00000000000..db6385d04fb --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/useSetIntrospection.ts @@ -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 } +} diff --git a/apps/studio/data/pg-graphql/keys.ts b/apps/studio/data/pg-graphql/keys.ts new file mode 100644 index 00000000000..66f5e7e36da --- /dev/null +++ b/apps/studio/data/pg-graphql/keys.ts @@ -0,0 +1,4 @@ +export const pgGraphqlKeys = { + schemaComment: (projectRef: string | undefined, schema: string) => + ['projects', projectRef, 'pg-graphql', 'schema-comment', schema] as const, +} diff --git a/apps/studio/data/pg-graphql/schema-comment-query.ts b/apps/studio/data/pg-graphql/schema-comment-query.ts new file mode 100644 index 00000000000..be411fd4494 --- /dev/null +++ b/apps/studio/data/pg-graphql/schema-comment-query.ts @@ -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 { + 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 = ( + { projectRef, connectionString, schema }: SchemaCommentVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => { + const { data: project } = useSelectedProjectQuery() + const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + return useQuery({ + queryKey: pgGraphqlKeys.schemaComment(projectRef, schema), + queryFn: ({ signal }) => getSchemaComment({ projectRef, connectionString, schema }, signal), + enabled: enabled && projectRef !== undefined && isActive, + ...options, + }) +} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 66bb0b99e61..66f6f7761aa 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -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',