findBreakingChanges.js.flow 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. // @flow strict
  2. import objectValues from '../polyfills/objectValues';
  3. import keyMap from '../jsutils/keyMap';
  4. import inspect from '../jsutils/inspect';
  5. import invariant from '../jsutils/invariant';
  6. import naturalCompare from '../jsutils/naturalCompare';
  7. import { print } from '../language/printer';
  8. import { visit } from '../language/visitor';
  9. import type { GraphQLSchema } from '../type/schema';
  10. import type {
  11. GraphQLField,
  12. GraphQLType,
  13. GraphQLInputType,
  14. GraphQLNamedType,
  15. GraphQLEnumType,
  16. GraphQLUnionType,
  17. GraphQLObjectType,
  18. GraphQLInterfaceType,
  19. GraphQLInputObjectType,
  20. } from '../type/definition';
  21. import { isSpecifiedScalarType } from '../type/scalars';
  22. import {
  23. isScalarType,
  24. isObjectType,
  25. isInterfaceType,
  26. isUnionType,
  27. isEnumType,
  28. isInputObjectType,
  29. isNonNullType,
  30. isListType,
  31. isNamedType,
  32. isRequiredArgument,
  33. isRequiredInputField,
  34. } from '../type/definition';
  35. import { astFromValue } from './astFromValue';
  36. export const BreakingChangeType = Object.freeze({
  37. TYPE_REMOVED: 'TYPE_REMOVED',
  38. TYPE_CHANGED_KIND: 'TYPE_CHANGED_KIND',
  39. TYPE_REMOVED_FROM_UNION: 'TYPE_REMOVED_FROM_UNION',
  40. VALUE_REMOVED_FROM_ENUM: 'VALUE_REMOVED_FROM_ENUM',
  41. REQUIRED_INPUT_FIELD_ADDED: 'REQUIRED_INPUT_FIELD_ADDED',
  42. IMPLEMENTED_INTERFACE_REMOVED: 'IMPLEMENTED_INTERFACE_REMOVED',
  43. FIELD_REMOVED: 'FIELD_REMOVED',
  44. FIELD_CHANGED_KIND: 'FIELD_CHANGED_KIND',
  45. REQUIRED_ARG_ADDED: 'REQUIRED_ARG_ADDED',
  46. ARG_REMOVED: 'ARG_REMOVED',
  47. ARG_CHANGED_KIND: 'ARG_CHANGED_KIND',
  48. DIRECTIVE_REMOVED: 'DIRECTIVE_REMOVED',
  49. DIRECTIVE_ARG_REMOVED: 'DIRECTIVE_ARG_REMOVED',
  50. REQUIRED_DIRECTIVE_ARG_ADDED: 'REQUIRED_DIRECTIVE_ARG_ADDED',
  51. DIRECTIVE_REPEATABLE_REMOVED: 'DIRECTIVE_REPEATABLE_REMOVED',
  52. DIRECTIVE_LOCATION_REMOVED: 'DIRECTIVE_LOCATION_REMOVED',
  53. });
  54. export const DangerousChangeType = Object.freeze({
  55. VALUE_ADDED_TO_ENUM: 'VALUE_ADDED_TO_ENUM',
  56. TYPE_ADDED_TO_UNION: 'TYPE_ADDED_TO_UNION',
  57. OPTIONAL_INPUT_FIELD_ADDED: 'OPTIONAL_INPUT_FIELD_ADDED',
  58. OPTIONAL_ARG_ADDED: 'OPTIONAL_ARG_ADDED',
  59. IMPLEMENTED_INTERFACE_ADDED: 'IMPLEMENTED_INTERFACE_ADDED',
  60. ARG_DEFAULT_VALUE_CHANGE: 'ARG_DEFAULT_VALUE_CHANGE',
  61. });
  62. export type BreakingChange = {|
  63. type: $Keys<typeof BreakingChangeType>,
  64. description: string,
  65. |};
  66. export type DangerousChange = {|
  67. type: $Keys<typeof DangerousChangeType>,
  68. description: string,
  69. |};
  70. /**
  71. * Given two schemas, returns an Array containing descriptions of all the types
  72. * of breaking changes covered by the other functions down below.
  73. */
  74. export function findBreakingChanges(
  75. oldSchema: GraphQLSchema,
  76. newSchema: GraphQLSchema,
  77. ): Array<BreakingChange> {
  78. const breakingChanges = findSchemaChanges(oldSchema, newSchema).filter(
  79. (change) => change.type in BreakingChangeType,
  80. );
  81. return ((breakingChanges: any): Array<BreakingChange>);
  82. }
  83. /**
  84. * Given two schemas, returns an Array containing descriptions of all the types
  85. * of potentially dangerous changes covered by the other functions down below.
  86. */
  87. export function findDangerousChanges(
  88. oldSchema: GraphQLSchema,
  89. newSchema: GraphQLSchema,
  90. ): Array<DangerousChange> {
  91. const dangerousChanges = findSchemaChanges(oldSchema, newSchema).filter(
  92. (change) => change.type in DangerousChangeType,
  93. );
  94. return ((dangerousChanges: any): Array<DangerousChange>);
  95. }
  96. function findSchemaChanges(
  97. oldSchema: GraphQLSchema,
  98. newSchema: GraphQLSchema,
  99. ): Array<BreakingChange | DangerousChange> {
  100. return [
  101. ...findTypeChanges(oldSchema, newSchema),
  102. ...findDirectiveChanges(oldSchema, newSchema),
  103. ];
  104. }
  105. function findDirectiveChanges(
  106. oldSchema: GraphQLSchema,
  107. newSchema: GraphQLSchema,
  108. ): Array<BreakingChange | DangerousChange> {
  109. const schemaChanges = [];
  110. const directivesDiff = diff(
  111. oldSchema.getDirectives(),
  112. newSchema.getDirectives(),
  113. );
  114. for (const oldDirective of directivesDiff.removed) {
  115. schemaChanges.push({
  116. type: BreakingChangeType.DIRECTIVE_REMOVED,
  117. description: `${oldDirective.name} was removed.`,
  118. });
  119. }
  120. for (const [oldDirective, newDirective] of directivesDiff.persisted) {
  121. const argsDiff = diff(oldDirective.args, newDirective.args);
  122. for (const newArg of argsDiff.added) {
  123. if (isRequiredArgument(newArg)) {
  124. schemaChanges.push({
  125. type: BreakingChangeType.REQUIRED_DIRECTIVE_ARG_ADDED,
  126. description: `A required arg ${newArg.name} on directive ${oldDirective.name} was added.`,
  127. });
  128. }
  129. }
  130. for (const oldArg of argsDiff.removed) {
  131. schemaChanges.push({
  132. type: BreakingChangeType.DIRECTIVE_ARG_REMOVED,
  133. description: `${oldArg.name} was removed from ${oldDirective.name}.`,
  134. });
  135. }
  136. if (oldDirective.isRepeatable && !newDirective.isRepeatable) {
  137. schemaChanges.push({
  138. type: BreakingChangeType.DIRECTIVE_REPEATABLE_REMOVED,
  139. description: `Repeatable flag was removed from ${oldDirective.name}.`,
  140. });
  141. }
  142. for (const location of oldDirective.locations) {
  143. if (newDirective.locations.indexOf(location) === -1) {
  144. schemaChanges.push({
  145. type: BreakingChangeType.DIRECTIVE_LOCATION_REMOVED,
  146. description: `${location} was removed from ${oldDirective.name}.`,
  147. });
  148. }
  149. }
  150. }
  151. return schemaChanges;
  152. }
  153. function findTypeChanges(
  154. oldSchema: GraphQLSchema,
  155. newSchema: GraphQLSchema,
  156. ): Array<BreakingChange | DangerousChange> {
  157. const schemaChanges = [];
  158. const typesDiff = diff(
  159. objectValues(oldSchema.getTypeMap()),
  160. objectValues(newSchema.getTypeMap()),
  161. );
  162. for (const oldType of typesDiff.removed) {
  163. schemaChanges.push({
  164. type: BreakingChangeType.TYPE_REMOVED,
  165. description: isSpecifiedScalarType(oldType)
  166. ? `Standard scalar ${oldType.name} was removed because it is not referenced anymore.`
  167. : `${oldType.name} was removed.`,
  168. });
  169. }
  170. for (const [oldType, newType] of typesDiff.persisted) {
  171. if (isEnumType(oldType) && isEnumType(newType)) {
  172. schemaChanges.push(...findEnumTypeChanges(oldType, newType));
  173. } else if (isUnionType(oldType) && isUnionType(newType)) {
  174. schemaChanges.push(...findUnionTypeChanges(oldType, newType));
  175. } else if (isInputObjectType(oldType) && isInputObjectType(newType)) {
  176. schemaChanges.push(...findInputObjectTypeChanges(oldType, newType));
  177. } else if (isObjectType(oldType) && isObjectType(newType)) {
  178. schemaChanges.push(
  179. ...findFieldChanges(oldType, newType),
  180. ...findImplementedInterfacesChanges(oldType, newType),
  181. );
  182. } else if (isInterfaceType(oldType) && isInterfaceType(newType)) {
  183. schemaChanges.push(
  184. ...findFieldChanges(oldType, newType),
  185. ...findImplementedInterfacesChanges(oldType, newType),
  186. );
  187. } else if (oldType.constructor !== newType.constructor) {
  188. schemaChanges.push({
  189. type: BreakingChangeType.TYPE_CHANGED_KIND,
  190. description:
  191. `${oldType.name} changed from ` +
  192. `${typeKindName(oldType)} to ${typeKindName(newType)}.`,
  193. });
  194. }
  195. }
  196. return schemaChanges;
  197. }
  198. function findInputObjectTypeChanges(
  199. oldType: GraphQLInputObjectType,
  200. newType: GraphQLInputObjectType,
  201. ): Array<BreakingChange | DangerousChange> {
  202. const schemaChanges = [];
  203. const fieldsDiff = diff(
  204. objectValues(oldType.getFields()),
  205. objectValues(newType.getFields()),
  206. );
  207. for (const newField of fieldsDiff.added) {
  208. if (isRequiredInputField(newField)) {
  209. schemaChanges.push({
  210. type: BreakingChangeType.REQUIRED_INPUT_FIELD_ADDED,
  211. description: `A required field ${newField.name} on input type ${oldType.name} was added.`,
  212. });
  213. } else {
  214. schemaChanges.push({
  215. type: DangerousChangeType.OPTIONAL_INPUT_FIELD_ADDED,
  216. description: `An optional field ${newField.name} on input type ${oldType.name} was added.`,
  217. });
  218. }
  219. }
  220. for (const oldField of fieldsDiff.removed) {
  221. schemaChanges.push({
  222. type: BreakingChangeType.FIELD_REMOVED,
  223. description: `${oldType.name}.${oldField.name} was removed.`,
  224. });
  225. }
  226. for (const [oldField, newField] of fieldsDiff.persisted) {
  227. const isSafe = isChangeSafeForInputObjectFieldOrFieldArg(
  228. oldField.type,
  229. newField.type,
  230. );
  231. if (!isSafe) {
  232. schemaChanges.push({
  233. type: BreakingChangeType.FIELD_CHANGED_KIND,
  234. description:
  235. `${oldType.name}.${oldField.name} changed type from ` +
  236. `${String(oldField.type)} to ${String(newField.type)}.`,
  237. });
  238. }
  239. }
  240. return schemaChanges;
  241. }
  242. function findUnionTypeChanges(
  243. oldType: GraphQLUnionType,
  244. newType: GraphQLUnionType,
  245. ): Array<BreakingChange | DangerousChange> {
  246. const schemaChanges = [];
  247. const possibleTypesDiff = diff(oldType.getTypes(), newType.getTypes());
  248. for (const newPossibleType of possibleTypesDiff.added) {
  249. schemaChanges.push({
  250. type: DangerousChangeType.TYPE_ADDED_TO_UNION,
  251. description: `${newPossibleType.name} was added to union type ${oldType.name}.`,
  252. });
  253. }
  254. for (const oldPossibleType of possibleTypesDiff.removed) {
  255. schemaChanges.push({
  256. type: BreakingChangeType.TYPE_REMOVED_FROM_UNION,
  257. description: `${oldPossibleType.name} was removed from union type ${oldType.name}.`,
  258. });
  259. }
  260. return schemaChanges;
  261. }
  262. function findEnumTypeChanges(
  263. oldType: GraphQLEnumType,
  264. newType: GraphQLEnumType,
  265. ): Array<BreakingChange | DangerousChange> {
  266. const schemaChanges = [];
  267. const valuesDiff = diff(oldType.getValues(), newType.getValues());
  268. for (const newValue of valuesDiff.added) {
  269. schemaChanges.push({
  270. type: DangerousChangeType.VALUE_ADDED_TO_ENUM,
  271. description: `${newValue.name} was added to enum type ${oldType.name}.`,
  272. });
  273. }
  274. for (const oldValue of valuesDiff.removed) {
  275. schemaChanges.push({
  276. type: BreakingChangeType.VALUE_REMOVED_FROM_ENUM,
  277. description: `${oldValue.name} was removed from enum type ${oldType.name}.`,
  278. });
  279. }
  280. return schemaChanges;
  281. }
  282. function findImplementedInterfacesChanges(
  283. oldType: GraphQLObjectType | GraphQLInterfaceType,
  284. newType: GraphQLObjectType | GraphQLInterfaceType,
  285. ): Array<BreakingChange | DangerousChange> {
  286. const schemaChanges = [];
  287. const interfacesDiff = diff(oldType.getInterfaces(), newType.getInterfaces());
  288. for (const newInterface of interfacesDiff.added) {
  289. schemaChanges.push({
  290. type: DangerousChangeType.IMPLEMENTED_INTERFACE_ADDED,
  291. description: `${newInterface.name} added to interfaces implemented by ${oldType.name}.`,
  292. });
  293. }
  294. for (const oldInterface of interfacesDiff.removed) {
  295. schemaChanges.push({
  296. type: BreakingChangeType.IMPLEMENTED_INTERFACE_REMOVED,
  297. description: `${oldType.name} no longer implements interface ${oldInterface.name}.`,
  298. });
  299. }
  300. return schemaChanges;
  301. }
  302. function findFieldChanges(
  303. oldType: GraphQLObjectType | GraphQLInterfaceType,
  304. newType: GraphQLObjectType | GraphQLInterfaceType,
  305. ): Array<BreakingChange | DangerousChange> {
  306. const schemaChanges = [];
  307. const fieldsDiff = diff(
  308. objectValues(oldType.getFields()),
  309. objectValues(newType.getFields()),
  310. );
  311. for (const oldField of fieldsDiff.removed) {
  312. schemaChanges.push({
  313. type: BreakingChangeType.FIELD_REMOVED,
  314. description: `${oldType.name}.${oldField.name} was removed.`,
  315. });
  316. }
  317. for (const [oldField, newField] of fieldsDiff.persisted) {
  318. schemaChanges.push(...findArgChanges(oldType, oldField, newField));
  319. const isSafe = isChangeSafeForObjectOrInterfaceField(
  320. oldField.type,
  321. newField.type,
  322. );
  323. if (!isSafe) {
  324. schemaChanges.push({
  325. type: BreakingChangeType.FIELD_CHANGED_KIND,
  326. description:
  327. `${oldType.name}.${oldField.name} changed type from ` +
  328. `${String(oldField.type)} to ${String(newField.type)}.`,
  329. });
  330. }
  331. }
  332. return schemaChanges;
  333. }
  334. function findArgChanges(
  335. oldType: GraphQLObjectType | GraphQLInterfaceType,
  336. oldField: GraphQLField<mixed, mixed>,
  337. newField: GraphQLField<mixed, mixed>,
  338. ): Array<BreakingChange | DangerousChange> {
  339. const schemaChanges = [];
  340. const argsDiff = diff(oldField.args, newField.args);
  341. for (const oldArg of argsDiff.removed) {
  342. schemaChanges.push({
  343. type: BreakingChangeType.ARG_REMOVED,
  344. description: `${oldType.name}.${oldField.name} arg ${oldArg.name} was removed.`,
  345. });
  346. }
  347. for (const [oldArg, newArg] of argsDiff.persisted) {
  348. const isSafe = isChangeSafeForInputObjectFieldOrFieldArg(
  349. oldArg.type,
  350. newArg.type,
  351. );
  352. if (!isSafe) {
  353. schemaChanges.push({
  354. type: BreakingChangeType.ARG_CHANGED_KIND,
  355. description:
  356. `${oldType.name}.${oldField.name} arg ${oldArg.name} has changed type from ` +
  357. `${String(oldArg.type)} to ${String(newArg.type)}.`,
  358. });
  359. } else if (oldArg.defaultValue !== undefined) {
  360. if (newArg.defaultValue === undefined) {
  361. schemaChanges.push({
  362. type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE,
  363. description: `${oldType.name}.${oldField.name} arg ${oldArg.name} defaultValue was removed.`,
  364. });
  365. } else {
  366. // Since we looking only for client's observable changes we should
  367. // compare default values in the same representation as they are
  368. // represented inside introspection.
  369. const oldValueStr = stringifyValue(oldArg.defaultValue, oldArg.type);
  370. const newValueStr = stringifyValue(newArg.defaultValue, newArg.type);
  371. if (oldValueStr !== newValueStr) {
  372. schemaChanges.push({
  373. type: DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE,
  374. description: `${oldType.name}.${oldField.name} arg ${oldArg.name} has changed defaultValue from ${oldValueStr} to ${newValueStr}.`,
  375. });
  376. }
  377. }
  378. }
  379. }
  380. for (const newArg of argsDiff.added) {
  381. if (isRequiredArgument(newArg)) {
  382. schemaChanges.push({
  383. type: BreakingChangeType.REQUIRED_ARG_ADDED,
  384. description: `A required arg ${newArg.name} on ${oldType.name}.${oldField.name} was added.`,
  385. });
  386. } else {
  387. schemaChanges.push({
  388. type: DangerousChangeType.OPTIONAL_ARG_ADDED,
  389. description: `An optional arg ${newArg.name} on ${oldType.name}.${oldField.name} was added.`,
  390. });
  391. }
  392. }
  393. return schemaChanges;
  394. }
  395. function isChangeSafeForObjectOrInterfaceField(
  396. oldType: GraphQLType,
  397. newType: GraphQLType,
  398. ): boolean {
  399. if (isListType(oldType)) {
  400. return (
  401. // if they're both lists, make sure the underlying types are compatible
  402. (isListType(newType) &&
  403. isChangeSafeForObjectOrInterfaceField(
  404. oldType.ofType,
  405. newType.ofType,
  406. )) ||
  407. // moving from nullable to non-null of the same underlying type is safe
  408. (isNonNullType(newType) &&
  409. isChangeSafeForObjectOrInterfaceField(oldType, newType.ofType))
  410. );
  411. }
  412. if (isNonNullType(oldType)) {
  413. // if they're both non-null, make sure the underlying types are compatible
  414. return (
  415. isNonNullType(newType) &&
  416. isChangeSafeForObjectOrInterfaceField(oldType.ofType, newType.ofType)
  417. );
  418. }
  419. return (
  420. // if they're both named types, see if their names are equivalent
  421. (isNamedType(newType) && oldType.name === newType.name) ||
  422. // moving from nullable to non-null of the same underlying type is safe
  423. (isNonNullType(newType) &&
  424. isChangeSafeForObjectOrInterfaceField(oldType, newType.ofType))
  425. );
  426. }
  427. function isChangeSafeForInputObjectFieldOrFieldArg(
  428. oldType: GraphQLType,
  429. newType: GraphQLType,
  430. ): boolean {
  431. if (isListType(oldType)) {
  432. // if they're both lists, make sure the underlying types are compatible
  433. return (
  434. isListType(newType) &&
  435. isChangeSafeForInputObjectFieldOrFieldArg(oldType.ofType, newType.ofType)
  436. );
  437. }
  438. if (isNonNullType(oldType)) {
  439. return (
  440. // if they're both non-null, make sure the underlying types are
  441. // compatible
  442. (isNonNullType(newType) &&
  443. isChangeSafeForInputObjectFieldOrFieldArg(
  444. oldType.ofType,
  445. newType.ofType,
  446. )) ||
  447. // moving from non-null to nullable of the same underlying type is safe
  448. (!isNonNullType(newType) &&
  449. isChangeSafeForInputObjectFieldOrFieldArg(oldType.ofType, newType))
  450. );
  451. }
  452. // if they're both named types, see if their names are equivalent
  453. return isNamedType(newType) && oldType.name === newType.name;
  454. }
  455. function typeKindName(type: GraphQLNamedType): string {
  456. if (isScalarType(type)) {
  457. return 'a Scalar type';
  458. }
  459. if (isObjectType(type)) {
  460. return 'an Object type';
  461. }
  462. if (isInterfaceType(type)) {
  463. return 'an Interface type';
  464. }
  465. if (isUnionType(type)) {
  466. return 'a Union type';
  467. }
  468. if (isEnumType(type)) {
  469. return 'an Enum type';
  470. }
  471. // istanbul ignore else (See: 'https://github.com/graphql/graphql-js/issues/2618')
  472. if (isInputObjectType(type)) {
  473. return 'an Input type';
  474. }
  475. // istanbul ignore next (Not reachable. All possible named types have been considered)
  476. invariant(false, 'Unexpected type: ' + inspect((type: empty)));
  477. }
  478. function stringifyValue(value: mixed, type: GraphQLInputType): string {
  479. const ast = astFromValue(value, type);
  480. invariant(ast != null);
  481. const sortedAST = visit(ast, {
  482. ObjectValue(objectNode) {
  483. // Make a copy since sort mutates array
  484. const fields = [...objectNode.fields];
  485. fields.sort((fieldA, fieldB) =>
  486. naturalCompare(fieldA.name.value, fieldB.name.value),
  487. );
  488. return { ...objectNode, fields };
  489. },
  490. });
  491. return print(sortedAST);
  492. }
  493. function diff<T: { name: string, ... }>(
  494. oldArray: $ReadOnlyArray<T>,
  495. newArray: $ReadOnlyArray<T>,
  496. ): {|
  497. added: Array<T>,
  498. removed: Array<T>,
  499. persisted: Array<[T, T]>,
  500. |} {
  501. const added = [];
  502. const removed = [];
  503. const persisted = [];
  504. const oldMap = keyMap(oldArray, ({ name }) => name);
  505. const newMap = keyMap(newArray, ({ name }) => name);
  506. for (const oldItem of oldArray) {
  507. const newItem = newMap[oldItem.name];
  508. if (newItem === undefined) {
  509. removed.push(oldItem);
  510. } else {
  511. persisted.push([oldItem, newItem]);
  512. }
  513. }
  514. for (const newItem of newArray) {
  515. if (oldMap[newItem.name] === undefined) {
  516. added.push(newItem);
  517. }
  518. }
  519. return { added, persisted, removed };
  520. }