Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dfb15cf
enhances for argtype coercion
AndresPrez May 24, 2022
b73c68a
Adds unit tests for new functionalities
AndresPrez May 24, 2022
6717867
fixes linting issues
AndresPrez May 26, 2022
d924691
Creates a generic FieldRewriter
AndresPrez May 26, 2022
f67ca80
forgot to export new rewriter
AndresPrez May 26, 2022
ac91ec4
Adds ability to add arguments to the field in FieldRewriter
AndresPrez May 26, 2022
b05e39c
fixes issue when renamed object fields returned an array of values
AndresPrez May 26, 2022
9da7701
Adds support for alises. Implements new CustomRewriter
AndresPrez May 27, 2022
5a2b3d3
matchAnyPath moved from rewrite handler to rewrite claass
AndresPrez May 27, 2022
5d1e8fc
adds support to rename fields with alises
AndresPrez May 27, 2022
843d057
Adds matching node to the rewriteResponse fn
AndresPrez May 27, 2022
8ebdb48
Merge branch 'master' into feature/ap/field-rewriter
AndresPrez May 29, 2022
54e7491
Fixes issue with CustomRewriter default matchConditions array
AndresPrez May 30, 2022
40eda8b
Merge branch 'feature/ap/field-rewriter' of github.com:AndresPrez/gra…
AndresPrez May 30, 2022
a267589
Adds rewriteVariables to CustomRewriter
AndresPrez May 31, 2022
b7073f0
fixes reviewed code
AndresPrez Jun 6, 2022
d021875
variable trim and rename
AndresPrez Jun 7, 2022
0b67967
adds fix for when rewritten field results are empty arrays
AndresPrez Jun 19, 2022
7873cfa
v0.0.16
AndresPrez Jun 20, 2022
ccbb642
fixes "rewriteResultsAtPath" for non-field paths
AndresPrez Jun 28, 2022
a752523
v0.0.17
AndresPrez Jun 28, 2022
0d313a5
v0.0.1
AndresPrez Jun 28, 2022
8ce15ad
fixes unintended change to package.json
AndresPrez Jun 28, 2022
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
40 changes: 27 additions & 13 deletions src/RewriteHandler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { FragmentDefinitionNode, parse, print } from 'graphql';
import { ASTNode, FragmentDefinitionNode, parse, print } from 'graphql';
import { extractPath, FragmentTracer, rewriteDoc, rewriteResultsAtPath } from './ast';
import Rewriter, { Variables } from './rewriters/Rewriter';

interface RewriterMatch {
rewriter: Rewriter;
paths: ReadonlyArray<ReadonlyArray<string>>;
// TODO: allPaths hasnt been tested for fragments
allPaths: ReadonlyArray<ReadonlyArray<string>>;
nodeMatchAndParents?: ASTNode[];
}

/**
Expand Down Expand Up @@ -40,17 +43,24 @@ export default class RewriteHandler {
if (isMatch) {
rewrittenVariables = rewriter.rewriteVariables(rewrittenNodeAndVars, rewrittenVariables);
rewrittenNodeAndVars = rewriter.rewriteQuery(rewrittenNodeAndVars, rewrittenVariables);
const simplePath = extractPath([...parents, rewrittenNodeAndVars.node]);
let paths: ReadonlyArray<ReadonlyArray<string>> = [simplePath];
const fieldPath = extractPath([...parents, rewrittenNodeAndVars.node]);
const anyPath = extractPath([...parents, rewrittenNodeAndVars.node], true);
let fieldPaths: ReadonlyArray<ReadonlyArray<string>> = [fieldPath];
let allPaths: ReadonlyArray<ReadonlyArray<string>> = [anyPath];
const fragmentDef = parents.find(({ kind }) => kind === 'FragmentDefinition') as
| FragmentDefinitionNode
| undefined;
if (fragmentDef) {
paths = fragmentTracer.prependFragmentPaths(fragmentDef.name.value, simplePath);
fieldPaths = fragmentTracer.prependFragmentPaths(fragmentDef.name.value, fieldPath);
allPaths = fragmentTracer.prependFragmentPaths(fragmentDef.name.value, anyPath);
}
this.matches.push({
rewriter,
paths
allPaths,
paths: fieldPaths,
...(rewriter.saveNode
? { nodeMatchAndParents: [...parents, rewrittenNodeAndVars.node] }
: {})
});
}
return isMatch;
Expand All @@ -70,15 +80,19 @@ export default class RewriteHandler {
if (this.hasProcessedResponse) throw new Error('This handler has already returned a response');
this.hasProcessedResponse = true;
let rewrittenResponse = response;
this.matches.reverse().forEach(({ rewriter, paths }) => {
paths.forEach(path => {
rewrittenResponse = rewriteResultsAtPath(
rewrittenResponse,
path,
(parentResponse, key, index) => rewriter.rewriteResponse(parentResponse, key, index)
);
this.matches
.reverse()
.forEach(({ rewriter, paths: fieldPaths, allPaths, nodeMatchAndParents }) => {
const paths = rewriter.matchAnyPath ? allPaths : fieldPaths;
paths.forEach(path => {
rewrittenResponse = rewriteResultsAtPath(
rewrittenResponse,
path,
(parentResponse, key, index) =>
rewriter.rewriteResponse(parentResponse, key, index, nodeMatchAndParents)
);
});
});
});
return rewrittenResponse;
}
}
51 changes: 45 additions & 6 deletions src/ast.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ASTNode, DocumentNode, FragmentDefinitionNode, VariableDefinitionNode } from 'graphql';
import {
ArgumentNode,
ASTNode,
DocumentNode,
FragmentDefinitionNode,
Kind,
VariableDefinitionNode
} from 'graphql';
import { pushToArrayAtKey } from './utils';

const ignoreKeys = new Set(['loc']);
Expand Down Expand Up @@ -239,19 +246,51 @@ export const replaceVariableDefinitions = (
};

/**
* return the path that will be returned in the response from from the chain of parents
* Return the path that will be returned in the response from the chain of parents.
* By default this will only build up paths for field nodes, but the anyKind flag allows
* to build paths for any named node.
*
* It also supports aliases.
*/
/** @hidden */
export const extractPath = (parents: ReadonlyArray<ASTNode>): ReadonlyArray<string> => {
export const extractPath = (
parents: ReadonlyArray<ASTNode>,
anyKind?: boolean
): ReadonlyArray<string> => {
const path: string[] = [];
parents.forEach(parent => {
if (parent.kind === 'Field') {
path.push(parent.name.value);
parents.forEach((parent: any) => {
if (parent.kind === 'Field' || anyKind) {
if (parent.alias) {
path.push(parent.alias.value);
} else if (parent.name) {
path.push(parent.name.value);
}
}
});
return path;
};

/**
* return an ArgumentNode with a VariableNode as its value node with matching name.
*/
/** @hidden */
export const astArgVarNode = (argName: string): ArgumentNode => {
return {
kind: Kind.ARGUMENT,
name: {
kind: Kind.NAME,
value: argName
},
value: {
kind: Kind.VARIABLE,
name: {
kind: Kind.NAME,
value: argName
}
}
};
};

/** @hidden */
interface ResultObj {
[key: string]: any;
Expand Down
73 changes: 73 additions & 0 deletions src/rewriters/CustomRewriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { ASTNode } from 'graphql';
import { NodeAndVarDefs } from '../ast';
import Rewriter, { RewriterOpts, Variables } from './Rewriter';

interface CustomRewriterOpts extends RewriterOpts {
matchesFn?: (nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray<ASTNode>) => boolean;
rewriteQueryFn?: (nodeAndVarDefs: NodeAndVarDefs, variables: Variables) => NodeAndVarDefs;
rewriteVariablesFn?: (nodeAndVarDefs: NodeAndVarDefs, variables: Variables) => Variables;
rewriteResponseFn?: (
response: any,
key: string,
index?: number,
nodeMatchAndParents?: ASTNode[]
) => NodeAndVarDefs;
}

/**
* A Custom rewriter with its Rewriter functions received as arguments.
* This Rewriter allows users to write their own rewriter functions.
*/
class CustomRewriter extends Rewriter {
protected matchesFn: (nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray<ASTNode>) => boolean;
protected rewriteQueryFn: (
nodeAndVarDefs: NodeAndVarDefs,
variables: Variables
) => NodeAndVarDefs;
protected rewriteVariablesFn: (nodeAndVarDefs: NodeAndVarDefs, variables: Variables) => Variables;
protected rewriteResponseFn: (
response: any,
key: string,
index?: number,
nodeMatchAndParents?: ASTNode[]
) => NodeAndVarDefs;

constructor(options: CustomRewriterOpts) {
const {
matchesFn,
rewriteQueryFn,
rewriteVariablesFn,
rewriteResponseFn,
matchConditions = [() => true],
...rewriterOpts
} = options;
super({ ...rewriterOpts, matchConditions });
this.matchesFn = matchesFn || super.matches;
this.rewriteQueryFn = rewriteQueryFn || super.rewriteQuery;
this.rewriteVariablesFn = rewriteVariablesFn || super.rewriteVariables;
this.rewriteResponseFn = rewriteResponseFn || super.rewriteResponse;
}

public matches(nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray<ASTNode>): boolean {
return this.matchesFn(nodeAndVarDefs, parents);
}

public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs, variables: Variables) {
return this.rewriteQueryFn(nodeAndVarDefs, variables);
}

public rewriteResponse(
response: any,
key: string,
index?: number,
nodeMatchAndParents?: ASTNode[]
) {
return this.rewriteResponseFn(response, key, index, nodeMatchAndParents);
}

public rewriteVariables(nodeAndVarDefs: NodeAndVarDefs, variables: Variables): Variables {
return this.rewriteVariablesFn(nodeAndVarDefs, variables);
}
}

export default CustomRewriter;
133 changes: 133 additions & 0 deletions src/rewriters/FieldRewriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { ArgumentNode, ASTNode, FieldNode, Kind, SelectionSetNode } from 'graphql';
import { astArgVarNode, NodeAndVarDefs } from '../ast';
import Rewriter, { RewriterOpts, Variables } from './Rewriter';

interface FieldRewriterOpts extends RewriterOpts {
newFieldName?: string;
arguments?: string[];
objectFieldName?: string;
}

/**
* More generic version of ScalarFieldToObjectField rewriter
*/
class FieldRewriter extends Rewriter {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it's doing the same thing as ScalarFieldToObjectFieldRewriter, but with the option of rewriting the field name. If that's correct, would it make sense to add a FieldNameRewriter instead which can rename a field, and then that could be used in series with the ScalarFieldToObjectFieldRewriter rather than trying to duplicate functionality? I'm worried that trying to put too much functionality into a single rewriter will make it confusing what that rewriter is doing.

Copy link
Contributor Author

@AndresPrez AndresPrez Jun 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, in addition to what the ScalarFieldToObjectFieldRewriter does, the FieldRewriter not only also renames the field, but it supports tapping into its arguments too. I guess Im down for the approach of having more limited rewriters. So for that we'd probably need two additional ones: FieldNameRewriter and FieldArgsRewriter.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's already FieldArgNameRewriter and FieldArgTypeRewriter for rewriting args. Would anything else besides those be needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, adding arguments to a Field that has no initial arguments is missing, also argument value coercion I think is also lacking.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FieldArgTypeRewriter should be able to do argument value coercion. What's the use case for adding an arg to a field? adding an argument to a field isn't a breaking change

Copy link
Contributor Author

@AndresPrez AndresPrez Jun 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right FieldArgTypeRewriter has argument coercion.
About the other thing, It shouldn't be a breaking change to add args to a field with no initial args, as its optional. Maybe my use case is somewhat specific, but I used to have a resolver that based on a scalar field it made a totalCount aggregation returning the total number of results. Now I dont have that resolver anymore so I need to convert that scalar field into a object field but also passing a filter argument (there's a test for this in the rewriteField.test.ts file called 'rewrites a scalar field to be a renamed object field with variable arguments and with 1 scalar subfield').

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aah interesting I see what you mean about maybe needing to add an argument to make an old query valid in a new schema. That is a use-case that none of the current rewriters handle currently. Philosophically this library has favored small targeted rewriters for each use-case rather than 1 big one that does most things, but maybe this is a good change actually. For adding arguments, what do you think about making it a map instead of an array? ex arguments: { arg1: "$arg1" } instead of arguments: ['arg1'] to give more control over what the argument value is set to? I can imagine a situation where you might want to set it to a constant or something, like arguments: { perPage: 1 }.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes totally, that sounds even better

protected newFieldName?: string;
protected arguments?: string[];
protected objectFieldName?: string;

constructor(options: FieldRewriterOpts) {
super(options);
this.newFieldName = options.newFieldName;
this.arguments = options.arguments;
this.objectFieldName = options.objectFieldName;
}

public matches(nodeAndVars: NodeAndVarDefs, parents: ASTNode[]): boolean {
if (!super.matches(nodeAndVars, parents)) return false;
const node = nodeAndVars.node as FieldNode;
// if there's the intention of converting the field to a subselection
// make sure there's no subselections on this field
if (node.selectionSet && !!this.objectFieldName) return false;
return true;
}

public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs, variables: Variables) {
const node = nodeAndVarDefs.node as FieldNode;
const { variableDefinitions } = nodeAndVarDefs;
// if there's the intention of converting the field to a subselection
// and there's a subselection already, just return
if (node.selectionSet && !!this.objectFieldName) return nodeAndVarDefs;

// if fieldName is meant to be renamed.
if (this.newFieldName) {
let newName = this.newFieldName;
if (this.newFieldName.includes(':')) {
const [alias, name] = this.newFieldName.split(':');
newName = name.trim();
Object.assign(node, { alias: { value: alias.trim(), kind: Kind.NAME } });
}
Object.assign(node.name, { value: newName });
}

// if there's the intention of converting the field to a subselection
// of objectFieldNames assign SelectionSetNode to the field accordingly.
if (this.objectFieldName) {
const selectionSet: SelectionSetNode = {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: this.objectFieldName }
}
]
};
Object.assign(node, { selectionSet });
}

// If, 1) the field is a SelectionSet,
// 2) this.arguments is not empty nor undefined, and
// 3) query comes with variables, then assign ArgumentNodes to the field accordingly.
if (node.selectionSet && !!this.arguments && variables) {
// field may already come with some arguments
const newArguments: ArgumentNode[] = [...(node.arguments || [])];
this.arguments.forEach(argName => {
if (
this.isArgumentInVariables(argName, variables) &&
!this.isArgumentInArguments(argName, newArguments)
) {
newArguments.push(astArgVarNode(argName));
}
});
if (!!newArguments) Object.assign(node, { arguments: newArguments });
}

return {
variableDefinitions,
node
} as NodeAndVarDefs;
}

public rewriteResponse(response: any, key: string, index?: number) {
// Extract the element we are working on
const element = super.extractReponseElement(response, key, index);
if (element === null) return response;

let originalKey = key;
// if the key is found to be the renamed field
// then change the name of such field in the response
// and pass the new key (field name) down.
if (this.newFieldName) {
let newFieldName = this.newFieldName;
// the newFieldName may be alised.
if (this.newFieldName.includes(':')) {
const [alias] = this.newFieldName.split(':');
newFieldName = alias.trim();
}
if (key === newFieldName) {
if (this.fieldName) {
originalKey = this.fieldName;
Object.assign(response, { [originalKey]: response[key] });
delete response[key];
}
}
}
// Undo the nesting in the response so it matches the original query
let newElement = element;
if (this.objectFieldName) {
newElement = element[this.objectFieldName];
}
return super.rewriteResponseElement(response, newElement, originalKey, index);
}

private isArgumentInArguments(argName: string, argumentNodes: ArgumentNode[]) {
return argumentNodes.map(argNode => argNode.name.value).includes(argName);
}

private isArgumentInVariables(argName: string, variables: Variables): boolean {
if (variables && Object.keys(variables).includes(argName)) return true;
return false;
}
}

export default FieldRewriter;
21 changes: 19 additions & 2 deletions src/rewriters/Rewriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,32 @@ export interface RewriterOpts {
fieldName?: string;
rootTypes?: RootType[];
matchConditions?: matchCondition[];
matchAnyPath?: boolean;
saveNode?: boolean;
}

/**
* Abstract base Rewriter class
* Extend this class and overwrite its methods to create a new rewriter
*/
abstract class Rewriter {
public matchAnyPath: boolean = false;
public saveNode: boolean = false;
protected rootTypes: RootType[] = ['query', 'mutation', 'fragment'];
protected fieldName?: string;
protected matchConditions?: matchCondition[];

constructor({ fieldName, rootTypes, matchConditions }: RewriterOpts) {
constructor({
fieldName,
rootTypes,
matchConditions,
matchAnyPath = false,
saveNode = false
}: RewriterOpts) {
this.fieldName = fieldName;
this.matchConditions = matchConditions;
this.matchAnyPath = matchAnyPath;
this.saveNode = saveNode;
if (!this.fieldName && !this.matchConditions) {
throw new Error(
'Neither a fieldName or matchConditions were provided. Please choose to pass either one in order to be able to detect which fields to rewrite.'
Expand Down Expand Up @@ -74,7 +86,12 @@ abstract class Rewriter {
* Receives the parent object of the matched field with the key of the matched field.
* For arrays, the index of the element is also present.
*/
public rewriteResponse(response: any, key: string, index?: number): any {
public rewriteResponse(
response: any,
key: string,
index?: number,
nodeMatchAndParents?: ASTNode[]
): any {
return response;
}

Expand Down
Loading