diff --git a/.npm/package/npm-shrinkwrap.json b/.npm/package/npm-shrinkwrap.json index 9c856afa..b2eb3bff 100644 --- a/.npm/package/npm-shrinkwrap.json +++ b/.npm/package/npm-shrinkwrap.json @@ -21,6 +21,11 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, + "deep-extend": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.5.0.tgz", + "integrity": "sha1-bvSgmwX5iw41jW2T1Mo8rsZnKAM=" + }, "dot-object": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/dot-object/-/dot-object-1.5.4.tgz", diff --git a/lib/createQuery.js b/lib/createQuery.js new file mode 100644 index 00000000..0f55d7a8 --- /dev/null +++ b/lib/createQuery.js @@ -0,0 +1,67 @@ +import Query from './query/query.js'; +import NamedQuery from './namedQuery/namedQuery.js'; +import NamedQueryStore from './namedQuery/store.js'; + +/** + * This is a polymorphic function, it allows you to create a query as an object + * or it also allows you to re-use an existing query if it's a named one + * + * @param args + * @returns {*} + */ +export default (...args) => { + if (typeof args[0] === 'string') { + let [name, body, options] = args; + options = options || {}; + + // It's a resolver query + if (_.isFunction(body)) { + return createNamedQuery(name, null, body, options); + } + + const entryPointName = _.first(_.keys(body)); + const collection = Mongo.Collection.get(entryPointName); + + if (!collection) { + throw new Meteor.Error('invalid-name', `We could not find any collection with the name "${entryPointName}". Make sure it is imported prior to using this`) + } + + return createNamedQuery(name, collection, body[entryPointName], options); + } else { + // Query Creation, it can have an endpoint as collection or as a NamedQuery + let [body, options] = args; + options = options || {}; + + const entryPointName = _.first(_.keys(body)); + const collection = Mongo.Collection.get(entryPointName); + + if (!collection) { + if (Meteor.isDevelopment && !NamedQueryStore.get(entryPointName)) { + console.warn(`You are creating a query with the entry point "${entryPointName}", but there was no collection found for it (maybe you forgot to import it client-side?). It's assumed that it's referencing a NamedQuery.`) + } + + return createNamedQuery(entryPointName, null, {}, {params: body[entryPointName]}); + } else { + return createNormalQuery(collection, body[entryPointName], options); + } + } +} + +function createNamedQuery(name, collection, body, options = {}) { + // if it exists already, we re-use it + const namedQuery = NamedQueryStore.get(name); + let query; + + if (!namedQuery) { + query = new NamedQuery(name, collection, body, options); + NamedQueryStore.add(name, query); + } else { + query = namedQuery.clone(options.params); + } + + return query; +} + +function createNormalQuery(collection, body, options) { + return new Query(collection, body, options); +} diff --git a/lib/documentor/index.js b/lib/documentor/index.js deleted file mode 100644 index 7e1707a8..00000000 --- a/lib/documentor/index.js +++ /dev/null @@ -1,109 +0,0 @@ -import { linkStorage } from '../links/symbols.js'; -import NamedQueryStore from '../namedQuery/store'; -import deepClone from 'lodash.clonedeep'; - -export default function extract() { - return { - namedQueries: extractNamedQueryDocumentation(), - collections: extractCollectionDocumentation() - } -}; - -function extractNamedQueryDocumentation() { - const namedQueries = NamedQueryStore.getAll(); - - let DocumentationObject = {}; - - _.each(namedQueries, namedQuery => { - DocumentationObject[namedQuery.queryName] = { - body: namedQuery.body, - collection: namedQuery.collection._name, - isExposed: namedQuery.isExposed, - paramsSchema: (namedQuery.exposeConfig.schema) - ? - formatSchemaType( - deepClone(namedQuery.exposeConfig.schema) - ) - : null - }; - }); - - return DocumentationObject; -} - -function extractCollectionDocumentation() { - const collections = Mongo.Collection.getAll(); - let DocumentationObject = {}; - - _.each(collections, ({name, instance}) => { - if (name.substr(0, 7) == 'meteor_') { - return; - } - - DocumentationObject[name] = {}; - var isExposed = !!instance.__isExposedForGrapher; - DocumentationObject[name]['isExposed'] = isExposed; - - if (isExposed && instance.__exposure.config.body) { - DocumentationObject[name]['exposureBody'] = deepClone(instance.__exposure.config.body); - } - - extractSchema(DocumentationObject[name], instance); - extractLinks(DocumentationObject[name], instance); - extractReducers(DocumentationObject[name], instance); - }); - - return DocumentationObject; -} - - -function extractSchema(storage, collection) { - storage.schema = {}; - - if (collection.simpleSchema && collection.simpleSchema()) { - storage.schema = deepClone(collection.simpleSchema()._schema); - - formatSchemaType(storage.schema); - } -} - -function extractReducers(storage, collection) { - storage.reducers = {}; - - if (collection.__reducers) { - _.each(collection.__reducers, (value, key) => { - storage.reducers[key] = { - body: deepClone(value.body) - } - }) - } -} - -function formatSchemaType(schema) { - _.each(schema, (value, key) => { - if (value.type && value.type.name) { - value.type = value.type.name; - } - }); - - return schema; -} - -function extractLinks(storage, collection) { - storage.links = {}; - const collectionLinkStorage = collection[linkStorage]; - - _.each(collectionLinkStorage, (linker, name) => { - storage.links[name] = { - collection: !linker.isResolver() ? linker.getLinkedCollection()._name : null, - strategy: linker.strategy, - metadata: linker.linkConfig.metadata, - isVirtual: linker.isVirtual(), - inversedBy: linker.linkConfig.inversedBy, - isResolver: linker.isResolver(), - resolverFunction: linker.isResolver() ? linker.linkConfig.resolve.toString() : null, - isOneResult: linker.isOneResult(), - linkStorageField: linker.linkStorageField - } - }) -} \ No newline at end of file diff --git a/lib/exposure/exposure.config.schema.js b/lib/exposure/exposure.config.schema.js index cc50c4ef..5dc9633b 100644 --- a/lib/exposure/exposure.config.schema.js +++ b/lib/exposure/exposure.config.schema.js @@ -8,7 +8,9 @@ export const ExposureDefaults = { }; export const ExposureSchema = { - firewall: Match.Maybe(Function), + firewall: Match.Maybe( + Match.OneOf(Function, [Function]) + ), maxLimit: Match.Maybe(Match.Integer), maxDepth: Match.Maybe(Match.Integer), publication: Match.Maybe(Boolean), diff --git a/lib/exposure/exposure.js b/lib/exposure/exposure.js index bfbe1816..8e0a5ca8 100644 --- a/lib/exposure/exposure.js +++ b/lib/exposure/exposure.js @@ -215,9 +215,7 @@ export default class Exposure { collection.firewall = (filters, options, userId) => { if (userId !== undefined) { - if (firewall) { - firewall.call({collection: collection}, filters, options, userId); - } + this._callFirewall({collection: collection}, filters, options, userId); enforceMaxLimit(options, maxLimit); @@ -257,4 +255,22 @@ export default class Exposure { return findOne(filters, options); } } + + /** + * @private + */ + _callFirewall(...args) { + const {firewall} = this.config; + if (!firewall) { + return; + } + + if (_.isArray(firewall)) { + firewall.forEach(fire => { + fire.call(...args); + }) + } else { + firewall.call(...args); + } + } }; diff --git a/lib/extension.js b/lib/extension.js new file mode 100644 index 00000000..3ae105a7 --- /dev/null +++ b/lib/extension.js @@ -0,0 +1,20 @@ +import Query from './query/query.js'; +import NamedQuery from './namedQuery/namedQuery.js'; +import NamedQueryStore from './namedQuery/store.js'; + +_.extend(Mongo.Collection.prototype, { + createQuery(...args) { + if (typeof args[0] === 'string') { + //NamedQuery + const [name, body, options] = args; + const query = new NamedQuery(name, this, body, options); + NamedQueryStore.add(name, query); + + return query; + } else { + const [body, params] = args; + + return new Query(this, body, params); + } + } +}); \ No newline at end of file diff --git a/lib/links/config.schema.js b/lib/links/config.schema.js index d27cc91e..c69ed92f 100644 --- a/lib/links/config.schema.js +++ b/lib/links/config.schema.js @@ -1,7 +1,7 @@ import {Match} from 'meteor/check'; import {Mongo} from 'meteor/mongo'; -export const CacheSchema = { +export const DenormalizeSchema = { field: String, body: Object, bypassSchema: Match.Maybe(Boolean) @@ -23,5 +23,5 @@ export const LinkConfigSchema = { index: Match.Maybe(Boolean), unique: Match.Maybe(Boolean), autoremove: Match.Maybe(Boolean), - cache: Match.Maybe(Match.ObjectIncluding(CacheSchema)), + denormalize: Match.Maybe(Match.ObjectIncluding(DenormalizeSchema)), }; \ No newline at end of file diff --git a/lib/links/constants.js b/lib/links/constants.js new file mode 100644 index 00000000..9c5077bc --- /dev/null +++ b/lib/links/constants.js @@ -0,0 +1,3 @@ +export default { + LINK_STORAGE: '__links' +}; diff --git a/lib/links/extension.js b/lib/links/extension.js index d6005cb4..ef0c545e 100644 --- a/lib/links/extension.js +++ b/lib/links/extension.js @@ -1,5 +1,5 @@ import { Mongo } from 'meteor/mongo'; -import { linkStorage } from './symbols.js'; +import {LINK_STORAGE} from './constants.js'; import Linker from './linker.js'; _.extend(Mongo.Collection.prototype, { @@ -7,43 +7,43 @@ _.extend(Mongo.Collection.prototype, { * The data we add should be valid for config.schema.js */ addLinks(data) { - if (!this[linkStorage]) { - this[linkStorage] = {}; + if (!this[LINK_STORAGE]) { + this[LINK_STORAGE] = {}; } _.each(data, (linkConfig, linkName) => { - if (this[linkStorage][linkName]) { + if (this[LINK_STORAGE][linkName]) { throw new Meteor.Error(`You cannot add the link with name: ${linkName} because it was already added to ${this._name} collection`) } const linker = new Linker(this, linkName, linkConfig); - _.extend(this[linkStorage], { + _.extend(this[LINK_STORAGE], { [linkName]: linker }); }); }, getLinks() { - return this[linkStorage]; + return this[LINK_STORAGE]; }, getLinker(name) { - if (this[linkStorage]) { - return this[linkStorage][name]; + if (this[LINK_STORAGE]) { + return this[LINK_STORAGE][name]; } }, hasLink(name) { - if (!this[linkStorage]) { + if (!this[LINK_STORAGE]) { return false; } - return !!this[linkStorage][name]; + return !!this[LINK_STORAGE][name]; }, getLink(objectOrId, name) { - let linkData = this[linkStorage]; + let linkData = this[LINK_STORAGE]; if (!linkData) { throw new Meteor.Error(`There are no links defined for collection: ${this._name}`); diff --git a/lib/links/linker.js b/lib/links/linker.js index 76c1d237..2a8a8fb9 100644 --- a/lib/links/linker.js +++ b/lib/links/linker.js @@ -26,7 +26,7 @@ export default class Linker { // initialize cascade removal hooks. this._initAutoremove(); - this._initCache(); + this._initDenormalization(); if (this.isVirtual()) { // if it's a virtual field make sure that when this is deleted, it will be removed from the references @@ -381,8 +381,12 @@ export default class Linker { } } - _initCache() { - if (!this.linkConfig.cache || !Meteor.isServer) { + /** + * Initializes denormalization using herteby:denormalize + * @private + */ + _initDenormalization() { + if (!this.linkConfig.denormalize || !Meteor.isServer) { return; } @@ -391,7 +395,7 @@ export default class Linker { throw new Meteor.Error('missing-package', `Please add the herteby:denormalize package to your Meteor application in order to make caching work`) } - const {field, body, bypassSchema} = this.linkConfig.cache; + const {field, body, bypassSchema} = this.linkConfig.denormalize; let cacheConfig; let referenceFieldSuffix = ''; @@ -427,13 +431,13 @@ export default class Linker { } /** - * Verifies if this linker is cached. It can be cached from the inverse side as well. + * Verifies if this linker is denormalized. It can be denormalized from the inverse side as well. * * @returns {boolean} * @private */ - isCached() { - return !!this.linkConfig.cache; + isDenormalized() { + return !!this.linkConfig.denormalize; } /** @@ -443,8 +447,8 @@ export default class Linker { * @returns {boolean} * @private */ - isSubBodyCache(body) { - const cacheBody = this.linkConfig.cache.body; + isSubBodyDenormalized(body) { + const cacheBody = this.linkConfig.denormalize.body; const cacheBodyFields = _.keys(dot.dot(cacheBody)); const bodyFields = _.keys(dot.dot(body)); diff --git a/lib/links/symbols.js b/lib/links/symbols.js deleted file mode 100644 index d972a8a7..00000000 --- a/lib/links/symbols.js +++ /dev/null @@ -1,3 +0,0 @@ -const linkStorage = Symbol('linkStorage'); - -export {linkStorage} \ No newline at end of file diff --git a/lib/namedQuery/expose/extension.js b/lib/namedQuery/expose/extension.js index 90a49b89..cd20d9cf 100644 --- a/lib/namedQuery/expose/extension.js +++ b/lib/namedQuery/expose/extension.js @@ -8,9 +8,10 @@ import deepClone from 'lodash.clonedeep'; import genCountEndpoint from '../../query/counts/genEndpoint.server'; import {check} from 'meteor/check'; -const specialParameters = ['$body']; - _.extend(NamedQuery.prototype, { + /** + * @param config + */ expose(config = {}) { if (!Meteor.isServer) { throw new Meteor.Error('invalid-environment', `You must run this in server-side code`); @@ -23,65 +24,94 @@ _.extend(NamedQuery.prototype, { this.exposeConfig = Object.assign({}, ExposeDefaults, config); check(this.exposeConfig, ExposeSchema); - if (this.exposeConfig.method) { + if (this.exposeConfig.validateParams) { + this.options.validateParams = this.exposeConfig.validateParams; + } + + if (!this.isResolver) { + this._initNormalQuery(); + } else { + this._initMethod(); + } + + this.isExposed = true; + }, + + /** + * Initializes a normal NamedQuery (normal == not a resolver) + * @private + */ + _initNormalQuery() { + const config = this.exposeConfig; + if (config.method) { this._initMethod(); } - if (this.exposeConfig.publication) { + if (config.publication) { this._initPublication(); } - if (!this.exposeConfig.method && !this.exposeConfig.publication) { + if (!config.method && !config.publication) { throw new Meteor.Error('weird', 'If you want to expose your named query you need to specify at least one of ["method", "publication"] options to true') } this._initCountMethod(); this._initCountPublication(); - if (this.exposeConfig.embody) { + if (config.embody) { this.body = mergeDeep( deepClone(this.body), - this.exposeConfig.embody + config.embody ); } + }, - this.isExposed = true; + /** + * @param context + * @private + */ + _unblockIfNecessary(context) { + if (this.exposeConfig.unblock) { + context.unblock(); + } }, + /** + * @private + */ _initMethod() { const self = this; Meteor.methods({ [this.name](newParams) { - this.unblock(); - - self._validateParams(newParams); + self._unblockIfNecessary(this); - if (self.exposeConfig.firewall) { - self.exposeConfig.firewall.call(this, this.userId, newParams); - } - - return self.clone(newParams).fetch(); + // security is done in the fetching because we provide a context + return self.clone(newParams).fetch(this); } }) }, + /** + * @returns {void} + * @private + */ _initCountMethod() { const self = this; Meteor.methods({ [this.name + '.count'](newParams) { - this.unblock(); - self._validateParams(newParams); - - if (self.exposeConfig.firewall) { - self.exposeConfig.firewall.call(this, this.userId, newParams); - } + self._unblockIfNecessary(this); - return self.clone(newParams).getCount(); + // security is done in the fetching because we provide a context + return self.clone(newParams).getCount(this); } }); }, + /** + * @returns {*} + * @private + */ _initCountPublication() { const self = this; @@ -92,27 +122,24 @@ _.extend(NamedQuery.prototype, { }, getSession(newParams) { - self._validateParams(newParams); - if (self.exposeConfig.firewall) { - self.exposeConfig.firewall.call(this, this.userId, newParams); - } + self.doValidateParams(newParams); + self._callFirewall(this, this.userId, params); return { params: newParams }; }, }); }, + /** + * @private + */ _initPublication() { const self = this; - Meteor.publishComposite(this.name, function (newParams) { - self._validateParams(newParams); + Meteor.publishComposite(this.name, function (params) { + self.doValidateParams(params); + self._callFirewall(this, this.userId, params); - if (self.exposeConfig.firewall) { - self.exposeConfig.firewall.call(this, this.userId, newParams); - } - - let params = _.extend({}, self.params, newParams); const body = prepareForProcess(self.body, params); const rootNode = createGraph(self.collection, body); @@ -121,20 +148,24 @@ _.extend(NamedQuery.prototype, { }); }, - _validateParams(params) { - if (this.exposeConfig.schema) { - const paramsToValidate = _.omit(params, ...specialParameters); - - if (process.env.NODE_ENV !== 'production') { - try { - check(paramsToValidate, this._paramSchema); - } catch (validationError) { - console.error(`Invalid parameters supplied to query ${this.queryName}`, validationError); - throw validationError; // rethrow - } - } else { - check(paramsToValidate, this._paramSchema); - } + /** + * @param context + * @param userId + * @param params + * @private + */ + _callFirewall(context, userId, params) { + const {firewall} = this.exposeConfig; + if (!firewall) { + return; + } + + if (_.isArray(firewall)) { + firewall.forEach(fire => { + fire.call(context, userId, params); + }) + } else { + firewall.call(context, userId, params); } } }); \ No newline at end of file diff --git a/lib/namedQuery/expose/schema.js b/lib/namedQuery/expose/schema.js index fecc61a1..0381f5f6 100644 --- a/lib/namedQuery/expose/schema.js +++ b/lib/namedQuery/expose/schema.js @@ -3,12 +3,18 @@ import {Match} from 'meteor/check'; export const ExposeDefaults = { publication: true, method: true, + unblock: true, }; export const ExposeSchema = { - firewall: Match.Maybe(Function), + firewall: Match.Maybe( + Match.OneOf(Function, [Function]) + ), publication: Match.Maybe(Boolean), + unblock: Match.Maybe(Boolean), method: Match.Maybe(Boolean), embody: Match.Maybe(Object), - schema: Match.Maybe(Object), + validateParams: Match.Maybe( + Match.OneOf(Object, Function) + ), }; diff --git a/lib/namedQuery/namedQuery.base.js b/lib/namedQuery/namedQuery.base.js index 8bdb14b5..1ea457e6 100644 --- a/lib/namedQuery/namedQuery.base.js +++ b/lib/namedQuery/namedQuery.base.js @@ -1,14 +1,20 @@ import deepClone from 'lodash.clonedeep'; +const specialParameters = ['$body']; + export default class NamedQueryBase { - constructor(name, collection, body, params = {}) { + constructor(name, collection, body, options = {}) { this.queryName = name; - this.body = deepClone(body); - Object.freeze(this.body); + if (_.isFunction(body)) { + this.resolver = body; + } else { + this.body = deepClone(body); + } this.subscriptionHandle = null; - this.params = params; + this.params = options.params || {}; + this.options = options; this.collection = collection; this.isExposed = false; } @@ -17,22 +23,65 @@ export default class NamedQueryBase { return `named_query_${this.queryName}`; } + get isResolver() { + return !!this.resolver; + } + setParams(params) { this.params = _.extend({}, this.params, params); return this; } + /** + * Validates the parameters + */ + doValidateParams(params) { + params = params || this.params; + params = _.omit(params, ...specialParameters); + + const {validateParams} = this.options; + if (!validateParams) return; + + try { + this._validate(validateParams, params); + } catch (validationError) { + console.error(`Invalid parameters supplied to the query "${this.queryName}"\n`, validationError); + throw validationError; // rethrow + } + } + clone(newParams) { + const params = _.extend({}, deepClone(this.params), newParams); + let clone = new this.constructor( this.queryName, this.collection, - deepClone(this.body), - _.extend({}, deepClone(this.params), newParams) + this.isResolver ? this.resolver : deepClone(this.body), + { + ...this.options, + params, + } ); clone.cacher = this.cacher; + if (this.exposeConfig) { + clone.exposeConfig = this.exposeConfig; + } return clone; } + + /** + * @param {function|object} validator + * @param {object} params + * @private + */ + _validate(validator, params) { + if (_.isFunction(validator)) { + validator.call(null, params) + } else { + check(params, validator) + } + } } \ No newline at end of file diff --git a/lib/namedQuery/namedQuery.client.js b/lib/namedQuery/namedQuery.client.js index 9c341ff0..7a30ad1f 100644 --- a/lib/namedQuery/namedQuery.client.js +++ b/lib/namedQuery/namedQuery.client.js @@ -14,6 +14,10 @@ export default class extends Base { * @returns {null|any|*} */ subscribe(callback) { + if (this.isResolver) { + throw new Meteor.Error('not-allowed', `You cannot subscribe to a resolver query`); + } + this.subscriptionHandle = Meteor.subscribe( this.name, this.params, @@ -30,6 +34,10 @@ export default class extends Base { * @returns {Object} */ subscribeCount(callback) { + if (this.isResolver) { + throw new Meteor.Error('not-allowed', `You cannot subscribe to a resolver query`); + } + if (!this._counter) { this._counter = new CountSubscription(this); } diff --git a/lib/namedQuery/namedQuery.server.js b/lib/namedQuery/namedQuery.server.js index f7e3ab69..3eb7d5cb 100644 --- a/lib/namedQuery/namedQuery.server.js +++ b/lib/namedQuery/namedQuery.server.js @@ -9,18 +9,26 @@ export default class extends Base { * Retrieves the data. * @returns {*} */ - fetch() { - const query = this.collection.createQuery( - deepClone(this.body), - deepClone(this.params) - ); + fetch(context) { + this._performSecurityChecks(context, this.params); - if (this.cacher) { - const cacheId = generateQueryId(this.queryName, this.params); - return this.cacher.get(cacheId, {query}); - } + if (this.isResolver) { + return this._fetchResolverData(context); + } else { + const query = this.collection.createQuery( + deepClone(this.body), + { + params: deepClone(this.params) + } + ); - return query.fetch(); + if (this.cacher) { + const cacheId = generateQueryId(this.queryName, this.params); + return this.cacher.get(cacheId, {query}); + } + + return query.fetch(); + } } /** @@ -36,7 +44,9 @@ export default class extends Base { * * @returns {any} */ - getCount() { + getCount(context) { + this._performSecurityChecks(context, this.params); + const countCursor = this.getCursorForCounting(); if (this.cacher) { @@ -68,4 +78,51 @@ export default class extends Base { this.cacher = cacher; } + + /** + * Configure resolve. This doesn't actually call the resolver, it just sets it + * @param fn + */ + resolve(fn) { + if (!this.isResolver) { + throw new Meteor.Error('invalid-call', `You cannot use resolve() on a non resolver NamedQuery`); + } + + this.resolver = fn; + } + + /** + * @returns {*} + * @private + */ + _fetchResolverData(context) { + const resolver = this.resolver; + const self = this; + const query = { + fetch() { + return resolver.call(context, self.params); + } + }; + + if (this.cacher) { + const cacheId = generateQueryId(this.queryName, this.params); + return this.cacher.get(cacheId, {query}); + } + + return query.fetch(); + } + + /** + * @param context Meteor method/publish context + * @param params + * + * @private + */ + _performSecurityChecks(context, params) { + if (context && this.exposeConfig) { + this._callFirewall(context, context.userId, params); + } + + this.doValidateParams(params); + } } \ No newline at end of file diff --git a/lib/namedQuery/testing/bootstrap/queries/index.js b/lib/namedQuery/testing/bootstrap/queries/index.js new file mode 100644 index 00000000..9a766387 --- /dev/null +++ b/lib/namedQuery/testing/bootstrap/queries/index.js @@ -0,0 +1,17 @@ +import postList from './postList'; +import postListCached from './postListCached'; +import postListExposure from './postListExposure'; +import postListParamsCheck from './postListParamsCheck'; +import postListParamsCheckServer from './postListParamsCheckServer'; +import postListResolver from './postListResolver'; +import postListResolverCached from './postListResolverCached'; + +export { + postList, + postListCached, + postListExposure, + postListParamsCheck, + postListParamsCheckServer, + postListResolver, + postListResolverCached +} diff --git a/lib/namedQuery/testing/bootstrap/queries/postList.js b/lib/namedQuery/testing/bootstrap/queries/postList.js new file mode 100644 index 00000000..880a9caa --- /dev/null +++ b/lib/namedQuery/testing/bootstrap/queries/postList.js @@ -0,0 +1,24 @@ +import { createQuery, MemoryResultCacher } from 'meteor/cultofcoders:grapher'; + +const postList = createQuery('postList', { + posts: { + $filter({filters, options, params}) { + if (params.title) { + filters.title = params.title; + } + + if (params.limit) { + options.limit = params.limit; + } + }, + title: 1, + author: { + name: 1 + }, + group: { + name: 1 + } + } +}); + +export default postList; \ No newline at end of file diff --git a/lib/namedQuery/testing/bootstrap/queries/postListCached.js b/lib/namedQuery/testing/bootstrap/queries/postListCached.js new file mode 100644 index 00000000..b50d0952 --- /dev/null +++ b/lib/namedQuery/testing/bootstrap/queries/postListCached.js @@ -0,0 +1,13 @@ +import { createQuery, MemoryResultCacher } from 'meteor/cultofcoders:grapher'; + +const postListCached = createQuery('postListCached', { + posts: { + title: 1, + } +}); + +postListCached.cacheResults(new MemoryResultCacher({ + ttl: 200, +})); + +export default postListCached; diff --git a/lib/namedQuery/testing/bootstrap/queries/postListExposure.js b/lib/namedQuery/testing/bootstrap/queries/postListExposure.js index 692a8311..78ef91dc 100644 --- a/lib/namedQuery/testing/bootstrap/queries/postListExposure.js +++ b/lib/namedQuery/testing/bootstrap/queries/postListExposure.js @@ -1,6 +1,6 @@ import { createQuery } from 'meteor/cultofcoders:grapher'; -export default createQuery('postListExposure', { +const postListExposure = createQuery('postListExposure', { posts: { title: 1, author: { @@ -10,4 +10,18 @@ export default createQuery('postListExposure', { name: 1 } } -}); \ No newline at end of file +}); + +if (Meteor.isServer) { + postListExposure.expose({ + firewall(userId, params) { + }, + embody: { + $filter({filters, params}) { + filters.title = params.title + } + } + }); +} + +export default postListExposure; \ No newline at end of file diff --git a/lib/namedQuery/testing/bootstrap/queries/postListParamsCheck.js b/lib/namedQuery/testing/bootstrap/queries/postListParamsCheck.js new file mode 100644 index 00000000..731087ce --- /dev/null +++ b/lib/namedQuery/testing/bootstrap/queries/postListParamsCheck.js @@ -0,0 +1,19 @@ +import {createQuery} from 'meteor/cultofcoders:grapher'; + +const postList = createQuery('postListResolverParamsCheck', () => {}, { + validateParams: { + title: String, + } +}); + +if (Meteor.isServer) { + postList.expose({}); + + postList.resolve(params => { + return [ + params.title + ]; + }) +} + +export default postList; \ No newline at end of file diff --git a/lib/namedQuery/testing/bootstrap/queries/postListParamsCheckServer.js b/lib/namedQuery/testing/bootstrap/queries/postListParamsCheckServer.js new file mode 100644 index 00000000..43a911b1 --- /dev/null +++ b/lib/namedQuery/testing/bootstrap/queries/postListParamsCheckServer.js @@ -0,0 +1,19 @@ +import { createQuery } from 'meteor/cultofcoders:grapher'; + +const postList = createQuery('postListResolverParamsCheckServer', () => {}); + +if (Meteor.isServer) { + postList.expose({ + validateParams: { + title: String + } + }); + + postList.resolve(params => { + return [ + params.title + ]; + }) +} + +export default postList; \ No newline at end of file diff --git a/lib/namedQuery/testing/bootstrap/queries/postListResolver.js b/lib/namedQuery/testing/bootstrap/queries/postListResolver.js new file mode 100644 index 00000000..62bad492 --- /dev/null +++ b/lib/namedQuery/testing/bootstrap/queries/postListResolver.js @@ -0,0 +1,15 @@ +import { createQuery, MemoryResultCacher } from 'meteor/cultofcoders:grapher'; + +const postList = createQuery('postListResolver', () => {}); + +if (Meteor.isServer) { + postList.expose({}); + + postList.resolve(params => { + return [ + params.title + ]; + }) +} + +export default postList; diff --git a/lib/namedQuery/testing/bootstrap/queries/postListResolverCached.js b/lib/namedQuery/testing/bootstrap/queries/postListResolverCached.js new file mode 100644 index 00000000..662ffd63 --- /dev/null +++ b/lib/namedQuery/testing/bootstrap/queries/postListResolverCached.js @@ -0,0 +1,19 @@ +import { createQuery, MemoryResultCacher } from 'meteor/cultofcoders:grapher'; + +const postList = createQuery('postListResolverCached', () => {}); + +if (Meteor.isServer) { + postList.expose({}); + + postList.resolve(params => { + return [ + params.title + ]; + }); + + postList.cacheResults(new MemoryResultCacher({ + ttl: 200, + })); +} + +export default postList; \ No newline at end of file diff --git a/lib/namedQuery/testing/bootstrap/server.js b/lib/namedQuery/testing/bootstrap/server.js index effbe66f..660932cd 100644 --- a/lib/namedQuery/testing/bootstrap/server.js +++ b/lib/namedQuery/testing/bootstrap/server.js @@ -1,49 +1 @@ -import { createQuery, MemoryResultCacher } from 'meteor/cultofcoders:grapher'; -import postListExposure from './queries/postListExposure.js'; - -const postList = createQuery('postList', { - posts: { - $filter({filters, options, params}) { - if (params.title) { - filters.title = params.title; - } - - if (params.limit) { - options.limit = params.limit; - } - }, - title: 1, - author: { - name: 1 - }, - group: { - name: 1 - } - } -}); - - -export { postList }; -export { postListExposure }; - -postListExposure.expose({ - firewall(userId, params) { - }, - embody: { - $filter({filters, params}) { - filters.title = params.title - } - } -}); - -const postListCached = createQuery('postListCached', { - posts: { - title: 1, - } -}); - -export {postListCached}; - -postListCached.cacheResults(new MemoryResultCacher({ - ttl: 200, -})); \ No newline at end of file +import './queries'; \ No newline at end of file diff --git a/lib/namedQuery/testing/server.test.js b/lib/namedQuery/testing/server.test.js index 39ddc493..aa9cf5cf 100644 --- a/lib/namedQuery/testing/server.test.js +++ b/lib/namedQuery/testing/server.test.js @@ -1,7 +1,13 @@ -import { postList, postListCached } from './bootstrap/server.js'; +import { + postList, + postListCached, + postListResolver, + postListResolverCached, + postListParamsCheck, + postListParamsCheckServer, +} from './bootstrap/queries'; import { createQuery } from 'meteor/cultofcoders:grapher'; - describe('Named Query', function () { it('Should return the proper values', function () { const createdQuery = createQuery({ @@ -96,5 +102,58 @@ describe('Named Query', function () { assert.isObject(post.group); assert.isUndefined(post.group.createdAt); }) - }) + }); + + it('Should work with resolver() queries with params', function () { + const title = 'User Post - 3'; + const createdQuery = createQuery({ + postListResolver: { + title + } + }); + + const directQuery = postListResolver.clone({ + title + }); + + let data = createdQuery.fetch(); + assert.isArray(data); + assert.equal(title, data[0]); + + + data = directQuery.fetch(); + assert.isArray(data); + assert.equal(title, data[0]); + }); + + it('Should work with resolver() that is cached', function () { + const title = 'User Post - 3'; + let data = postListResolverCached.clone({title}).fetch(); + + assert.isArray(data); + assert.equal(title, data[0]); + + data = postListResolverCached.clone({title}).fetch(); + + assert.isArray(data); + assert.equal(title, data[0]); + }); + + it('Should work with resolver() that has params validation', function (done) { + try { + postListParamsCheck.clone({}).fetch(); + } catch (e) { + assert.isObject(e); + done(); + } + }); + + it('Should work with resolver() that has params server-side validation', function (done) { + try { + postListParamsCheckServer.clone({}).fetch(); + } catch (e) { + assert.isObject(e); + done(); + } + }); }); diff --git a/lib/query/counts/constants.js b/lib/query/counts/constants.js index 01b6f493..229f134e 100644 --- a/lib/query/counts/constants.js +++ b/lib/query/counts/constants.js @@ -1 +1 @@ -export const COUNTS_COLLECTION_CLIENT = '$grapher.counts'; +export const COUNTS_COLLECTION_CLIENT = 'grapher_counts'; diff --git a/lib/query/createQuery.js b/lib/query/createQuery.js deleted file mode 100644 index 37373b02..00000000 --- a/lib/query/createQuery.js +++ /dev/null @@ -1,46 +0,0 @@ -import Query from './query.js'; -import NamedQuery from '../namedQuery/namedQuery.js'; -import NamedQueryStore from '../namedQuery/store.js'; - -export default (...args) => { - let name; - let body; - let rest; - if (typeof args[0] === 'string') { //NamedQuery - name = args[0]; - body = args[1]; - rest = args.slice(2) - } else { //Query - body = args[0]; - rest = args.slice(1) - } - - if (_.keys(body).length > 1) { - throw new Meteor.Error('invalid-query', 'When using createQuery you should only have one main root point that represents the collection name.') - } - - const entryPointName = _.first(_.keys(body)); - - const collection = Mongo.Collection.get(entryPointName); - if (!collection) { - if (name) { //is a NamedQuery - throw new Meteor.Error('invalid-name', `We could not find any collection with the name "${entryPointName}". Make sure it is imported prior to using this`) - } - const namedQuery = NamedQueryStore.get(entryPointName); - - if (!namedQuery) { - throw new Meteor.Error('entry-point-not-found', `We could not find any collection or named query with the name "${entryPointName}". Make sure you have them loaded in the environment you are executing *createQuery*`) - } else { - return namedQuery.clone(body[entryPointName], ...rest); - } - } - - if (name) { - const query = new NamedQuery(name, collection, body[entryPointName], ...rest); - NamedQueryStore.add(name, query); - - return query; - } else { - return new Query(collection, body[entryPointName], ...rest); - } -} \ No newline at end of file diff --git a/lib/query/extension.js b/lib/query/extension.js deleted file mode 100644 index e1686b9f..00000000 --- a/lib/query/extension.js +++ /dev/null @@ -1,25 +0,0 @@ -import Query from './query.js'; -import NamedQuery from '../namedQuery/namedQuery.js'; -import NamedQueryStore from '../namedQuery/store.js'; - -_.extend(Mongo.Collection.prototype, { - createQuery(...args) { - if (typeof args[0] === 'string') { - //NamedQuery - const name = args[0]; - const body = args[1]; - const params = args[2]; - - const query = new NamedQuery(name, this, body, params); - NamedQueryStore.add(name, query); - - return query; - } else { - //Query - const body = args[0]; - const params = args[1]; - - return new Query(this, body, params); - } - } -}); \ No newline at end of file diff --git a/lib/query/hypernova/aggregateSearchFilters.js b/lib/query/hypernova/aggregateSearchFilters.js index 57c5070b..8039cc0f 100644 --- a/lib/query/hypernova/aggregateSearchFilters.js +++ b/lib/query/hypernova/aggregateSearchFilters.js @@ -36,13 +36,17 @@ export default class AggregateFilters { if (!this.isVirtual) { return { _id: { - $in: _.pluck(this.parentObjects, this.linkStorageField) + $in: _.uniq( + _.pluck(this.parentObjects, this.linkStorageField) + ) } }; } else { return { [this.linkStorageField]: { - $in: _.pluck(this.parentObjects, '_id') + $in: _.uniq( + _.pluck(this.parentObjects, '_id') + ) } }; } @@ -67,7 +71,7 @@ export default class AggregateFilters { }); return { - _id: {$in: ids} + _id: {$in: _.uniq(ids)} }; } else { let filters = {}; @@ -78,7 +82,9 @@ export default class AggregateFilters { } filters[this.linkStorageField + '._id'] = { - $in: _.pluck(this.parentObjects, '_id') + $in: _.uniq( + _.pluck(this.parentObjects, '_id') + ) }; return filters; @@ -90,14 +96,18 @@ export default class AggregateFilters { const arrayOfIds = _.pluck(this.parentObjects, this.linkStorageField); return { _id: { - $in: _.union(...arrayOfIds) + $in: _.uniq( + _.union(...arrayOfIds) + ) } }; } else { const arrayOfIds = _.pluck(this.parentObjects, '_id'); return { [this.linkStorageField]: { - $in: _.union(...arrayOfIds) + $in: _.uniq( + _.union(...arrayOfIds) + ) } }; } @@ -125,7 +135,7 @@ export default class AggregateFilters { }); return { - _id: {$in: ids} + _id: {$in: _.uniq(ids)} }; } else { let filters = {}; @@ -136,7 +146,9 @@ export default class AggregateFilters { } filters._id = { - $in: _.pluck(this.parentObjects, '_id') + $in: _.uniq( + _.pluck(this.parentObjects, '_id') + ) }; return { diff --git a/lib/query/lib/createGraph.js b/lib/query/lib/createGraph.js index 426ece31..a39cbe3d 100644 --- a/lib/query/lib/createGraph.js +++ b/lib/query/lib/createGraph.js @@ -49,9 +49,9 @@ export function createNodes(root) { // check if it is a cached link // if yes, then we need to explicitly define this at collection level // so when we transform the data for delivery, we move it to the link name - if (linker.isCached()) { - if (linker.isSubBodyCache(body)) { - const cacheField = linker.linkConfig.cache.field; + if (linker.isDenormalized()) { + if (linker.isSubBodyDenormalized(body)) { + const cacheField = linker.linkConfig.denormalize.field; root.snapCache(cacheField, fieldName); addFieldNode(body, cacheField, root); diff --git a/lib/query/query.base.js b/lib/query/query.base.js index 52bac6ea..84dfd9ae 100644 --- a/lib/query/query.base.js +++ b/lib/query/query.base.js @@ -1,20 +1,26 @@ import deepClone from 'lodash.clonedeep'; +import {check} from 'meteor/check'; export default class QueryBase { - constructor(collection, body, params = {}) { + constructor(collection, body, options = {}) { this.collection = collection; this.body = deepClone(body); - Object.freeze(this.body); - this._params = params; + this.params = options.params || {}; + this.options = options; } clone(newParams) { + const params = _.extend({}, deepClone(this.params), newParams); + return new this.constructor( this.collection, deepClone(this.body), - _.extend({}, deepClone(this.params), newParams) + { + params, + ...this.options + } ); } @@ -22,18 +28,28 @@ export default class QueryBase { return `exposure_${this.collection._name}`; } - get params() { - return this._params; + /** + * Validates the parameters + */ + doValidateParams() { + const {validateParams} = this.options; + if (!validateParams) return; + + if (_.isFunction(validateParams)) { + validateParams.call(null, this.params) + } else { + check(this.params) + } } /** * Merges the params with previous params. * - * @param data + * @param params * @returns {Query} */ - setParams(data) { - _.extend(this._params, data); + setParams(params) { + this.params = _.extend({}, this.params, params); return this; } diff --git a/lib/query/query.client.js b/lib/query/query.client.js index 035261a4..81685ad0 100644 --- a/lib/query/query.client.js +++ b/lib/query/query.client.js @@ -14,6 +14,8 @@ export default class Query extends Base { * @returns {null|any|*} */ subscribe(callback) { + this.doValidateParams(); + this.subscriptionHandle = Meteor.subscribe( this.name, prepareForProcess(this.body, this.params), @@ -30,6 +32,8 @@ export default class Query extends Base { * @returns {Object} */ subscribeCount(callback) { + this.doValidateParams(); + if (!this._counter) { this._counter = new CountSubscription(this); } @@ -66,6 +70,8 @@ export default class Query extends Base { * @return {*} */ async fetchSync() { + this.doValidateParams(); + if (this.subscriptionHandle) { throw new Meteor.Error('This query is reactive, meaning you cannot use promises to fetch the data.'); } @@ -87,6 +93,8 @@ export default class Query extends Base { * @returns {*} */ fetch(callbackOrOptions) { + this.doValidateParams(); + if (!this.subscriptionHandle) { return this._fetchStatic(callbackOrOptions) } else { diff --git a/lib/query/testing/link-cache/collections.js b/lib/query/testing/link-cache/collections.js index 58938577..b163564a 100644 --- a/lib/query/testing/link-cache/collections.js +++ b/lib/query/testing/link-cache/collections.js @@ -17,7 +17,7 @@ Posts.addLinks({ type: 'one', collection: Authors, field: 'authorId', - cache: { + denormalize: { field: 'authorCache', body: { name: 1, @@ -30,7 +30,7 @@ Posts.addLinks({ metadata: true, collection: Categories, field: 'categoryIds', - cache: { + denormalize: { field: 'categoriesCache', body: { name: 1, @@ -43,7 +43,7 @@ Authors.addLinks({ posts: { collection: Posts, inversedBy: 'author', - cache: { + denormalize: { field: 'postCache', body: { title: 1, @@ -54,7 +54,7 @@ Authors.addLinks({ type: 'many', collection: Groups, field: 'groupIds', - cache: { + denormalize: { field: 'groupsCache', body: { name: 1, @@ -67,7 +67,7 @@ Authors.addLinks({ collection: AuthorProfiles, field: 'profileId', unique: true, - cache: { + denormalize: { field: 'profileCache', body: { name: 1, @@ -81,7 +81,7 @@ AuthorProfiles.addLinks({ collection: Authors, inversedBy: 'profile', unique: true, - cache: { + denormalize: { field: 'authorCache', body: { name: 1, @@ -94,7 +94,7 @@ Groups.addLinks({ authors: { collection: Authors, inversedBy: 'groups', - cache: { + denormalize: { field: 'authorsCache', body: { name: 1, @@ -107,7 +107,7 @@ Categories.addLinks({ posts: { collection: Posts, inversedBy: 'categories', - cache: { + denormalize: { field: 'postsCache', body: { title: 1, diff --git a/lib/query/testing/link-cache/server.test.js b/lib/query/testing/link-cache/server.test.js index ccc7caca..06792222 100644 --- a/lib/query/testing/link-cache/server.test.js +++ b/lib/query/testing/link-cache/server.test.js @@ -3,6 +3,20 @@ import {createQuery} from 'meteor/cultofcoders:grapher'; import {Authors, AuthorProfiles, Groups, Posts, Categories} from './collections'; describe('Query Link Cache', function () { + it('Should work with nested filters', function () { + let query = Posts.createQuery({ + $options: {limit: 5}, + author: { + name: 1, + } + }); + + let insideFind = false; + stubFind(Authors, function () { + insideFind = true; + }); + }); + it('Should work properly - One Direct', function () { let query = Posts.createQuery({ $options: {limit: 5}, diff --git a/lib/query/testing/server.test.js b/lib/query/testing/server.test.js index 7cb52423..a92afaa5 100644 --- a/lib/query/testing/server.test.js +++ b/lib/query/testing/server.test.js @@ -473,9 +473,11 @@ describe('Hypernova', function () { authors: {} } }, { - options: {limit: 1}, - filters: { - name: 'JavaScript' + params: { + options: {limit: 1}, + filters: { + name: 'JavaScript' + } } }); diff --git a/main.client.js b/main.client.js index b13c6991..1d3b0999 100644 --- a/main.client.js +++ b/main.client.js @@ -1,10 +1,10 @@ +import './lib/extension.js'; import './lib/links/extension.js'; -import './lib/query/extension.js'; import './lib/query/reducers/extension.js'; export { default as createQuery -} from './lib/query/createQuery.js'; +} from './lib/createQuery.js'; export { default as prepareForProcess diff --git a/main.server.js b/main.server.js index 7f3a8871..6c0fecac 100644 --- a/main.server.js +++ b/main.server.js @@ -1,22 +1,25 @@ +import './lib/extension.js'; import './lib/aggregate'; import './lib/exposure/extension.js'; import './lib/links/extension.js'; -import './lib/query/extension.js'; import './lib/query/reducers/extension.js'; import './lib/namedQuery/expose/extension.js'; +import NamedQueryStore from './lib/namedQuery/store'; +import LinkConstants from './lib/links/constants'; + +export { + NamedQueryStore, + LinkConstants +} export { default as createQuery -} from './lib/query/createQuery.js'; +} from './lib/createQuery.js'; export { default as Exposure } from './lib/exposure/exposure.js'; -export { - default as getDocumentationObject -} from './lib/documentor/index.js'; - export { default as MemoryResultCacher } from './lib/namedQuery/cache/MemoryResultCacher'; diff --git a/package.js b/package.js index 6d7bd215..f880be31 100644 --- a/package.js +++ b/package.js @@ -14,6 +14,7 @@ Npm.depends({ 'sift': '3.2.6', 'dot-object': '1.5.4', 'lodash.clonedeep': '4.5.0', + 'deep-extend': '0.5.0', }); Package.onUse(function (api) {