Skip to content

Commit dd6a755

Browse files
Jonas MeierPatrick Burger
Jonas Meier
authored and
Patrick Burger
committed
fix: Always use lean Mongoose documents
With hydrated Mongoose documents they aren't serializable. Therefore caching can't be used, as it serializes the documents.
1 parent 84824a7 commit dd6a755

File tree

9 files changed

+6217
-43
lines changed

9 files changed

+6217
-43
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
node_modules/
2-
dist/
2+
# dist/

dist/cache.js

+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"use strict";
2+
3+
Object.defineProperty(exports, "__esModule", {
4+
value: true
5+
});
6+
exports.createCachingMethods = void 0;
7+
exports.getNestedValue = getNestedValue;
8+
exports.isValidObjectIdString = exports.idToString = void 0;
9+
exports.prepFields = prepFields;
10+
exports.stringToId = void 0;
11+
var _dataloader = _interopRequireDefault(require("dataloader"));
12+
var _mongodb = require("mongodb");
13+
var _bson = require("bson");
14+
var _helpers = require("./helpers");
15+
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
16+
const idToString = id => {
17+
if (id instanceof _mongodb.ObjectId) {
18+
return id.toHexString();
19+
} else {
20+
return id && id.toString ? id.toString() : id;
21+
}
22+
};
23+
24+
// https://www.geeksforgeeks.org/how-to-check-if-a-string-is-valid-mongodb-objectid-in-nodejs/
25+
exports.idToString = idToString;
26+
const isValidObjectIdString = string => _mongodb.ObjectId.isValid(string) && String(new _mongodb.ObjectId(string)) === string;
27+
exports.isValidObjectIdString = isValidObjectIdString;
28+
const stringToId = string => {
29+
if (string instanceof _mongodb.ObjectId) {
30+
return string;
31+
}
32+
if (isValidObjectIdString(string)) {
33+
return new _mongodb.ObjectId(string);
34+
}
35+
return string;
36+
};
37+
exports.stringToId = stringToId;
38+
function prepFields(fields) {
39+
const cleanedFields = {};
40+
Object.keys(fields).sort().forEach(key => {
41+
if (typeof key !== 'undefined') {
42+
cleanedFields[key] = Array.isArray(fields[key]) ? fields[key] : [fields[key]];
43+
}
44+
});
45+
return {
46+
loaderKey: _bson.EJSON.stringify(cleanedFields),
47+
cleanedFields
48+
};
49+
}
50+
51+
// getNestedValue({ nested: { foo: 'bar' } }, 'nested.foo')
52+
// => 'bar'
53+
function getNestedValue(object, string) {
54+
string = string.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
55+
string = string.replace(/^\./, ''); // strip a leading dot
56+
var a = string.split('.');
57+
for (var i = 0, n = a.length; i < n; ++i) {
58+
var k = a[i];
59+
if (k in object) {
60+
object = object[k];
61+
} else {
62+
return;
63+
}
64+
}
65+
return object;
66+
}
67+
68+
// https://github.com/graphql/dataloader#batch-function
69+
// "The Array of values must be the same length as the Array of keys."
70+
// "Each index in the Array of values must correspond to the same index in the Array of keys."
71+
const orderDocs = (fieldsArray, docs) => fieldsArray.map(fields => docs.filter(doc => {
72+
for (let fieldName of Object.keys(fields)) {
73+
const fieldValue = getNestedValue(fields, fieldName);
74+
if (typeof fieldValue === 'undefined') continue;
75+
const filterValuesArr = Array.isArray(fieldValue) ? fieldValue.map(val => idToString(val)) : [idToString(fieldValue)];
76+
const docValue = doc[fieldName];
77+
const docValuesArr = Array.isArray(docValue) ? docValue.map(val => idToString(val)) : [idToString(docValue)];
78+
let isMatch = false;
79+
for (const filterVal of filterValuesArr) {
80+
if (docValuesArr.includes(filterVal)) {
81+
isMatch = true;
82+
}
83+
}
84+
if (!isMatch) return false;
85+
}
86+
return true;
87+
}));
88+
const createCachingMethods = ({
89+
collection,
90+
model,
91+
cache
92+
}) => {
93+
const loader = new _dataloader.default(async ejsonArray => {
94+
const fieldsArray = ejsonArray.map(_bson.EJSON.parse);
95+
(0, _helpers.log)('fieldsArray', fieldsArray);
96+
const filterArray = fieldsArray.reduce((filterArray, fields) => {
97+
const existingFieldsFilter = filterArray.find(filter => [...Object.keys(filter)].sort().join() === [...Object.keys(fields)].sort().join());
98+
const filter = existingFieldsFilter || {};
99+
for (const fieldName in fields) {
100+
if (typeof fields[fieldName] === 'undefined') continue;
101+
if (!filter[fieldName]) filter[fieldName] = {
102+
$in: []
103+
};
104+
let newVals = Array.isArray(fields[fieldName]) ? fields[fieldName] : [fields[fieldName]];
105+
filter[fieldName].$in = [...filter[fieldName].$in, ...newVals.map(stringToId).filter(val => !filter[fieldName].$in.includes(val))];
106+
}
107+
if (existingFieldsFilter) return filterArray;
108+
return [...filterArray, filter];
109+
}, []);
110+
(0, _helpers.log)('filterArray: ', filterArray);
111+
const filter = filterArray.length === 1 ? filterArray[0] : {
112+
$or: filterArray
113+
};
114+
(0, _helpers.log)('filter: ', filter);
115+
const findPromise = model ? model.find(filter).lean({
116+
defaults: true
117+
}).exec() : collection.find(filter).toArray();
118+
const results = await findPromise;
119+
(0, _helpers.log)('results: ', results);
120+
const orderedDocs = orderDocs(fieldsArray, results);
121+
(0, _helpers.log)('orderedDocs: ', orderedDocs);
122+
return orderedDocs;
123+
});
124+
const cachePrefix = `mongo-${(0, _helpers.getCollection)(collection).collectionName}-`;
125+
const methods = {
126+
findOneById: async (_id, {
127+
ttl
128+
} = {}) => {
129+
const cacheKey = cachePrefix + idToString(_id);
130+
const cacheDoc = await cache.get(cacheKey);
131+
(0, _helpers.log)('findOneById found in cache:', cacheDoc);
132+
if (cacheDoc) {
133+
return _bson.EJSON.parse(cacheDoc);
134+
}
135+
(0, _helpers.log)(`Dataloader.load: ${_bson.EJSON.stringify({
136+
_id
137+
})}`);
138+
const docs = await loader.load(_bson.EJSON.stringify({
139+
_id
140+
}));
141+
(0, _helpers.log)('Dataloader.load returned: ', docs);
142+
if (Number.isInteger(ttl)) {
143+
// https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-caching#apollo-server-caching
144+
cache.set(cacheKey, _bson.EJSON.stringify(docs[0]), {
145+
ttl
146+
});
147+
}
148+
return docs[0];
149+
},
150+
findManyByIds: (ids, {
151+
ttl
152+
} = {}) => {
153+
return Promise.all(ids.map(id => methods.findOneById(id, {
154+
ttl
155+
})));
156+
},
157+
findByFields: async (fields, {
158+
ttl
159+
} = {}) => {
160+
const {
161+
cleanedFields,
162+
loaderKey
163+
} = prepFields(fields);
164+
const cacheKey = cachePrefix + loaderKey;
165+
const cacheDoc = await cache.get(cacheKey);
166+
if (cacheDoc) {
167+
return _bson.EJSON.parse(cacheDoc);
168+
}
169+
const fieldNames = Object.keys(cleanedFields);
170+
let docs;
171+
if (fieldNames.length === 1) {
172+
const field = cleanedFields[fieldNames[0]];
173+
const fieldArray = Array.isArray(field) ? field : [field];
174+
const docsArray = await Promise.all(fieldArray.map(value => {
175+
const filter = {};
176+
filter[fieldNames[0]] = value;
177+
return loader.load(_bson.EJSON.stringify(filter));
178+
}));
179+
docs = [].concat(...docsArray);
180+
} else {
181+
docs = await loader.load(loaderKey);
182+
}
183+
if (Number.isInteger(ttl)) {
184+
// https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-caching#apollo-server-caching
185+
cache.set(cacheKey, _bson.EJSON.stringify(docs), {
186+
ttl
187+
});
188+
}
189+
return docs;
190+
},
191+
deleteFromCacheById: async _id => {
192+
loader.clear(_bson.EJSON.stringify({
193+
_id
194+
}));
195+
const cacheKey = cachePrefix + idToString(_id);
196+
(0, _helpers.log)('Deleting cache key: ', cacheKey);
197+
await cache.delete(cacheKey);
198+
},
199+
deleteFromCacheByFields: async fields => {
200+
const {
201+
loaderKey
202+
} = prepFields(fields);
203+
const cacheKey = cachePrefix + loaderKey;
204+
loader.clear(loaderKey);
205+
await cache.delete(cacheKey);
206+
}
207+
};
208+
return methods;
209+
};
210+
exports.createCachingMethods = createCachingMethods;

dist/datasource.js

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"use strict";
2+
3+
Object.defineProperty(exports, "__esModule", {
4+
value: true
5+
});
6+
exports.MongoDataSource = void 0;
7+
var _graphql = require("graphql");
8+
var _utils = require("@apollo/utils.keyvaluecache");
9+
var _cache = require("./cache");
10+
var _helpers = require("./helpers");
11+
class MongoDataSource {
12+
constructor({
13+
modelOrCollection,
14+
cache
15+
}) {
16+
if (!(0, _helpers.isCollectionOrModel)(modelOrCollection)) {
17+
throw new _graphql.GraphQLError('MongoDataSource constructor must be given a collection or Mongoose model');
18+
}
19+
if ((0, _helpers.isModel)(modelOrCollection)) {
20+
this.model = modelOrCollection;
21+
this.collection = this.model.collection;
22+
} else {
23+
this.collection = modelOrCollection;
24+
}
25+
const methods = (0, _cache.createCachingMethods)({
26+
collection: this.collection,
27+
model: this.model,
28+
cache: cache || new _utils.InMemoryLRUCache()
29+
});
30+
Object.assign(this, methods);
31+
}
32+
}
33+
exports.MongoDataSource = MongoDataSource;

dist/helpers.js

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"use strict";
2+
3+
Object.defineProperty(exports, "__esModule", {
4+
value: true
5+
});
6+
exports.log = exports.isModel = exports.isCollectionOrModel = exports.getCollection = void 0;
7+
const TYPEOF_COLLECTION = 'object';
8+
const isModel = x => Boolean(typeof x === 'function' && x.prototype &&
9+
/**
10+
* @see https://github.com/Automattic/mongoose/blob/b4e0ae52a57b886bc7046d38332ce3b38a2f9acd/lib/model.js#L116
11+
*/
12+
x.prototype.$isMongooseModelPrototype);
13+
exports.isModel = isModel;
14+
const isCollectionOrModel = x => Boolean(x && (typeof x === TYPEOF_COLLECTION || isModel(x)));
15+
exports.isCollectionOrModel = isCollectionOrModel;
16+
const getCollection = x => isModel(x) ? x.collection : x;
17+
exports.getCollection = getCollection;
18+
const DEBUG = false;
19+
const log = (...args) => DEBUG && console.log(...args);
20+
exports.log = log;

dist/index.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"use strict";
2+
3+
Object.defineProperty(exports, "__esModule", {
4+
value: true
5+
});
6+
Object.defineProperty(exports, "MongoDataSource", {
7+
enumerable: true,
8+
get: function () {
9+
return _datasource.MongoDataSource;
10+
}
11+
});
12+
var _datasource = require("./datasource");

index.d.ts

+11-8
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ declare module 'apollo-datasource-mongodb' {
55
Collection as MongooseCollection,
66
Document,
77
Model as MongooseModel,
8+
LeanDocument,
89
} from 'mongoose'
910

1011
export type Collection<T extends { [key: string]: any }, U = MongoCollection<T>> = T extends Document
@@ -28,6 +29,8 @@ declare module 'apollo-datasource-mongodb' {
2829
| (string | number | boolean | ObjectId)[]
2930
}
3031

32+
type MongooseDocumentOrMongoCollection<T> = MongoCollection<T> | Document
33+
3134
export interface Options {
3235
ttl: number
3336
}
@@ -43,22 +46,22 @@ declare module 'apollo-datasource-mongodb' {
4346

4447
constructor(options: MongoDataSourceConfig<TData>)
4548

46-
findOneById(
49+
protected findOneById(
4750
id: ObjectId | string,
4851
options?: Options
49-
): Promise<TData | null | undefined>
52+
): Promise<LeanDocument<TData> | null>
5053

51-
findManyByIds(
54+
protected findManyByIds(
5255
ids: (ObjectId | string)[],
5356
options?: Options
54-
): Promise<(TData | null | undefined)[]>
57+
): Promise<(LeanDocument<TData> | null)[]>
5558

56-
findByFields(
59+
protected findByFields(
5760
fields: Fields,
5861
options?: Options
59-
): Promise<(TData | null | undefined)[]>
62+
): Promise<(LeanDocument<TData> | null)[]>
6063

61-
deleteFromCacheById(id: ObjectId | string): Promise<void>
62-
deleteFromCacheByFields(fields: Fields): Promise<void>
64+
protected deleteFromCacheById(id: ObjectId | string): Promise<void>
65+
protected deleteFromCacheByFields(fields: Fields): Promise<void>
6366
}
6467
}

0 commit comments

Comments
 (0)