Skip to content

Commit

Permalink
Merge 7237427 into 3a8fbdd
Browse files Browse the repository at this point in the history
  • Loading branch information
Mikhus committed Mar 1, 2019
2 parents 3a8fbdd + 7237427 commit 778fb63
Show file tree
Hide file tree
Showing 3 changed files with 254 additions and 9 deletions.
56 changes: 55 additions & 1 deletion README.md
Expand Up @@ -283,6 +283,60 @@ projection = {
*/
```

**Since version 2.1.0**

It supports `skip` option to filter output of `fieldsList()`, `fieldsMap()` and
`fieldsProjection()` functions.

[See motivation](https://github.com/Mikhus/graphql-fields-list/issues/4)

Skip option accepts an array of field projections to skip. It allows usage
of wildcard symbol `*` within field names. Please, note, that skip occurs
before transformations, so it should reflect original field names,
transformations would be applied after skip is done.

Typical usage as:

```javascript
const map = fieldsMap(info, { skip: [
'users.pageInfo.*',
'users.edges.node.email',
'users.edges.node.address',
'users.edges.node.*Name',
]});
/*
RESULT:
map = {
users: {
edges: {
node: {
id: false,
phoneNumber: false,
},
},
},
}
*/
const projection = fieldsProjection(info, {
skip: [
'users.pageInfo.*',
'users.edges.node.email',
'users.edges.node.address',
'users.edges.node.*Name',
],
transform: {
'users.edges.node.id': 'users.edges.node._id',
},
});
/*
RESULT:
projection = {
'users.edges.node._id': 1,
'users.edges.node.phoneNumber': 1,
};
*/
```

## License

[ISC Licence](LICENSE)
[ISC Licence](LICENSE)
134 changes: 126 additions & 8 deletions index.ts
Expand Up @@ -24,6 +24,13 @@ import {
FieldNode,
} from 'graphql';

/**
* Pre-compiled wildcard replacement regexp
*
* @type {RegExp}
*/
const RX_AST = /\*/g;

/**
* Fragment item type
*
Expand All @@ -48,9 +55,44 @@ export interface FieldNamesMap {
* @access public
*/
export interface FieldsListOptions {
/**
* Path to a tree branch which should be mapped during fields extraction
* @type {string}
*/
path?: string;

/**
* Transformation rules which should be used to re-name field names
* @type {FieldNamesMap}
*/
transform?: FieldNamesMap;

/**
* Flag which turns on/off GraphQL directives checks on a fields
* and take them into account during fields analysis
* @type {boolean}
*/
withDirectives?: boolean;

/**
* Fields skip rule patterns. Usually used to ignore part of request field
* subtree. For example if query looks like:
* profiles {
* id
* users {
* name
* email
* password
* }
* }
* and you doo n not care about users, it can be done like:
* fieldsList(info, { skip: ['users'] }); // or
* fieldsProjection(info, { skip: ['users.*'] }); // more obvious notation
*
* If you want to skip only exact fields, it can be done as:
* fieldsMap(info, { skip: ['users.email', 'users.password'] })
*/
skip?: string[];
}

/**
Expand Down Expand Up @@ -202,59 +244,133 @@ function verifyDirectives(
*
* @param {SelectionNode} node
* @param {*} root
* @param {*} skip
* @param {TraverseOptions} opts
*/
function verifyInlineFragment(
node: SelectionNode,
root: any,
opts: TraverseOptions,
skip: any,
) {
if (node.kind === 'InlineFragment') {
const nodes = getNodes(node);

nodes.length && traverse(nodes, root, opts);
nodes.length && traverse(nodes, root, opts, skip);

return true;
}

return false;
}

/**
* Builds skip rules tree from a given skip option argument
*
* @param {string[]} skip - skip option arguments
* @return {any} - skip rules tree
*/
function skipTree(skip: string[]) {
const tree: any = {};

for (const pattern of skip) {
const props = pattern.split('.');
let propTree = tree;

for (let i = 0, s = props.length; i < s; i++) {
const prop = props[i];
const all = props[i + 1] === '*';

if (!propTree[prop]) {
propTree[prop] = i === s - 1 || all ? true : {};
all && i++;
}

propTree = propTree[prop];
}
}

return tree;
}

/**
*
* @param node
* @param skip
*/
function verifySkip(node: string, skip: any) {
if (!skip) {
return false;
}

if (skip[node]) {
return skip[node];
}

// lookup through wildcard patterns
let nodeTree: any = false;
const patterns = Object.keys(skip).filter(pattern => ~pattern.indexOf('*'));

for (const pattern of patterns) {
const rx: RegExp = new RegExp(pattern.replace(RX_AST, '.*'));

if (rx.test(node)) {
nodeTree = skip[pattern];

if (nodeTree === true) {
break;
}
}
}

return nodeTree;
}

/**
* Traverses recursively given nodes and fills-up given root tree with
* a requested field names
*
* @param {ReadonlyArray<FieldNode>} nodes
* @param {*} root
* @param {TraverseOptions} opts
* @param {*} skip
* @return {*}
* @access private
*/
function traverse(
nodes: ReadonlyArray<SelectionNode>,
root: any,
opts: TraverseOptions,
) {
skip: any,
): any {
for (const node of nodes) {
if (opts.withVars && !verifyDirectives(node.directives, opts.vars)) {
continue;
}

if (verifyInlineFragment(node, root, opts)) {
if (verifyInlineFragment(node, root, opts, skip)) {
continue;
}

const name = (node as FieldNode).name.value;

if (opts.fragments[name]) {
traverse(getNodes(opts.fragments[name]), root, opts);
traverse(getNodes(opts.fragments[name]), root, opts, skip);
continue;
}

const nodes = getNodes(node);

root[name] = root[name] || (nodes.length ? {} : false);
nodes.length && traverse(nodes, root[name], opts);
const nodeSkip = verifySkip(name, skip);

if (nodeSkip !== true) {
root[name] = root[name] || (nodes.length ? {} : false);
nodes.length && traverse(
nodes,
root[name],
opts,
nodeSkip,
);
}
}

return root;
Expand Down Expand Up @@ -364,12 +480,13 @@ export function fieldsMap(
return {};
}

const { path, withDirectives } = parseOptions(options);
const { path, withDirectives, skip } = parseOptions(options);
const tree = traverse(getNodes(fieldNode), {}, {
fragments: info.fragments,
vars: info.variableValues,
withVars: withDirectives,
},
skipTree(skip || []),
);

return getBranch(tree, path);
Expand Down Expand Up @@ -466,6 +583,7 @@ if (process.env['IS_UNIT_TEST']) {
verifyInfo,
verifyFieldNode,
verifyInlineFragment,
verifySkip,
parseOptions,
toDotNotation,
});
Expand Down
73 changes: 73 additions & 0 deletions test/index.ts
Expand Up @@ -169,6 +169,23 @@ describe('module "graphql-fields-list"', () => {
'users.edges.node.address': 1,
});
});

it('should properly skip configured fields', () => {
expect(fieldsProjection(info, {
skip: [
'users.pageInfo.*',
'users.edges.node.email',
'users.edges.node.address',
'users.edges.node.*Name',
],
transform: {
'users.edges.node.id': 'users.edges.node._id',
},
})).deep.equals({
'users.edges.node._id': 1,
'users.edges.node.phoneNumber': 1,
});
})
});

describe('@public: fieldsList()', () => {
Expand Down Expand Up @@ -300,6 +317,62 @@ describe('module "graphql-fields-list"', () => {
withDirectives: false
}));
});

it('should properly skip configured fields', () => {
expect(fieldsMap(info, { skip: ['users.pageInfo'] }))
.deep.equals({
users: {
edges: {
node: {
id: false,
firstName: false,
lastName: false,
phoneNumber: false,
email: false,
address: false,
},
},
},
});
expect(fieldsMap(info, { skip: [
'users.pageInfo.hasNextPage',
'users.edges.node.email',
'users.edges.node.address',
]})).deep.equals({
users: {
pageInfo: {
startCursor: false,
endCursor: false,
},
edges: {
node: {
id: false,
firstName: false,
lastName: false,
phoneNumber: false,
},
},
},
});
});

it('should properly skip configured fields having wildcards', () => {
expect(fieldsMap(info, { skip: [
'users.pageInfo.*',
'users.edges.node.email',
'users.edges.node.address',
'users.edges.node.*Name',
]})).deep.equals({
users: {
edges: {
node: {
id: false,
phoneNumber: false,
},
},
},
});
});
});

describe('@private: getNodes()', () => {
Expand Down

0 comments on commit 778fb63

Please sign in to comment.