Skip to content

Commit

Permalink
feat: initial support for graphql.persisted (#240)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoviDeCroock committed Apr 5, 2024
1 parent 4f54785 commit 097f83b
Show file tree
Hide file tree
Showing 8 changed files with 422 additions and 15 deletions.
4 changes: 3 additions & 1 deletion packages/example-tada/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createClient, useQuery } from 'urql';
import { useQuery } from 'urql';
import { graphql } from './graphql';
import { Fields, Pokemon, PokemonFields } from './Pokemon';

Expand Down Expand Up @@ -32,6 +32,8 @@ const PokemonQuery = graphql(`
}
`, [PokemonFields, Fields.Pokemon]);

const persisted = graphql.persisted<typeof PokemonQuery>("sha256:dc31ff9637bbc77bb95dffb2ca73b0e607639b018befd06e9ad801b54483d661")

const Pokemons = () => {
const [result] = useQuery({
query: PokemonQuery,
Expand Down
1 change: 1 addition & 0 deletions packages/graphqlsp/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { getGraphQLDiagnostics } from './diagnostics';
export { init } from './ts';
export { findAllPersistedCallExpressions } from './ast';
23 changes: 23 additions & 0 deletions packages/graphqlsp/src/ast/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,29 @@ export function findAllCallExpressions(
return { nodes: result, fragments };
}

export function findAllPersistedCallExpressions(
sourceFile: ts.SourceFile
): Array<ts.CallExpression> {
const result: Array<ts.CallExpression> = [];
function find(node: ts.Node) {
if (ts.isCallExpression(node)) {
// This expression ideally for us looks like <template>.persisted
const expression = node.expression.getText();
const parts = expression.split('.');
if (parts.length !== 2) return;

const [template, method] = parts;
if (!templates.has(template) || method !== 'persisted') return;

result.push(node);
} else {
ts.forEachChild(node, find);
}
}
find(sourceFile);
return result;
}

export function getAllFragments(
fileName: string,
node: ts.CallExpression,
Expand Down
102 changes: 102 additions & 0 deletions packages/graphqlsp/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { print } from '@0no-co/graphql.web';

import {
findAllCallExpressions,
findAllPersistedCallExpressions,
findAllTaggedTemplateNodes,
getSource,
} from './ast';
Expand All @@ -23,6 +24,7 @@ import {
MISSING_FRAGMENT_CODE,
getColocatedFragmentNames,
} from './checkImports';
import { getDocumentReferenceFromTypeQuery } from './persisted';

const clientDirectives = new Set([
'populate',
Expand All @@ -48,12 +50,18 @@ const directiveRegex = /Unknown directive "@([^)]+)"/g;
export const SEMANTIC_DIAGNOSTIC_CODE = 52001;
export const MISSING_OPERATION_NAME_CODE = 52002;
export const USING_DEPRECATED_FIELD_CODE = 52004;
export const MISSING_PERSISTED_TYPE_ARG = 520100;
export const MISSING_PERSISTED_CODE_ARG = 520101;
export const MISSING_PERSISTED_DOCUMENT = 520102;
export const ALL_DIAGNOSTICS = [
SEMANTIC_DIAGNOSTIC_CODE,
MISSING_OPERATION_NAME_CODE,
USING_DEPRECATED_FIELD_CODE,
MISSING_FRAGMENT_CODE,
UNUSED_FIELD_CODE,
MISSING_PERSISTED_TYPE_ARG,
MISSING_PERSISTED_CODE_ARG,
MISSING_PERSISTED_DOCUMENT,
];

const cache = new LRUCache<number, ts.Diagnostic[]>({
Expand Down Expand Up @@ -117,6 +125,98 @@ export function getGraphQLDiagnostics(
const shouldCheckForColocatedFragments =
info.config.shouldCheckForColocatedFragments ?? true;
let fragmentDiagnostics: ts.Diagnostic[] = [];

if (isCallExpression) {
const persistedCalls = findAllPersistedCallExpressions(source);
// We need to check whether the user has correctly inserted a hash,
// by means of providing an argument to the function and that they
// are establishing a reference to the document by means of the generic.
//
// OPTIONAL: we could also check whether the hash is out of date with the
// document but this removes support for self-generating identifiers
const persistedDiagnostics = persistedCalls
.map<ts.Diagnostic | null>(callExpression => {
if (!callExpression.typeArguments) {
return {
category: ts.DiagnosticCategory.Warning,
code: MISSING_PERSISTED_TYPE_ARG,
file: source,
messageText: 'Missing generic pointing at the GraphQL document.',
start: callExpression.getStart(),
length: callExpression.getEnd() - callExpression.getStart(),
};
}

const [typeQuery] = callExpression.typeArguments;

if (!ts.isTypeQueryNode(typeQuery)) {
// Provide diagnostic about wroong generic
return {
category: ts.DiagnosticCategory.Warning,
code: MISSING_PERSISTED_TYPE_ARG,
file: source,
messageText:
'Provided generic should be a typeQueryNode in the shape of graphql.persisted<typeof document>.',
start: typeQuery.getStart(),
length: typeQuery.getEnd() - typeQuery.getStart(),
};
}

const { node: foundNode } = getDocumentReferenceFromTypeQuery(
typeQuery,
filename,
info
);

if (!foundNode) {
return {
category: ts.DiagnosticCategory.Warning,
code: MISSING_PERSISTED_DOCUMENT,
file: source,
messageText: `Can't find reference to "${typeQuery.getText()}".`,
start: typeQuery.getStart(),
length: typeQuery.getEnd() - typeQuery.getStart(),
};
}

const initializer = foundNode.initializer;
if (
!initializer ||
!ts.isCallExpression(initializer) ||
!ts.isNoSubstitutionTemplateLiteral(initializer.arguments[0])
) {
// TODO: we can make this check more stringent where we also parse and resolve
// the accompanying template.
return {
category: ts.DiagnosticCategory.Warning,
code: MISSING_PERSISTED_DOCUMENT,
file: source,
messageText: `Referenced type "${typeQuery.getText()}" is not a GraphQL document.`,
start: typeQuery.getStart(),
length: typeQuery.getEnd() - typeQuery.getStart(),
};
}

if (!callExpression.arguments[0]) {
// TODO: this might be covered by the API enforcing the first
// argument so can possibly be removed.
return {
category: ts.DiagnosticCategory.Warning,
code: MISSING_PERSISTED_CODE_ARG,
file: source,
messageText: `The call-expression is missing a hash for the persisted argument.`,
start: callExpression.arguments.pos,
length: callExpression.arguments.end - callExpression.arguments.pos,
};
}

return null;
})
.filter(Boolean);

tsDiagnostics.push(...(persistedDiagnostics as ts.Diagnostic[]));
}

if (isCallExpression && shouldCheckForColocatedFragments) {
const moduleSpecifierToFragments = getColocatedFragmentNames(source, info);

Expand Down Expand Up @@ -357,6 +457,8 @@ const runDiagnostics = (
info
) || [];

if (!usageDiagnostics) return tsDiagnostics

return [...tsDiagnostics, ...usageDiagnostics];
} else {
return tsDiagnostics;
Expand Down
89 changes: 84 additions & 5 deletions packages/graphqlsp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getGraphQLCompletions } from './autoComplete';
import { getGraphQLQuickInfo } from './quickInfo';
import { ALL_DIAGNOSTICS, getGraphQLDiagnostics } from './diagnostics';
import { templates } from './ast/templates';
import { getPersistedCodeFixAtPosition } from './persisted';

function createBasicDecorator(info: ts.server.PluginCreateInfo) {
const proxy: ts.LanguageService = Object.create(null);
Expand Down Expand Up @@ -99,6 +100,89 @@ function create(info: ts.server.PluginCreateInfo) {
}
};

proxy.getEditsForRefactor = (
filename,
formatOptions,
positionOrRange,
refactorName,
actionName,
preferences,
interactive
) => {
const original = info.languageService.getEditsForRefactor(
filename,
formatOptions,
positionOrRange,
refactorName,
actionName,
preferences,
interactive
);

const codefix = getPersistedCodeFixAtPosition(
filename,
typeof positionOrRange === 'number'
? positionOrRange
: positionOrRange.pos,
info
);
if (!codefix) return original;
return {
edits: [
{
fileName: filename,
textChanges: [{ newText: codefix.replacement, span: codefix.span }],
},
],
};
};

proxy.getApplicableRefactors = (
filename,
positionOrRange,
preferences,
reason,
kind,
includeInteractive
) => {
const original = info.languageService.getApplicableRefactors(
filename,
positionOrRange,
preferences,
reason,
kind,
includeInteractive
);

const codefix = getPersistedCodeFixAtPosition(
filename,
typeof positionOrRange === 'number'
? positionOrRange
: positionOrRange.pos,
info
);
console.log('[GraphQLSP]', JSON.stringify(codefix));
if (codefix) {
return [
{
name: 'GraphQL',
description: 'Operations specific to gql.tada!',
actions: [
{
name: 'Insert document-id',
description:
'Generate a document-id for your persisted-operation, by default a SHA256 hash.',
},
],
inlineable: true,
},
...original,
];
} else {
return original;
}
};

proxy.getQuickInfoAtPosition = (filename: string, cursorPosition: number) => {
const quickInfo = getGraphQLQuickInfo(
filename,
Expand All @@ -115,11 +199,6 @@ function create(info: ts.server.PluginCreateInfo) {
);
};

// TODO: check out the following hooks
// - getSuggestionDiagnostics, can suggest refactors
// - getCompletionEntryDetails, this can build on the auto-complete for more information
// - getCodeFixesAtPosition

logger('proxy: ' + JSON.stringify(proxy));

return proxy;
Expand Down
Loading

0 comments on commit 097f83b

Please sign in to comment.