Modified createQuery to accept options and implemented resolver queries

This commit is contained in:
Theodor Diaconu 2017-11-28 17:38:51 +02:00
parent 9cc083420b
commit 4802724f97
39 changed files with 662 additions and 370 deletions

View file

@ -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",

67
lib/createQuery.js Normal file
View file

@ -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);
}

View file

@ -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
}
})
}

View file

@ -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),

View file

@ -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);
}
}
};

20
lib/extension.js Normal file
View file

@ -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);
}
}
});

View file

@ -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)),
};

3
lib/links/constants.js Normal file
View file

@ -0,0 +1,3 @@
export default {
LINK_STORAGE: '__links'
};

View file

@ -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}`);

View file

@ -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));

View file

@ -1,3 +0,0 @@
const linkStorage = Symbol('linkStorage');
export {linkStorage}

View file

@ -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();
}
if (this.exposeConfig.publication) {
this.isExposed = true;
},
/**
* Initializes a normal NamedQuery (normal == not a resolver)
* @private
*/
_initNormalQuery() {
const config = this.exposeConfig;
if (config.method) {
this._initMethod();
}
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._unblockIfNecessary(this);
self._validateParams(newParams);
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);
self._unblockIfNecessary(this);
if (self.exposeConfig.firewall) {
self.exposeConfig.firewall.call(this, this.userId, newParams);
}
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);
/**
* @param context
* @param userId
* @param params
* @private
*/
_callFirewall(context, userId, params) {
const {firewall} = this.exposeConfig;
if (!firewall) {
return;
}
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);
}
if (_.isArray(firewall)) {
firewall.forEach(fire => {
fire.call(context, userId, params);
})
} else {
firewall.call(context, userId, params);
}
}
});

View file

@ -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)
),
};

View file

@ -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)
}
}
}

View file

@ -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);
}

View file

@ -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)
}
);
if (this.cacher) {
const cacheId = generateQueryId(this.queryName, this.params);
return this.cacher.get(cacheId, {query});
}
return query.fetch();
}
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);
}
}

View file

@ -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
}

View file

@ -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;

View file

@ -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;

View file

@ -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
}
}
});
});
if (Meteor.isServer) {
postListExposure.expose({
firewall(userId, params) {
},
embody: {
$filter({filters, params}) {
filters.title = params.title
}
}
});
}
export default postListExposure;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,
}));
import './queries';

View file

@ -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();
}
});
});

View file

@ -1 +1 @@
export const COUNTS_COLLECTION_CLIENT = '$grapher.counts';
export const COUNTS_COLLECTION_CLIENT = 'grapher_counts';

View file

@ -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);
}
}

View file

@ -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);
}
}
});

View file

@ -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 {

View file

@ -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);

View file

@ -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;
}

View file

@ -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 {

View file

@ -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,

View file

@ -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},

View file

@ -473,9 +473,11 @@ describe('Hypernova', function () {
authors: {}
}
}, {
options: {limit: 1},
filters: {
name: 'JavaScript'
params: {
options: {limit: 1},
filters: {
name: 'JavaScript'
}
}
});

View file

@ -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

View file

@ -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';

View file

@ -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) {