refactor(graphql): codegen (#10626)

This commit is contained in:
liuyi
2025-03-06 12:06:19 +08:00
committed by GitHub
parent fb084a9569
commit 7e61a0b2fc
10 changed files with 487 additions and 715 deletions

View File

@@ -32,7 +32,5 @@ packages/backend/server/src/__tests__/__snapshots__
packages/common/native/fixtures/** packages/common/native/fixtures/**
packages/frontend/native/index.d.ts packages/frontend/native/index.d.ts
packages/frontend/native/index.js packages/frontend/native/index.js
packages/frontend/graphql/src/graphql/index.ts
packages/frontend/graphql/src/schema.ts
packages/frontend/apps/android/App/app/build/** packages/frontend/apps/android/App/app/build/**
blocksuite/tests-legacy/snapshots blocksuite/tests-legacy/snapshots

View File

@@ -33,8 +33,6 @@
"packages/common/native/fixtures/**", "packages/common/native/fixtures/**",
"packages/frontend/native/index.d.ts", "packages/frontend/native/index.d.ts",
"packages/frontend/native/index.js", "packages/frontend/native/index.js",
"packages/frontend/graphql/src/graphql/index.ts",
"packages/frontend/graphql/src/schema.ts",
"packages/frontend/apps/android/App/app/build/**", "packages/frontend/apps/android/App/app/build/**",
"blocksuite/tests-legacy/snapshots" "blocksuite/tests-legacy/snapshots"
], ],

View File

@@ -41,7 +41,7 @@ export function Auth() {
fetch('/graphql', { fetch('/graphql', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
operationName: getUserFeaturesQuery.operationName, operationName: getUserFeaturesQuery.op,
query: getUserFeaturesQuery.query, query: getUserFeaturesQuery.query,
variables: {}, variables: {},
}), }),

View File

@@ -1,8 +1,8 @@
import { import {
type GetDocRolePermissionsQuery, type GetDocRolePermissionsQuery,
getDocRolePermissionsQuery, getDocRolePermissionsQuery,
type GetWorkspaceRolePermissionsQuery,
getWorkspaceRolePermissionsQuery, getWorkspaceRolePermissionsQuery,
type WorkspacePermissions,
} from '@affine/graphql'; } from '@affine/graphql';
import { Store } from '@toeverything/infra'; import { Store } from '@toeverything/infra';
@@ -10,7 +10,7 @@ import type { WorkspaceServerService } from '../../cloud';
import type { WorkspaceService } from '../../workspace'; import type { WorkspaceService } from '../../workspace';
export type WorkspacePermissionActions = keyof Omit< export type WorkspacePermissionActions = keyof Omit<
WorkspacePermissions, GetWorkspaceRolePermissionsQuery['workspaceRolePermissions']['permissions'],
'__typename' '__typename'
>; >;

View File

@@ -1,7 +1,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { Kind, print } = require('graphql'); const { Kind, print, visit, TypeInfo, visitWithTypeInfo } = require('graphql');
const { upperFirst, lowerFirst } = require('lodash'); const { upperFirst, lowerFirst } = require('lodash');
/** /**
@@ -19,11 +19,82 @@ function getExportedName(def) {
return name.endsWith(suffix) ? name : name + suffix; return name.endsWith(suffix) ? name : name + suffix;
} }
/**
* Check if a field is deprecated in the schema
*
* @param {import('graphql').GraphQLSchema} schema
* @param {string} typeName
* @param {string} fieldName
* @returns {boolean}
*/
function fieldDeprecation(schema, typeName, fieldName) {
const type = schema.getType(typeName);
if (!type || !type.getFields) {
return false;
}
const fields = type.getFields();
const field = fields[fieldName];
return field?.deprecationReason
? {
name: fieldName,
reason: field.deprecationReason,
}
: null;
}
/**
* Check if a query uses deprecated fields
*
* @param {import('graphql').GraphQLSchema} schema
* @param {import('graphql').DocumentNode} document
* @returns {boolean}
*/
function parseDeprecations(schema, document) {
const deprecations = [];
const typeInfo = new TypeInfo(schema);
visit(
document,
visitWithTypeInfo(typeInfo, {
Field: {
enter(node) {
const parentType = typeInfo.getParentType();
if (parentType && node.name) {
const fieldName = node.name.value;
let deprecation;
if (
parentType.name &&
(deprecation = fieldDeprecation(
schema,
parentType.name,
fieldName
))
) {
deprecations.push(deprecation);
}
}
},
},
})
);
return deprecations.map(
({ name, reason }) => `'${name}' is deprecated: ${reason}`
);
}
/** /**
* @type {import('@graphql-codegen/plugin-helpers').CodegenPlugin} * @type {import('@graphql-codegen/plugin-helpers').CodegenPlugin}
*/ */
module.exports = { module.exports = {
plugin: (schema, documents, { output }) => { plugin: (schema, documents, { output }) => {
const defs = new Map();
const queries = [];
const mutations = [];
const nameLocationMap = new Map(); const nameLocationMap = new Map();
const locationSourceMap = new Map( const locationSourceMap = new Map(
documents documents
@@ -31,167 +102,162 @@ module.exports = {
.map(source => [source.location, source]) .map(source => [source.location, source])
); );
/** function addDef(exportedName, location) {
* @type {string[]} if (nameLocationMap.has(exportedName)) {
*/ throw new Error(
const defs = []; `name ${exportedName} export from ${location} are duplicated.`
const queries = []; );
const mutations = []; }
nameLocationMap.set(exportedName, location);
}
function parseImports(location) {
if (!location) {
return '';
}
// parse '#import' lines
const importedDefinitions = [];
fs.readFileSync(location, 'utf-8')
.split(/\r\n|\r|\n/)
.forEach(line => {
if (line[0] === '#') {
const [importKeyword, importPath] = line.split(' ').filter(Boolean);
if (importKeyword === '#import') {
const realImportPath = path.posix.join(
location,
'..',
importPath.replace(/["']/g, '')
);
const imports =
locationSourceMap.get(realImportPath)?.document.definitions;
if (imports) {
importedDefinitions.push(...imports);
}
}
}
});
return importedDefinitions
.map(def => `\${${getExportedName(def)}}`)
.join('\n');
}
for (const [location, source] of locationSourceMap) { for (const [location, source] of locationSourceMap) {
if ( if (!source || !source.document || !source.rawSDL) {
!source ||
!source.document ||
!location ||
source.document.kind !== Kind.DOCUMENT ||
!source.document.definitions ||
!source.document.definitions.length
) {
return; return;
} }
const doc = source.document; visit(source.document, {
[Kind.OPERATION_DEFINITION]: {
enter: node => {
if (!node.name) {
throw new Error(
`Anonymous operation definition found in ${location}.`
);
}
if (doc.definitions.length > 1) { const exportedName = getExportedName(node);
throw new Error('Only support one definition per file.'); addDef(exportedName, location);
}
const definition = doc.definitions[0];
if (!definition) {
throw new Error(`Found empty file ${location}.`);
}
if ( // parse 'file' fields
!definition.selectionSet || const containsFile = node.variableDefinitions.some(def => {
!definition.selectionSet.selections || const varType = def?.type?.type?.name?.value;
definition.selectionSet.selections.length === 0 const checkContainFile = type => {
) { if (schema.getType(type)?.name === 'Upload') return true;
throw new Error(`Found empty fields selection in file ${location}`); const typeDef = schema.getType(type);
} const fields = typeDef.getFields?.();
if (!fields || typeof fields !== 'object') return false;
if ( for (let field of Object.values(fields)) {
definition.kind === Kind.OPERATION_DEFINITION || let type = field.type;
definition.kind === Kind.FRAGMENT_DEFINITION while (type.ofType) {
) { type = type.ofType;
if (!definition.name) { }
throw new Error(`Anonymous definition found in ${location}`); if (type.name === 'Upload') {
} return true;
const exportedName = getExportedName(definition);
// duplication checking
if (nameLocationMap.has(exportedName)) {
throw new Error(
`name ${exportedName} export from ${location} are duplicated.`
);
} else {
/**
* @type {import('graphql').DefinitionNode[]}
*/
let importedDefinitions = [];
if (source.location) {
fs.readFileSync(source.location, 'utf8')
.split(/\r\n|\r|\n/)
.forEach(line => {
if (line[0] === '#') {
const [importKeyword, importPath] = line
.split(' ')
.filter(Boolean);
if (importKeyword === '#import') {
const realImportPath = path.posix.join(
location,
'..',
importPath.replace(/["']/g, '')
);
const imports =
locationSourceMap.get(realImportPath)?.document
.definitions;
if (imports) {
importedDefinitions = [
...importedDefinitions,
...imports,
];
}
} }
} }
});
}
const importing = importedDefinitions
.map(def => `\${${getExportedName(def)}}`)
.join('\n');
// is query or mutation
if (definition.kind === Kind.OPERATION_DEFINITION) {
// add for runtime usage
doc.operationName = definition.name.value;
doc.defName = definition.selectionSet.selections
.filter(field => field.kind === Kind.FIELD)
.map(field => field.name.value)
.join(',');
nameLocationMap.set(exportedName, location);
const containsFile = doc.definitions.some(def => {
const { variableDefinitions } = def;
if (variableDefinitions) {
return variableDefinitions.some(variableDefinition => {
const varType = variableDefinition?.type?.type?.name?.value;
const checkContainFile = type => {
if (schema.getType(type)?.name === 'Upload') return true;
const typeDef = schema.getType(type);
const fields = typeDef.getFields?.();
if (!fields || typeof fields !== 'object') return false;
for (let field of Object.values(fields)) {
let type = field.type;
while (type.ofType) {
type = type.ofType;
}
if (type.name === 'Upload') {
return true;
}
}
return false;
};
return varType ? checkContainFile(varType) : false;
});
} else {
return false; return false;
} };
return varType ? checkContainFile(varType) : false;
}); });
defs.push(`export const ${exportedName} = {
id: '${exportedName}' as const, // Check if the query uses deprecated fields
operationName: '${doc.operationName}', const deprecations = parseDeprecations(schema, source.document);
definitionName: '${doc.defName}',
containsFile: ${containsFile}, const imports = parseImports(location);
query: \`
${print(doc)}${importing || ''}\`, defs.set(exportedName, {
}; type: node.operation,
`); name: exportedName,
if (definition.operation === 'query') { operationName: node.name.value,
containsFile,
deprecations,
query: `${print(node)}${imports ? `\n${imports}` : ''}`,
});
if (node.operation === 'query') {
queries.push(exportedName); queries.push(exportedName);
} else if (definition.operation === 'mutation') { } else if (node.operation === 'mutation') {
mutations.push(exportedName); mutations.push(exportedName);
} }
} else { },
defs.unshift(`export const ${exportedName} = \` },
${print(doc)}${importing || ''}\``); [Kind.FRAGMENT_DEFINITION]: {
} enter: node => {
} const exportedName = getExportedName(node);
} addDef(exportedName, location);
const imports = parseImports(location);
defs.set(exportedName, {
type: 'fragment',
name: exportedName,
content: `${print(node)}${imports || ''}`,
});
},
},
});
} }
const preludes = [
'/* do not manipulate this file manually. */',
`export interface GraphQLQuery {
id: string;
op: string;
query: string;
file?: boolean;
deprecations?: string[];
}`,
];
const operations = [];
defs.forEach(def => {
if (def.type === 'fragment') {
preludes.push(`export const ${def.name} = \`${def.content}\`;`);
} else {
let item = `export const ${def.name} = {
id: '${def.name}' as const,
op: '${def.operationName}',
query: \`${def.query}\`,
`;
if (def.containsFile) {
item += ' file: true,\n';
}
if (def.deprecations.length) {
item += ` deprecations: ${JSON.stringify(def.deprecations)},\n`;
}
item += '};\n';
operations.push(item);
}
});
fs.writeFileSync( fs.writeFileSync(
output, output,
[ preludes.join('\n') + '\n' + operations.join('\n')
'/* do not manipulate this file manually. */',
`export interface GraphQLQuery {
id: string;
operationName: string;
definitionName: string;
query: string;
containsFile?: boolean;
}
`,
...defs,
].join('\n')
); );
const queriesUnion = queries const queriesUnion = queries

View File

@@ -24,6 +24,7 @@
"build": "gql-gen --errors-only" "build": "gql-gen --errors-only"
}, },
"dependencies": { "dependencies": {
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*", "@affine/env": "workspace:*",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@@ -7,8 +7,7 @@ import type { GraphQLQuery } from '../graphql';
const query: GraphQLQuery = { const query: GraphQLQuery = {
id: 'query', id: 'query',
query: 'query { field }', query: 'query { field }',
operationName: 'query', op: 'query',
definitionName: 'query',
}; };
let fetch: Mock; let fetch: Mock;
@@ -55,7 +54,6 @@ describe('GraphQL fetcher', () => {
body: '{"query":"query { field }","variables":{"a":1,"b":"2","c":{"d":false}},"operationName":"query"}', body: '{"query":"query { field }","variables":{"a":1,"b":"2","c":{"d":false}},"operationName":"query"}',
headers: expect.objectContaining({ headers: expect.objectContaining({
'content-type': 'application/json', 'content-type': 'application/json',
'x-definition-name': 'query',
'x-operation-name': 'query', 'x-operation-name': 'query',
}), }),
method: 'POST', method: 'POST',

View File

@@ -1,3 +1,4 @@
import { DebugLogger } from '@affine/debug';
import type { ExecutionResult } from 'graphql'; import type { ExecutionResult } from 'graphql';
import { isNil, isObject, merge } from 'lodash-es'; import { isNil, isObject, merge } from 'lodash-es';
@@ -156,11 +157,11 @@ function formatRequestBody<Q extends GraphQLQuery>({
(keepNilVariables ?? true) ? variables : filterEmptyValue(variables), (keepNilVariables ?? true) ? variables : filterEmptyValue(variables),
}; };
if (query.operationName) { if (query.op) {
body.operationName = query.operationName; body.operationName = query.op;
} }
if (query.containsFile) { if (query.file) {
return transformToForm(body); return transformToForm(body);
} }
return body; return body;
@@ -170,15 +171,24 @@ export const gqlFetcherFactory = (
endpoint: string, endpoint: string,
fetcher: (input: string, init?: RequestInit) => Promise<Response> = fetch fetcher: (input: string, init?: RequestInit) => Promise<Response> = fetch
) => { ) => {
const logger = new DebugLogger('GraphQL');
const gqlFetch = async <Query extends GraphQLQuery>( const gqlFetch = async <Query extends GraphQLQuery>(
options: QueryOptions<Query> options: QueryOptions<Query>
): Promise<QueryResponse<Query>> => { ): Promise<QueryResponse<Query>> => {
if (
BUILD_CONFIG.appBuildType === 'canary' &&
options.query.deprecations?.length
) {
options.query.deprecations.forEach(deprecation => {
logger.warn(deprecation);
});
}
const body = formatRequestBody(options); const body = formatRequestBody(options);
const isFormData = body instanceof FormData; const isFormData = body instanceof FormData;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'x-operation-name': options.query.operationName, 'x-operation-name': options.query.op,
'x-definition-name': options.query.definitionName,
}; };
if (!isFormData) { if (!isFormData) {
headers['content-type'] = 'application/json'; headers['content-type'] = 'application/json';
@@ -208,8 +218,7 @@ export const gqlFetcherFactory = (
} }
throw new GraphQLError( throw new GraphQLError(
'GraphQL query responds unexpected result, query ' + 'GraphQL query responds unexpected result, query ' + options.query.op
options.query.operationName
); );
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -571,6 +571,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@affine/graphql@workspace:packages/frontend/graphql" resolution: "@affine/graphql@workspace:packages/frontend/graphql"
dependencies: dependencies:
"@affine/debug": "workspace:*"
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@graphql-codegen/add": "npm:^5.0.3" "@graphql-codegen/add": "npm:^5.0.3"
"@graphql-codegen/cli": "npm:5.0.5" "@graphql-codegen/cli": "npm:5.0.5"