pgGraphqlSchemaComment.ts 5.7 KB

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