mirror of
https://github.com/supabase/supabase.git
synced 2026-06-28 19:39:19 -04:00
72cebe3976
## 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 -->
[](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 -->
314 lines
9.3 KiB
TypeScript
314 lines
9.3 KiB
TypeScript
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)
|
|
}
|
|
)
|
|
})
|