Skip to content

Commit d6618f9

Browse files
KorsChenaodun
KorsChen
and
aodun
authored
feat: Automatically control Cache with LRU (#32)
* Automatically control with LRU Co-authored-by: aodun <aodun.czp@alipay.com>
1 parent 9cb9dab commit d6618f9

File tree

8 files changed

+318
-5
lines changed

8 files changed

+318
-5
lines changed

.github/workflows/nodejs.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2+
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3+
4+
name: Node.js CI
5+
6+
on:
7+
push:
8+
branches: [ master ]
9+
pull_request:
10+
branches: [ master ]
11+
12+
jobs:
13+
build:
14+
15+
runs-on: ${{ matrix.os }}
16+
17+
strategy:
18+
matrix:
19+
node-version: [8.x, 9.x]
20+
os: [ubuntu-latest, windows-latest, macos-latest]
21+
22+
steps:
23+
- uses: actions/checkout@v2
24+
- name: Use Node.js ${{ matrix.node-version }}
25+
uses: actions/setup-node@v1
26+
with:
27+
node-version: ${{ matrix.node-version }}
28+
- run: npm i -g npminstall && npminstall
29+
- run: npm run ci
30+
env:
31+
CI: true

.travis.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
sudo: false
1+
22
language: node_js
33
node_js:
44
- '8'
55
- '9'
6+
before_install:
7+
- npm i npminstall -g
68
install:
7-
- npm i npminstall && npminstall
9+
- npminstall
810
script:
911
- npm run ci
1012
after_script:

History.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
2.7.2 / 2020-05-12
2+
==================
3+
4+
* feat: Automatically control Cache with LRU.
15

26
2.7.1 / 2020-05-06
37
==================

app/service/graphql.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22

33
const { execute, formatError } = require('graphql');
4-
const gql = require('graphql-tag');
4+
const gql = require('../../lib/graphql-tag');
55

66
module.exports = app => {
77
class GraphqlService extends app.Service {

lib/graphql-tag.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
'use strict';
2+
3+
const parser = require('graphql/language/parser'),
4+
parse = parser.parse;
5+
6+
// A LRU cache with key: docString and value: graphql document
7+
const LRU = require('lru-cache'),
8+
options = { max: 1000, maxAge: 1000 * 60 * 60 * 24 }, // default 1 day
9+
docCache = LRU(options);
10+
11+
// Strip insignificant whitespace
12+
// Note that this could do a lot more, such as reorder fields etc.
13+
function normalize(string) {
14+
return string.replace(/[\s,]+/g, ' ').trim();
15+
}
16+
17+
// A map fragmentName -> [normalized source]
18+
let fragmentSourceMap = {};
19+
20+
function cacheKeyFromLoc(loc) {
21+
return normalize(loc.source.body.substring(loc.start, loc.end));
22+
}
23+
24+
// set cache option [https://github.com/isaacs/node-lru-cache]
25+
// only support max and maxAge
26+
function setCacheOptions(options) {
27+
if (options) {
28+
const { max, maxAge } = options;
29+
if (max && typeof (max) === 'number') {
30+
docCache.max = max;
31+
}
32+
if (maxAge && typeof (maxAge) === 'number') {
33+
docCache.maxAge = maxAge;
34+
}
35+
}
36+
}
37+
38+
// get cached items count
39+
function getCachedItemsCount() {
40+
return docCache.itemCount;
41+
}
42+
43+
// For testing.
44+
function resetCaches() {
45+
docCache.reset();
46+
fragmentSourceMap = {};
47+
}
48+
49+
// Take a unstripped parsed document (query/mutation or even fragment), and
50+
// check all fragment definitions, checking for name->source uniqueness.
51+
// We also want to make sure only unique fragments exist in the document.
52+
function processFragments(ast) {
53+
const astFragmentMap = {};
54+
const definitions = [];
55+
for (let i = 0; i < ast.definitions.length; i++) {
56+
const fragmentDefinition = ast.definitions[i];
57+
58+
if (fragmentDefinition.kind === 'FragmentDefinition') {
59+
const fragmentName = fragmentDefinition.name.value;
60+
const sourceKey = cacheKeyFromLoc(fragmentDefinition.loc);
61+
62+
// We know something about this fragment
63+
if (fragmentSourceMap.hasOwnProperty(fragmentName) && !fragmentSourceMap[fragmentName][sourceKey]) {
64+
65+
// this is a problem because the app developer is trying to register another fragment with
66+
// the same name as one previously registered. So, we tell them about it.
67+
console.warn('Warning: fragment with name ' + fragmentName + ' already exists.\n'
68+
+ 'graphql-tag enforces all fragment names across your application to be unique; read more about\n'
69+
+ 'this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names');
70+
71+
fragmentSourceMap[fragmentName][sourceKey] = true;
72+
73+
} else if (!fragmentSourceMap.hasOwnProperty(fragmentName)) {
74+
fragmentSourceMap[fragmentName] = {};
75+
fragmentSourceMap[fragmentName][sourceKey] = true;
76+
}
77+
78+
if (!astFragmentMap[sourceKey]) {
79+
astFragmentMap[sourceKey] = true;
80+
definitions.push(fragmentDefinition);
81+
}
82+
} else {
83+
definitions.push(fragmentDefinition);
84+
}
85+
}
86+
87+
ast.definitions = definitions;
88+
return ast;
89+
}
90+
91+
function stripLoc(doc, removeLocAtThisLevel) {
92+
const docType = Object.prototype.toString.call(doc);
93+
if (docType === '[object Array]') {
94+
return doc.map(function(d) {
95+
return stripLoc(d, removeLocAtThisLevel);
96+
});
97+
}
98+
99+
if (docType !== '[object Object]') {
100+
throw new Error('Unexpected input.');
101+
}
102+
103+
// We don't want to remove the root loc field so we can use it
104+
// for fragment substitution (see below)
105+
if (removeLocAtThisLevel && doc.loc) {
106+
delete doc.loc;
107+
}
108+
109+
// https://github.com/apollographql/graphql-tag/issues/40
110+
if (doc.loc) {
111+
delete doc.loc.startToken;
112+
delete doc.loc.endToken;
113+
}
114+
115+
const keys = Object.keys(doc);
116+
let key;
117+
let value;
118+
let valueType;
119+
120+
for (key in keys) {
121+
if (keys.hasOwnProperty(key)) {
122+
value = doc[keys[key]];
123+
valueType = Object.prototype.toString.call(value);
124+
125+
if (valueType === '[object Object]' || valueType === '[object Array]') {
126+
doc[keys[key]] = stripLoc(value, true);
127+
}
128+
}
129+
}
130+
131+
return doc;
132+
}
133+
134+
let experimentalFragmentVariables = false;
135+
function parseDocument(doc) {
136+
const cacheKey = normalize(doc);
137+
const cachedItem = docCache.get(cacheKey);
138+
if (cachedItem) {
139+
return cachedItem;
140+
}
141+
let parsed;
142+
try {
143+
parsed = parse(doc, { experimentalFragmentVariables });
144+
} catch (error) {
145+
throw new Error(error);
146+
}
147+
// check that all "new" fragments inside the documents are consistent with
148+
// existing fragments of the same name
149+
parsed = processFragments(parsed);
150+
parsed = stripLoc(parsed, false);
151+
docCache.set(cacheKey, parsed);
152+
153+
return parsed;
154+
}
155+
156+
function enableExperimentalFragmentVariables() {
157+
experimentalFragmentVariables = true;
158+
}
159+
160+
function disableExperimentalFragmentVariables() {
161+
experimentalFragmentVariables = false;
162+
}
163+
// XXX This should eventually disallow arbitrary string interpolation, like Relay does
164+
function gql(/* arguments */) {
165+
const args = Array.prototype.slice.call(arguments);
166+
const literals = args[0];
167+
168+
// We always get literals[0] and then matching post literals for each arg given
169+
let result = (typeof (literals) === 'string') ? literals : literals[0];
170+
171+
for (let i = 1; i < args.length; i++) {
172+
result += args[i];
173+
result += literals[i];
174+
}
175+
return parseDocument(result);
176+
}
177+
178+
// Support typescript, which isn't as nice as Babel about default exports
179+
gql.default = gql;
180+
gql.resetCaches = resetCaches;
181+
gql.getCachedItemsCount = getCachedItemsCount;
182+
gql.setCacheOptions = setCacheOptions;
183+
gql.enableExperimentalFragmentVariables = enableExperimentalFragmentVariables;
184+
gql.disableExperimentalFragmentVariables = disableExperimentalFragmentVariables;
185+
gql.parseDocument = parseDocument;
186+
gql.stripLoc = stripLoc;
187+
188+
module.exports = gql;

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
"apollo-server-koa": "2.0.4",
1717
"apollo-server-module-graphiql": "1.4.0",
1818
"graphql": "0.13.2",
19-
"graphql-tag": "2.9.2",
2019
"graphql-tools": "3.1.1",
21-
"lodash": "^4.17.10"
20+
"lodash": "^4.17.10",
21+
"lru-cache": "^4.1.2"
2222
},
2323
"devDependencies": {
2424
"autod": "^2.9.0",

test/app/service/graphql.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,62 @@ describe('test/plugin.test.js', () => {
6464
const resp = await ctx.graphql.query(query);
6565
assert.deepEqual(resp.data, { framework: { projects: [] } });
6666
});
67+
68+
it('user operations with fragments', async () => {
69+
const ctx = app.mockContext();
70+
const query = `query {
71+
drumsets: products(product_category_id: 1) {
72+
...ProductCommonFields
73+
prices
74+
}
75+
76+
cymbals: products(product_category_id: 2) {
77+
...ProductCommonFields
78+
}
79+
}
80+
81+
fragment ProductCommonFields on Product {
82+
id
83+
name
84+
price
85+
}`;
86+
const resp = await ctx.graphql.query(JSON.stringify({
87+
query,
88+
}));
89+
assert.deepEqual(resp.data, {});
90+
});
91+
92+
it('query from cache', async () => {
93+
const ctx = app.mockContext();
94+
await ctx.graphql.query(JSON.stringify({
95+
query: '{ user(id: 1) { lowerName } }',
96+
}));
97+
const cache = await ctx.graphql.query(JSON.stringify({
98+
query: '{ user(id: 1) { lowerName } }',
99+
}));
100+
assert.deepEqual(cache.data, { user: { lowerName: 'name1' } });
101+
});
102+
103+
it('user operations with fragments', async () => {
104+
const ctx = app.mockContext();
105+
const query = `query {
106+
drumsets: products(id: 1) {
107+
...ProductCommonFields
108+
prices
109+
}
110+
111+
cymbals: products(id: 2) {
112+
...ProductCommonFields
113+
}
114+
}
115+
116+
fragment ProductCommonFields on Product {
117+
id
118+
}
119+
`;
120+
const resp = await ctx.graphql.query(JSON.stringify({
121+
query,
122+
}));
123+
assert.deepEqual(resp.data, {});
124+
});
67125
});

test/lib/graphql-tag.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use strict';
2+
3+
const assert = require('assert');
4+
const gql = require('../../lib/graphql-tag');
5+
6+
describe('test/graphiql-tag.test.js', () => {
7+
it('should get graphiql html response', async () => {
8+
gql.resetCaches();
9+
gql.getCachedItemsCount();
10+
gql.setCacheOptions({ max: 1000, maxAge: 1000 * 60 * 60 * 24 });
11+
gql.enableExperimentalFragmentVariables();
12+
gql.disableExperimentalFragmentVariables();
13+
});
14+
15+
it('stripLoc input error', async () => {
16+
try {
17+
gql.stripLoc('');
18+
} catch (error) {
19+
assert(error);
20+
}
21+
});
22+
23+
it('Not a valid GraphQL document', async () => {
24+
try {
25+
gql.parseDocument('');
26+
} catch (error) {
27+
assert(error);
28+
}
29+
});
30+
});

0 commit comments

Comments
 (0)