get-document-structure.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
  2. import { VariablesOf } from 'gql.tada';
  3. import {
  4. DocumentNode,
  5. FieldNode,
  6. FragmentDefinitionNode,
  7. FragmentSpreadNode,
  8. OperationDefinitionNode,
  9. } from 'graphql';
  10. import { DefinitionNode, NamedTypeNode, SelectionSetNode, TypeNode } from 'graphql/language/ast.js';
  11. import { schemaInfo } from 'virtual:admin-api-schema';
  12. // for debug purposes
  13. (window as any).schemaInfo = schemaInfo;
  14. export interface FieldInfo {
  15. name: string;
  16. type: string;
  17. nullable: boolean;
  18. list: boolean;
  19. isPaginatedList: boolean;
  20. isScalar: boolean;
  21. typeInfo?: FieldInfo[];
  22. }
  23. /**
  24. * @description
  25. * Given a DocumentNode of a PaginatedList query, returns information about each
  26. * of the selected fields.
  27. *
  28. * Inside React components, use the `useListQueryFields` hook to get this information.
  29. */
  30. export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
  31. const fields: FieldInfo[] = [];
  32. const fragments: Record<string, FragmentDefinitionNode> = {};
  33. // Collect all fragment definitions
  34. documentNode.definitions.forEach(def => {
  35. if (def.kind === 'FragmentDefinition') {
  36. fragments[def.name.value] = def;
  37. }
  38. });
  39. const operationDefinition = documentNode.definitions.find(
  40. (def): def is OperationDefinitionNode =>
  41. def.kind === 'OperationDefinition' && def.operation === 'query',
  42. );
  43. for (const query of operationDefinition?.selectionSet.selections ?? []) {
  44. if (query.kind === 'Field') {
  45. const queryField = query;
  46. const fieldInfo = getQueryInfo(queryField.name.value);
  47. if (fieldInfo.isPaginatedList) {
  48. processPaginatedList(queryField, fieldInfo, fields, fragments);
  49. } else if (queryField.selectionSet) {
  50. // Check for nested paginated lists
  51. findNestedPaginatedLists(queryField, fieldInfo.type, fields, fragments);
  52. }
  53. }
  54. }
  55. return fields;
  56. }
  57. function processPaginatedList(
  58. field: FieldNode,
  59. fieldInfo: FieldInfo,
  60. fields: FieldInfo[],
  61. fragments: Record<string, FragmentDefinitionNode>,
  62. ) {
  63. const itemsField = field.selectionSet?.selections.find(
  64. selection => selection.kind === 'Field' && selection.name.value === 'items',
  65. ) as FieldNode;
  66. if (!itemsField) {
  67. return;
  68. }
  69. const typeFields = schemaInfo.types[fieldInfo.type];
  70. const isPaginatedList = typeFields.hasOwnProperty('items') && typeFields.hasOwnProperty('totalItems');
  71. if (!isPaginatedList) {
  72. throw new Error(`Could not determine type of items in ${fieldInfo.name}`);
  73. }
  74. const itemsType = getObjectFieldInfo(fieldInfo.type, 'items')?.type;
  75. if (!itemsType) {
  76. throw new Error(`Could not determine type of items in ${fieldInfo.name}`);
  77. }
  78. for (const item of itemsField.selectionSet?.selections ?? []) {
  79. if (item.kind === 'Field' || item.kind === 'FragmentSpread') {
  80. collectFields(itemsType, item, fields, fragments);
  81. }
  82. }
  83. }
  84. function findNestedPaginatedLists(
  85. field: FieldNode,
  86. parentType: string,
  87. fields: FieldInfo[],
  88. fragments: Record<string, FragmentDefinitionNode>,
  89. ) {
  90. for (const selection of field.selectionSet?.selections ?? []) {
  91. if (selection.kind === 'Field') {
  92. const fieldInfo = getObjectFieldInfo(parentType, selection.name.value);
  93. if (!fieldInfo) {
  94. continue;
  95. }
  96. if (fieldInfo.isPaginatedList) {
  97. processPaginatedList(selection, fieldInfo, fields, fragments);
  98. } else if (selection.selectionSet && !fieldInfo.isScalar) {
  99. // Continue recursion
  100. findNestedPaginatedLists(selection, fieldInfo.type, fields, fragments);
  101. }
  102. } else if (selection.kind === 'FragmentSpread') {
  103. // Handle fragment spread on the parent type
  104. const fragmentName = selection.name.value;
  105. const fragment = fragments[fragmentName];
  106. if (fragment && fragment.typeCondition.name.value === parentType) {
  107. for (const fragmentSelection of fragment.selectionSet.selections) {
  108. if (fragmentSelection.kind === 'Field') {
  109. const fieldInfo = getObjectFieldInfo(parentType, fragmentSelection.name.value);
  110. if (!fieldInfo) {
  111. continue;
  112. }
  113. if (fieldInfo.isPaginatedList) {
  114. processPaginatedList(fragmentSelection, fieldInfo, fields, fragments);
  115. } else if (fragmentSelection.selectionSet && !fieldInfo.isScalar) {
  116. findNestedPaginatedLists(fragmentSelection, fieldInfo.type, fields, fragments);
  117. }
  118. }
  119. }
  120. }
  121. }
  122. }
  123. }
  124. /**
  125. * @description
  126. * This function is used to get the fields of the operation variables from a DocumentNode.
  127. *
  128. * For example, in the following query:
  129. *
  130. * ```graphql
  131. * mutation UpdateProduct($input: UpdateProductInput!) {
  132. * updateProduct(input: $input) {
  133. * ...ProductDetail
  134. * }
  135. * }
  136. * ```
  137. *
  138. * The operation variables fields are the fields of the `UpdateProductInput` type.
  139. */
  140. export function getOperationVariablesFields<T extends TypedDocumentNode<any, any>>(
  141. documentNode: T,
  142. varName?: keyof VariablesOf<T>,
  143. ): FieldInfo[] {
  144. const fields: FieldInfo[] = [];
  145. const operationDefinition = documentNode.definitions.find(
  146. (def): def is OperationDefinitionNode => def.kind === 'OperationDefinition',
  147. );
  148. if (operationDefinition?.variableDefinitions) {
  149. const variableDefinitions = varName
  150. ? operationDefinition.variableDefinitions.filter(
  151. variable => variable.variable.name.value === varName,
  152. )
  153. : operationDefinition.variableDefinitions;
  154. variableDefinitions.forEach(variableDef => {
  155. const unwrappedType = unwrapVariableDefinitionType(variableDef.type);
  156. const isScalar = isScalarType(unwrappedType.name.value);
  157. const fieldName = variableDef.variable.name.value;
  158. const typeName = unwrappedType.name.value;
  159. const inputTypeInfo = isScalar
  160. ? {
  161. name: fieldName,
  162. type: typeName,
  163. nullable: false,
  164. list: false,
  165. isScalar: true,
  166. isPaginatedList: false,
  167. }
  168. : getInputTypeInfo(fieldName, typeName);
  169. if (varName && inputTypeInfo?.name === varName) {
  170. fields.push(...(inputTypeInfo.typeInfo ?? []));
  171. } else {
  172. fields.push(inputTypeInfo);
  173. }
  174. });
  175. }
  176. return fields;
  177. }
  178. function unwrapVariableDefinitionType(type: TypeNode): NamedTypeNode {
  179. if (type.kind === 'NonNullType') {
  180. return unwrapVariableDefinitionType(type.type);
  181. }
  182. if (type.kind === 'ListType') {
  183. return unwrapVariableDefinitionType(type.type);
  184. }
  185. return type;
  186. }
  187. /**
  188. * @description
  189. * This function is used to get the name of the query from a DocumentNode.
  190. *
  191. * For example, in the following query:
  192. *
  193. * ```graphql
  194. * query ProductDetail($id: ID!) {
  195. * product(id: $id) {
  196. * ...ProductDetail
  197. * }
  198. * }
  199. * ```
  200. *
  201. * The query name is `product`.
  202. */
  203. export function getQueryName(documentNode: DocumentNode): string {
  204. const operationDefinition = documentNode.definitions.find(
  205. (def): def is OperationDefinitionNode =>
  206. def.kind === 'OperationDefinition' && def.operation === 'query',
  207. );
  208. const firstSelection = operationDefinition?.selectionSet.selections[0];
  209. if (firstSelection?.kind === 'Field') {
  210. return firstSelection.name.value;
  211. } else {
  212. throw new Error('Could not determine query name');
  213. }
  214. }
  215. /**
  216. * @description
  217. * This function is used to get the type information of the query from a DocumentNode.
  218. *
  219. * For example, in the following query:
  220. *
  221. * ```graphql
  222. * query ProductDetail($id: ID!) {
  223. * product(id: $id) {
  224. * ...ProductDetail
  225. * }
  226. * }
  227. * ```
  228. *
  229. * The query type field will be the `Product` type.
  230. */
  231. export function getQueryTypeFieldInfo(documentNode: DocumentNode): FieldInfo {
  232. const name = getQueryName(documentNode);
  233. return getQueryInfo(name);
  234. }
  235. /**
  236. * @description
  237. * This function is used to get the path to the paginated list from a DocumentNode.
  238. *
  239. * For example, in the following query:
  240. *
  241. * ```graphql
  242. * query GetProductList($options: ProductListOptions) {
  243. * products(options: $options) {
  244. * items {
  245. * ...ProductDetail
  246. * }
  247. * totalCount
  248. * }
  249. * }
  250. * ```
  251. *
  252. * The path to the paginated list is `['products']`.
  253. */
  254. export function getObjectPathToPaginatedList(
  255. documentNode: DocumentNode,
  256. currentPath: string[] = [],
  257. ): string[] {
  258. // get the query OperationDefinition
  259. const operationDefinition = documentNode.definitions.find(
  260. (def): def is OperationDefinitionNode =>
  261. def.kind === 'OperationDefinition' && def.operation === 'query',
  262. );
  263. if (!operationDefinition) {
  264. throw new Error('Could not find query operation definition');
  265. }
  266. return findPaginatedListPath(operationDefinition.selectionSet, 'Query', currentPath);
  267. }
  268. function findPaginatedListPath(
  269. selectionSet: SelectionSetNode,
  270. parentType: string,
  271. currentPath: string[] = [],
  272. ): string[] {
  273. for (const selection of selectionSet.selections) {
  274. if (selection.kind === 'Field') {
  275. const fieldNode = selection;
  276. const fieldInfo = getObjectFieldInfo(parentType, fieldNode.name.value);
  277. if (!fieldInfo) {
  278. continue;
  279. }
  280. const newPath = [...currentPath, fieldNode.name.value];
  281. if (fieldInfo.isPaginatedList) {
  282. return newPath;
  283. }
  284. // If this field has a selection set, recursively search it
  285. if (fieldNode.selectionSet && !fieldInfo.isScalar) {
  286. const result = findPaginatedListPath(fieldNode.selectionSet, fieldInfo.type, newPath);
  287. if (result.length > 0) {
  288. return result;
  289. }
  290. }
  291. }
  292. }
  293. return [];
  294. }
  295. /**
  296. * @description
  297. * This function is used to get the name of the mutation from a DocumentNode.
  298. *
  299. * For example, in the following mutation:
  300. *
  301. * ```graphql
  302. * mutation CreateProduct($input: CreateProductInput!) {
  303. * createProduct(input: $input) {
  304. * ...ProductDetail
  305. * }
  306. * }
  307. * ```
  308. *
  309. * The mutation name is `createProduct`.
  310. */
  311. export function getMutationName(documentNode: DocumentNode): string {
  312. const operationDefinition = documentNode.definitions.find(
  313. (def): def is OperationDefinitionNode =>
  314. def.kind === 'OperationDefinition' && def.operation === 'mutation',
  315. );
  316. const firstSelection = operationDefinition?.selectionSet.selections[0];
  317. if (firstSelection?.kind === 'Field') {
  318. return firstSelection.name.value;
  319. } else {
  320. throw new Error('Could not determine mutation name');
  321. }
  322. }
  323. /**
  324. * @description
  325. * This function is used to get the type information of an operation from a DocumentNode.
  326. */
  327. export function getOperationTypeInfo(
  328. definitionNode: DefinitionNode | FieldNode,
  329. parentTypeName?: string,
  330. ): FieldInfo | undefined {
  331. if (definitionNode.kind === 'OperationDefinition') {
  332. const firstSelection = definitionNode?.selectionSet.selections[0];
  333. if (firstSelection?.kind === 'Field') {
  334. return definitionNode.operation === 'query'
  335. ? getQueryInfo(firstSelection.name.value)
  336. : getMutationInfo(firstSelection.name.value);
  337. }
  338. }
  339. if (definitionNode.kind === 'Field' && parentTypeName) {
  340. const fieldInfo = getObjectFieldInfo(parentTypeName, definitionNode.name.value);
  341. return fieldInfo;
  342. }
  343. }
  344. export function getTypeFieldInfo(typeName: string): FieldInfo[] {
  345. return Object.entries(schemaInfo.types[typeName])
  346. .map(([fieldName]) => {
  347. const fieldInfo = getObjectFieldInfo(typeName, fieldName);
  348. if (!fieldInfo) {
  349. return;
  350. }
  351. return fieldInfo;
  352. })
  353. .filter(x => x != null);
  354. }
  355. function getQueryInfo(name: string): FieldInfo {
  356. const fieldInfo = schemaInfo.types.Query[name];
  357. return {
  358. name,
  359. type: fieldInfo[0],
  360. nullable: fieldInfo[1],
  361. list: fieldInfo[2],
  362. isPaginatedList: fieldInfo[3],
  363. isScalar: schemaInfo.scalars.includes(fieldInfo[0]),
  364. };
  365. }
  366. function getMutationInfo(name: string): FieldInfo {
  367. const fieldInfo = schemaInfo.types.Mutation[name];
  368. return {
  369. name,
  370. type: fieldInfo[0],
  371. nullable: fieldInfo[1],
  372. list: fieldInfo[2],
  373. isPaginatedList: fieldInfo[3],
  374. isScalar: schemaInfo.scalars.includes(fieldInfo[0]),
  375. };
  376. }
  377. function getInputTypeInfo(name: string, type: string): FieldInfo {
  378. const fieldInfo = schemaInfo.inputs[type];
  379. if (!fieldInfo) {
  380. throw new Error(`Input type ${type} not found`);
  381. }
  382. return {
  383. name,
  384. type,
  385. nullable: true,
  386. list: false,
  387. isPaginatedList: false,
  388. isScalar: false,
  389. typeInfo: getInputTypeFields(type),
  390. };
  391. }
  392. function getInputTypeFields(name: string): FieldInfo[] {
  393. const inputType = schemaInfo.inputs[name];
  394. if (!inputType) {
  395. throw new Error(`Input type ${name} not found`);
  396. }
  397. return Object.entries(inputType).map(([fieldName, fieldInfo]: [string, any]) => {
  398. const type = fieldInfo[0];
  399. const isScalar = isScalarType(type);
  400. const isEnum = isEnumType(type);
  401. return {
  402. name: fieldName,
  403. type,
  404. nullable: fieldInfo[1],
  405. list: fieldInfo[2],
  406. isPaginatedList: fieldInfo[3],
  407. isScalar,
  408. typeInfo: !isScalar && !isEnum ? getInputTypeFields(type) : undefined,
  409. };
  410. });
  411. }
  412. export function isScalarType(type: string): boolean {
  413. return schemaInfo.scalars.includes(type);
  414. }
  415. export function isEnumType(type: string): boolean {
  416. return schemaInfo.enums[type] != null;
  417. }
  418. function getObjectFieldInfo(typeName: string, fieldName: string): FieldInfo | undefined {
  419. const fieldInfo = schemaInfo.types[typeName]?.[fieldName];
  420. if (!fieldInfo) {
  421. return undefined;
  422. }
  423. const type = fieldInfo[0];
  424. const isScalar = isScalarType(type);
  425. return {
  426. name: fieldName,
  427. type: fieldInfo[0],
  428. nullable: fieldInfo[1],
  429. list: fieldInfo[2],
  430. isPaginatedList: fieldInfo[3],
  431. isScalar,
  432. };
  433. }
  434. function collectFields(
  435. typeName: string,
  436. fieldNode: FieldNode | FragmentSpreadNode,
  437. fields: FieldInfo[],
  438. fragments: Record<string, FragmentDefinitionNode>,
  439. ) {
  440. if (fieldNode.kind === 'Field') {
  441. const fieldInfo = getObjectFieldInfo(typeName, fieldNode.name.value);
  442. if (!fieldInfo) {
  443. return;
  444. }
  445. fields.push(fieldInfo);
  446. if (fieldNode.selectionSet) {
  447. fieldNode.selectionSet.selections.forEach(subSelection => {
  448. if (subSelection.kind === 'Field') {
  449. collectFields(fieldInfo.type, subSelection, [], fragments);
  450. } else if (subSelection.kind === 'FragmentSpread') {
  451. const fragmentName = subSelection.name.value;
  452. const fragment = fragments[fragmentName];
  453. if (!fragment) {
  454. throw new Error(
  455. `Fragment "${fragmentName}" not found. Make sure to include it in the "${typeName}" type query.`,
  456. );
  457. }
  458. // We only want to collect fields from the fragment if it's the same type as
  459. // the field we're collecting from
  460. if (fragment.name.value !== typeName) {
  461. return;
  462. }
  463. if (fragment) {
  464. fragment.selectionSet.selections.forEach(fragmentSelection => {
  465. if (fragmentSelection.kind === 'Field') {
  466. collectFields(typeName, fragmentSelection, fields, fragments);
  467. }
  468. });
  469. }
  470. }
  471. });
  472. }
  473. }
  474. if (fieldNode.kind === 'FragmentSpread') {
  475. const fragmentName = fieldNode.name.value;
  476. const fragment = fragments[fragmentName];
  477. if (fragment) {
  478. fragment.selectionSet.selections.forEach(fragmentSelection => {
  479. if (fragmentSelection.kind === 'Field') {
  480. collectFields(typeName, fragmentSelection, fields, fragments);
  481. }
  482. });
  483. }
  484. }
  485. }