Files
supabase/apps/studio/components/interfaces/Integrations/GraphQL/pgGraphqlSchemaComment.ts
Charis 72cebe3976 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 -->
2026-05-21 07:07:35 -04:00

184 lines
5.7 KiB
TypeScript

/**
* 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
}