Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-items-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/graphql-flow": minor
---

Add support for cross-package fragment imports in monorepos
1 change: 1 addition & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ module.exports = {
```

## Introspecting your backend's graphql schema

Here's how to get your backend's schema in the way that this tool expects, using the builtin 'graphql introspection query':

```js
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,13 @@
"@babel/preset-env": "^7.24.5",
"@babel/preset-typescript": "^7.24.1",
"@changesets/cli": "^2.21.1",
"@jest/globals": "^30.2.0",
"@khanacademy/eslint-config": "^4.0.0",
"@types/jest": "^29.5.3",
"@types/minimist": "^1.2.5",
"@types/prop-types": "^15.7.12",
"@types/react": "^18.3.3",
"@types/resolve": "^1.20.6",
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.17.0",
"babel-jest": "23.4.2",
Expand All @@ -61,7 +64,8 @@
"apollo-utilities": "^1.3.4",
"graphql": "^16.9.0",
"jsonschema": "^1.4.1",
"minimist": "^1.2.8"
"minimist": "^1.2.8",
"resolve": "^1.22.8"
},
"packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c"
}
885 changes: 813 additions & 72 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

35 changes: 16 additions & 19 deletions schema.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema":"http://json-schema.org/draft-07/schema",
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"definitions": {
Expand All @@ -13,19 +13,13 @@
"match": {
"type": "array",
"items": {
"oneOf": [
{ "type": "object" },
{ "type": "string" }
]
"oneOf": [{"type": "object"}, {"type": "string"}]
}
},
"exclude": {
"type": "array",
"items": {
"oneOf": [
{ "type": "object" },
{ "type": "string" }
]
"oneOf": [{"type": "object"}, {"type": "string"}]
}
},
"scalars": {
Expand Down Expand Up @@ -65,9 +59,7 @@
"type": "boolean"
}
},
"required": [
"schemaFilePath"
]
"required": ["schemaFilePath"]
}
},
"properties": {
Expand All @@ -89,12 +81,17 @@
"type": "string"
}
},
"required": [ "root" ]
"required": ["root"]
},
"generate": {
"oneOf": [
{"$ref": "#/definitions/GenerateConfig"},
{
"type": "array",
"items": {"$ref": "#/definitions/GenerateConfig"}
}
]
},
"generate": {"oneOf": [
{"$ref": "#/definitions/GenerateConfig"},
{"type": "array", "items": {"$ref": "#/definitions/GenerateConfig"}}
]},
"alias": {
"type": "array",
"items": {
Expand All @@ -108,9 +105,9 @@
"type": "string"
}
},
"required": [ "find", "replacement" ]
"required": ["find", "replacement"]
}
}
},
"required": [ "crawl", "generate" ]
"required": ["crawl", "generate"]
}
192 changes: 191 additions & 1 deletion src/parser/__test__/parse.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
/**
* @jest-environment node
*/
import {describe, it, expect} from "@jest/globals";
import resolve from "resolve";

import {Config} from "../../types";
import {processFiles} from "../parse";
import {resolveDocuments} from "../resolve";

import {print} from "graphql/language/printer";

jest.mock("resolve");

const fixtureFiles: {
[key: string]:
| string
Expand All @@ -14,6 +20,27 @@ const fixtureFiles: {
resolvedPath: string;
};
} = {
"/repo/node_modules/monorepo-package/fragment.js": `
import gql from 'graphql-tag';

export const sharedFragment = gql\`
fragment SharedFields on Something {
id
}
\`;
`,
"/repo/packages/app/App.js": `
import gql from 'graphql-tag';
import {sharedFragment} from 'monorepo-package/fragment';
export const appQuery = gql\`
query AppQuery {
viewer {
...SharedFields
}
}
\${sharedFragment}
\`;
`,
"/firstFile.js": `
// Note that you can import graphql-tag as
// something other than gql.
Expand Down Expand Up @@ -56,6 +83,30 @@ const fixtureFiles: {
}
\`;
export {secondFragment};`,
"/starExportSource.js": `
import gql from 'graphql-tag';
export const starFragment = gql\`
fragment StarFragment on Star {
id
}
\`;
`,
"/starExportReexport.js": `
export * from './starExportSource.js';
`,
"/starExportConsumer.js": `
import gql from 'graphql-tag';
import {starFragment} from './starExportReexport.js';

export const starQuery = gql\`
query StarQuery {
stars {
...StarFragment
}
}
\${starFragment}
\`;
`,

"/thirdFile.js": `
import {fromFirstFile, alsoFirst, secondFragment} from './secondFile.js';
Expand Down Expand Up @@ -281,7 +332,7 @@ describe("processing fragments in various ways", () => {
expect(files["/invalidThings.js"].errors.map((m: any) => m.message))
.toMatchInlineSnapshot(`
Array [
"Unable to resolve someExternalFragment",
"Unable to resolve import someExternalFragment from \\\"somewhere\\\" at /invalidThings.js:4.",
"Unable to resolve someUndefinedFragment",
"Template literal interpolation must be an identifier",
]
Expand Down Expand Up @@ -338,4 +389,143 @@ describe("processing fragments in various ways", () => {
);
expect(printed).toMatchInlineSnapshot(`Object {}`);
});

it("should resolve fragments re-exported via export all", () => {
// Arrange
const config: Config = {
crawl: {
root: "/here/we/crawl",
},
generate: {
match: [/\.fixture\.js$/],
exclude: [
"_test\\.js$",
"\\bcourse-editor-package\\b",
"\\.fixture\\.js$",
"\\b__flowtests__\\b",
"\\bcourse-editor\\b",
],
readOnlyArray: false,
regenerateCommand: "make gqlflow",
scalars: {
JSONString: "string",
KALocale: "string",
NaiveDateTime: "string",
},
splitTypes: true,
generatedDirectory: "__graphql-types__",
exportAllObjectTypes: true,
schemaFilePath: "./composed_schema.graphql",
},
};
// Act
const files = processFiles(
["/starExportConsumer.js"],
config,
getFileSource,
);
const {resolved, errors} = resolveDocuments(files, config);
const printed: Record<string, any> = {};
Object.keys(resolved).map(
(k: any) => (printed[k] = print(resolved[k].document).trim()),
);

// Assert
Object.keys(files).forEach((k: any) => {
expect(files[k].errors).toEqual([]);
});
expect(errors).toEqual([]);
expect(printed).toMatchInlineSnapshot(`
Object {
"/starExportConsumer.js:5": "query StarQuery {
stars {
...StarFragment
}
}

fragment StarFragment on Star {
id
}",
"/starExportSource.js:3": "fragment StarFragment on Star {
id
}",
}
`);
});

it("should resolve fragments imported from monorepo packages", () => {
// Arrange
const config: Config = {
crawl: {
root: "/here/we/crawl",
},
generate: {
match: [/\.fixture\.js$/],
exclude: [
"_test\\.js$",
"\\bcourse-editor-package\\b",
"\\.fixture\\.js$",
"\\b__flowtests__\\b",
"\\bcourse-editor\\b",
],
readOnlyArray: false,
regenerateCommand: "make gqlflow",
scalars: {
JSONString: "string",
KALocale: "string",
NaiveDateTime: "string",
},
splitTypes: true,
generatedDirectory: "__graphql-types__",
exportAllObjectTypes: true,
schemaFilePath: "./composed_schema.graphql",
},
};

const resolveSync = resolve.sync as jest.Mock;
resolveSync.mockImplementation((specifier: string) => {
if (specifier === "monorepo-package/fragment") {
return "/repo/node_modules/monorepo-package/fragment.js";
}
throw new Error(`Unexpected resolve for ${specifier}`);
});

try {
// Act
const files = processFiles(
["/repo/packages/app/App.js"],
config,
getFileSource,
);
const {resolved, errors} = resolveDocuments(files, config);
const printed: Record<string, any> = {};
Object.keys(resolved).map(
(k: any) => (printed[k] = print(resolved[k].document).trim()),
);

// Assert
Object.keys(files).forEach((k: any) => {
expect(files[k].errors).toEqual([]);
});
expect(errors).toEqual([]);
expect(printed).toMatchInlineSnapshot(`
Object {
"/repo/node_modules/monorepo-package/fragment.js:4": "fragment SharedFields on Something {
id
}",
"/repo/packages/app/App.js:4": "query AppQuery {
viewer {
...SharedFields
}
}

fragment SharedFields on Something {
id
}",
}
`);
} finally {
resolveSync.mockReset();
}
});
});
43 changes: 41 additions & 2 deletions src/parser/__test__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/**
* @jest-environment node
*/
import fs from "fs";
import {describe, it, expect, jest} from "@jest/globals";
import {describe, it, expect} from "@jest/globals";
import type {Config} from "../../types";

import {getPathWithExtension} from "../utils";
import resolve from "resolve";
import {getPathWithExtension, resolveImportPath} from "../utils";

jest.mock("resolve");

const generate = {
match: [/\.fixture\.js$/],
Expand Down Expand Up @@ -78,3 +84,36 @@ describe("getPathWithExtension", () => {
expect(result).toBe("../../some/prefix/dir/file.js");
});
});

describe("resolveImportPath", () => {
const resolveSync = resolve.sync as jest.Mock;

beforeEach(() => {
resolveSync.mockReset();
});

it("returns null for graphql-tag without invoking resolve", () => {
const result = resolveImportPath("graphql-tag", "/from", config);
expect(result).toBeNull();
expect(resolveSync).not.toHaveBeenCalled();
});

it("resolves relative paths via node resolution", () => {
resolveSync.mockReturnValue("/from/fragment.ts");
const result = resolveImportPath("./fragment", "/from", config);
expect(result).toBe("/from/fragment");
expect(resolveSync).not.toHaveBeenCalled();
});

it("resolves package specifiers via node resolution", () => {
resolveSync.mockReturnValue("/repo/node_modules/pkg/fragment.js");
const result = resolveImportPath("pkg/fragment", "/from", config);
expect(result).toBe("/repo/node_modules/pkg/fragment.js");
expect(resolveSync).toHaveBeenCalledWith("pkg/fragment", {
basedir: "/from",
extensions: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
paths: undefined,
preserveSymlinks: false,
});
});
});
Loading
Loading