pgGraphqlSchemaComment.ts183 lines · main
1/**
2 * Helpers for parsing and updating the pg_graphql configuration directive that
3 * lives inside a Postgres schema comment.
4 *
5 * pg_graphql reads its per-schema configuration from a directive of the form:
6 *
7 * @graphql({"introspection": true, "inflect_names": true})
8 *
9 * embedded anywhere in the schema comment. There is at most one such directive
10 * per schema; if the user has set arbitrary other comment text alongside it,
11 * we preserve that text when rewriting the directive.
12 */
13
14export type GraphqlOptions = Record<string, unknown>
15
16export type ParsedSchemaComment = {
17 /** Options parsed from the directive. Empty object when no directive exists. */
18 options: GraphqlOptions
19 /** True if a recognizable `@graphql(...)` directive was found. */
20 hasDirective: boolean
21 /** True if a directive was found but its JSON body could not be parsed. */
22 isMalformed: boolean
23 /** Text before the directive (empty when there is none). */
24 prefix: string
25 /** Text after the directive (empty when there is none). */
26 suffix: string
27}
28
29type DirectiveLocation = {
30 /** Index of the `@` that starts the directive. */
31 start: number
32 /** Index after the matching `)`. */
33 end: number
34 /** Index of the opening `{` of the JSON body. */
35 jsonStart: number
36 /** Index after the matching `}` of the JSON body. */
37 jsonEnd: number
38}
39
40/**
41 * Locate a single `@graphql(...)` directive in the given text. Returns null if
42 * no syntactically well-formed directive is found.
43 *
44 * The matcher walks JSON strings character-by-character so that braces inside
45 * string values (e.g. `"label": "}{"`) don't confuse the balance counter.
46 */
47const findDirective = (text: string): DirectiveLocation | null => {
48 const directiveMatch = /@graphql\s*\(/.exec(text)
49 if (!directiveMatch) return null
50
51 const start = directiveMatch.index
52 let i = start + directiveMatch[0].length
53
54 // Skip whitespace between `(` and the opening `{`.
55 while (i < text.length && /\s/.test(text[i])) i++
56 if (text[i] !== '{') return null
57
58 const jsonStart = i
59 let depth = 0
60 let inString = false
61 let escape = false
62
63 for (; i < text.length; i++) {
64 const c = text[i]
65 if (escape) {
66 escape = false
67 continue
68 }
69 if (inString) {
70 if (c === '\\') escape = true
71 else if (c === '"') inString = false
72 continue
73 }
74 if (c === '"') {
75 inString = true
76 } else if (c === '{') {
77 depth++
78 } else if (c === '}') {
79 depth--
80 if (depth === 0) {
81 const jsonEnd = i + 1
82 // Skip whitespace between `}` and the closing `)`.
83 let j = jsonEnd
84 while (j < text.length && /\s/.test(text[j])) j++
85 if (text[j] !== ')') return null
86 return { start, end: j + 1, jsonStart, jsonEnd }
87 }
88 }
89 }
90 return null
91}
92
93export const parseSchemaComment = (comment: string | null | undefined): ParsedSchemaComment => {
94 const text = comment ?? ''
95 const location = findDirective(text)
96
97 if (!location) {
98 return {
99 options: {},
100 hasDirective: false,
101 isMalformed: false,
102 prefix: text,
103 suffix: '',
104 }
105 }
106
107 const json = text.slice(location.jsonStart, location.jsonEnd)
108 let options: GraphqlOptions = {}
109 let isMalformed = false
110 try {
111 const parsed = JSON.parse(json)
112 if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
113 options = parsed as GraphqlOptions
114 } else {
115 isMalformed = true
116 }
117 } catch {
118 isMalformed = true
119 }
120
121 return {
122 options,
123 hasDirective: true,
124 isMalformed,
125 prefix: text.slice(0, location.start),
126 suffix: text.slice(location.end),
127 }
128}
129
130/**
131 * Produce an updated schema comment string with `overrides` merged into the
132 * directive's options. Any surrounding comment text is preserved. When the
133 * existing directive is malformed, its options are discarded and replaced by
134 * `overrides` alone.
135 */
136export const buildSchemaCommentWith = (
137 comment: string | null | undefined,
138 overrides: GraphqlOptions
139): string => {
140 const parsed = parseSchemaComment(comment)
141 const baseOptions = parsed.isMalformed ? {} : parsed.options
142 const merged: GraphqlOptions = { ...baseOptions, ...overrides }
143 const directive = `@graphql(${JSON.stringify(merged)})`
144
145 if (!parsed.hasDirective) {
146 // Preserve any prior text; insert the directive at the end with a single
147 // space separator if the prior text is non-empty.
148 const existing = parsed.prefix
149 if (existing.length === 0) return directive
150 return existing.endsWith(' ') ? `${existing}${directive}` : `${existing} ${directive}`
151 }
152
153 return `${parsed.prefix}${directive}${parsed.suffix}`
154}
155
156/**
157 * Returns true when the parsed options explicitly set `introspection: true`.
158 * Every other value (including missing, `false`, or non-boolean) is treated as
159 * "introspection not enabled" so callers can show the opt-in notice.
160 */
161export const isIntrospectionEnabled = (options: GraphqlOptions): boolean => {
162 return options.introspection === true
163}
164
165/**
166 * Returns true when the installed pg_graphql version is >= 1.6.0, which is the
167 * first version that disables introspection by default.
168 *
169 * Accepts standard `MAJOR.MINOR.PATCH` strings; pre-release / build suffixes
170 * are ignored. Returns false on unparseable input so older / unknown
171 * installations fall back to the legacy "introspection on by default" behavior.
172 */
173export const isPgGraphqlIntrospectionOptIn = (version: string | null | undefined): boolean => {
174 if (!version) return false
175 const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
176 if (!match) return false
177 const major = Number(match[1])
178 const minor = Number(match[2])
179 if (Number.isNaN(major) || Number.isNaN(minor)) return false
180 if (major > 1) return true
181 if (major < 1) return false
182 return minor >= 6
183}