added namedQuery + cloning ability to query + fixes to exposure

This commit is contained in:
Theodor Diaconu 2016-10-07 10:31:58 +03:00
parent d0dde92cee
commit b0e1098d1e
23 changed files with 659 additions and 27 deletions

View file

@ -23,8 +23,8 @@ export default new SimpleSchema({
optional: true optional: true
}, },
restrictedLinks: { restrictLinks: {
type: [String], type: null, // Can be function that accepts userId and returns array or array
optional: true optional: true
}, },

View file

@ -44,6 +44,12 @@ export default class Exposure {
if (config.method) { if (config.method) {
this.initMethod(); this.initMethod();
} }
if (!config.method && !config.publication) {
throw new Meteor.Error('weird', 'If you want to expose your collection you need to specify at least one of ["method", "publication"] options to true')
}
this.initCountMethod();
} }
_validateAndClean() { _validateAndClean() {
@ -78,6 +84,8 @@ export default class Exposure {
Meteor.methods({ Meteor.methods({
[this.name](body) { [this.name](body) {
this.unblock();
const rootNode = createGraph(collection, body); const rootNode = createGraph(collection, body);
enforceMaxDepth(rootNode, config.maxDepth); enforceMaxDepth(rootNode, config.maxDepth);
@ -86,9 +94,16 @@ export default class Exposure {
return hypernova(rootNode, this.userId); return hypernova(rootNode, this.userId);
} }
}); });
}
initCountMethod() {
const collection = this.collection;
const config = this.config;
Meteor.methods({ Meteor.methods({
[this.name + '.count'](body) { [this.name + '.count'](body) {
this.unblock();
return collection.find(body.$filters || {}, {}, this.userId).count(); return collection.find(body.$filters || {}, {}, this.userId).count();
} }
}) })

57
lib/namedQuery/README.md Normal file
View file

@ -0,0 +1,57 @@
A secure query is a query in which the form of it is locked on the server.
Frozen queries are regarded as trusted code, the exposure from other collections will not affect them.
Only the firewall.
The reason behind this concept:
- You may have an Order for a Customer and to that order is an employee assigned
- You want to expose all employees to admin via user exposure
- Now, because exposures are linked you may need to add extra logic to user exposure, and it will eventually turn into a mess
- It gets hard to validate/invalidate fields links.
This is the reason why you should construct your secure query and offer control over it via params. That can be used and manipulated in $filter function.
```
const query = createNamedQuery('testList', {
tests: {
$filter({
filters,
options,
params
}) {
},
title: 1,
endcustomer: {
profile: 1
}
}
})
```
```
// In the same file or in a server-side file only:
query.expose({
firewall(userId, params) {
// throw exception if not allowed
},
body: { // merges deeply with your current body, so you can filter without showing the client how you do it to avoid exposing precious data
tests: {
$filter({filters, options, params})
}
}
})
```
```
// You must have your collections and queries imported already.
// Client side
createQuery({
testListQuery: {
endcustomer: Meteor.userId()
}
})
```

View file

@ -0,0 +1,26 @@
import NamedQuery from './namedQuery.js';
import NamedQueryStore from './store';
/**
* @param name
* @param data
* @param args
*
* @returns NamedQuery
*/
export default (name, data, ...args) => {
if (_.keys(data).length > 1) {
throw new Meteor.Error('invalid-query', 'When using createNamedQuery you should only have one main root point.')
}
const entryPointName = _.first(_.keys(data));
const collection = Mongo.Collection.get(entryPointName);
if (!collection) {
throw new Meteor.Error('invalid-name', `We could not find any collection with the name ${entryPointName}`)
}
const namedQuery = new NamedQuery(name, collection, data[entryPointName], ...args);
NamedQueryStore.add(name, namedQuery);
return namedQuery;
}

View file

@ -0,0 +1,85 @@
import NamedQuery from '../namedQuery.js';
import ExposeSchema from './schema.js';
import mergeDeep from './lib/mergeDeep.js';
import createGraph from '../../query/lib/createGraph.js';
import recursiveCompose from '../../query/lib/recursiveCompose.js';
import applyFilterFunction from '../../query/lib/applyFilterFunction.js';
_.extend(NamedQuery.prototype, {
expose(config = {}) {
if (!Meteor.isServer) {
throw new Meteor.Error('invalid-environment', `You must run this in server-side code`);
}
if (this.isExposed) {
throw new Meteor.Error('query-already-exposed', `You have already exposed: "${this.name}" named query`);
}
ExposeSchema.clean(config);
this.exposeConfig = config;
if (config.method) {
this._initMethod();
}
if (config.publication) {
this._initPublication();
}
if (!config.method && !config.publication) {
throw new Meteor.Error('weird', 'If you want to expose your frozen query you need to specify at least one of ["method", "publication"] options to true')
}
this._initCountMethod();
if (config.embody) {
this.body = mergeDeep(this.body, config.embody);
}
this.isExposed = true;
},
_initMethod() {
const self = this;
Meteor.methods({
[this.name](newParams) {
this.unblock();
if (self.exposeConfig.firewall) {
self.exposeConfig.firewall.call(this, this.userId, newParams);
}
return self.clone(newParams).fetch();
}
})
},
_initCountMethod() {
const self = this;
Meteor.methods({
[this.name + '.count'](newParams) {
this.unblock();
return self.clone(newParams).getCount();
}
})
},
_initPublication() {
const self = this;
Meteor.publishComposite(this.name, function (newParams) {
if (self.exposeConfig.firewall) {
self.exposeConfig.firewall.call(this, this.userId, newParams);
}
let params = _.extend({}, self.params, newParams);
let body = applyFilterFunction(self.body, params);
const rootNode = createGraph(self.collection, body);
return recursiveCompose(rootNode);
});
}
});

View file

@ -0,0 +1,21 @@
/**
* Deep merge two objects.
* @param target
* @param source
*/
export default function mergeDeep(target, source) {
if (_.isObject(target) && _.isObject(source)) {
_.each(source, (value, key) => {
if (_.isFunction(source[key])) {
target[key] = source[key];
} else if (_.isObject(source[key])) {
if (!target[key]) Object.assign(target, { [key]: {} });
mergeDeep(target[key], source[key]);
} else {
Object.assign(target, { [key]: source[key] });
}
});
}
return target;
}

View file

@ -0,0 +1,22 @@
export default new SimpleSchema({
firewall: {
type: Function,
optional: true
},
publication: {
type: Boolean,
defaultValue: true
},
method: {
type: Boolean,
defaultValue: true
},
embody: {
type: Object,
blackbox: true,
optional: true
}
})

View file

@ -0,0 +1,154 @@
import createGraph from '../query/lib/createGraph.js';
import recursiveCompose from '../query/lib/recursiveCompose.js';
import recursiveFetch from '../query/lib/recursiveFetch.js';
import applyFilterFunction from '../query/lib/applyFilterFunction.js';
export default class NamedQuery {
constructor(name, collection, body, params = {}) {
this.queryName = name;
this.body = body;
this.subscriptionHandle = null;
this.params = params;
this.collection = collection;
this.isExposed = false;
}
get name() {
return `named_query_${this.queryName}`;
}
clone(newParams) {
return new NamedQuery(
this.queryName,
this.collection,
_.clone(this.body),
_.extend({}, this.params, newParams)
);
}
setParams(params) {
this.params = _.extend({}, this.params, params);
return this;
}
/**
* Subscribe
*
* @param callback
* @returns {null|any|*}
*/
subscribe(callback) {
if (!Meteor.isClient) {
throw new Meteor.Error('not-allowed', 'You cannot subscribe from server');
}
this.subscriptionHandle = Meteor.subscribe(
this.name,
this.params,
callback
);
return this.subscriptionHandle;
}
/**
* Unsubscribe if an existing subscription exists
*/
unsubscribe() {
if (!Meteor.isClient) {
throw new Meteor.Error('not-allowed', 'You cannot subscribe/unsubscribe from server');
}
if (this.subscriptionHandle) {
this.subscriptionHandle.stop();
}
this.subscriptionHandle = null;
}
/**
* Retrieves the data.
* @param callbackOrOptions
* @returns {*}
*/
fetch(callbackOrOptions) {
if (Meteor.isClient) {
if (!this.subscriptionHandle) {
return this._fetchAsClientMethod(callbackOrOptions)
} else {
return this._fetchAsClientReactive(callbackOrOptions);
}
} else {
return this._fetchAsServer(callbackOrOptions);
}
}
/**
* @param args
* @returns {*}
*/
fetchOne(...args) {
return _.first(this.fetch(...args));
}
/**
* Gets the count of matching elements.
* @param callback
* @returns {any}
*/
getCount(callback) {
if (Meteor.isClient) {
if (!callback) {
throw new Meteor.Error('not-allowed', 'You are on client so you must either provide a callback to get the count.');
}
return Meteor.call(this.name + '.count', this.params, callback);
}
let body = applyFilterFunction(this.body, this.params);
return this.collection.find(body.$filters || {}, {}).count();
}
/**
* Fetching non-reactive queries
* @param callback
* @private
*/
_fetchAsClientMethod(callback) {
if (!callback) {
throw new Meteor.Error('not-allowed', 'You are on client so you must either provide a callback to get the data or subscribe first.');
}
Meteor.call(this.name, this.params, callback);
}
/**
* Fetching when we've got an active publication
*
* @param options
* @returns {*}
* @private
*/
_fetchAsClientReactive(options = {}) {
let body = applyFilterFunction(this.body, this.params);
if (!options.allowSkip && body.$options && body.$options.skip) {
delete body.$options.skip;
}
return recursiveFetch(
createGraph(this.collection, body)
);
}
/**
* Fetching from the server-side
*
* @private
*/
_fetchAsServer() {
const query = this.collection.createQuery(this.body, this.params);
return query.fetch();
}
}

21
lib/namedQuery/store.js Normal file
View file

@ -0,0 +1,21 @@
export default new class {
constructor() {
this.storage = {};
}
add(key, value) {
if (this.storage[key]) {
throw new Meteor.Error(`Frozen query with key ${key} is already defined`);
}
this.storage[key] = value;
}
get(key) {
return this.storage[key];
}
getAll() {
return this.storage;
}
}

View file

View file

@ -0,0 +1,13 @@
import { createNamedQuery } from 'meteor/cultofcoders:grapher';
export default createNamedQuery('postListExposure', {
posts: {
title: 1,
author: {
name: 1
},
group: {
name: 1
}
}
});

View file

@ -0,0 +1,30 @@
import { createNamedQuery } from 'meteor/cultofcoders:grapher';
import postListExposure from './queries/postListExposure.js';
const postList = createNamedQuery('postList', {
posts: {
$filter({filters, params}) {
filters.title = params.title
},
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
}
}
});

View file

@ -0,0 +1,105 @@
import postListExposure from './bootstrap/queries/postListExposure.js';
import { createQuery } from 'meteor/cultofcoders:grapher';
describe('Named Query', function () {
it('Should return proper values', function (done) {
const query = createQuery({
postListExposure: {
title: 'User Post - 3'
}
});
query.fetch((err, res) => {
assert.isUndefined(err);
assert.isTrue(res.length > 0);
_.each(res, post => {
assert.equal(post.title, 'User Post - 3');
assert.isObject(post.author);
assert.isObject(post.group);
});
done();
})
});
it('Should return proper values using query directly via import', function (done) {
const query = postListExposure.clone({title: 'User Post - 3'});
query.fetch((err, res) => {
assert.isUndefined(err);
assert.isTrue(res.length > 0);
_.each(res, post => {
assert.equal(post.title, 'User Post - 3');
assert.isObject(post.author);
assert.isObject(post.group);
});
done();
})
});
it('Should work with count', function (done) {
const query = postListExposure.clone({title: 'User Post - 3'});
query.getCount((err, res) => {
assert.equal(6, res);
done();
})
});
it('Should work with reactive queries', function (done) {
const query = createQuery({
postListExposure: {
title: 'User Post - 3'
}
});
const handle = query.subscribe();
Tracker.autorun(c => {
if (handle.ready()) {
c.stop();
const res = query.fetch();
assert.isTrue(res.length > 0);
_.each(res, post => {
assert.equal(post.title, 'User Post - 3');
assert.isObject(post.author);
assert.isObject(post.group);
});
handle.stop();
done();
}
})
});
it('Should work with reactive queries via import', function (done) {
const query = postListExposure.clone({
title: 'User Post - 3'
});
const handle = query.subscribe();
Tracker.autorun(c => {
if (handle.ready()) {
c.stop();
const res = query.fetch();
assert.isTrue(res.length > 0);
_.each(res, post => {
assert.equal(post.title, 'User Post - 3');
assert.isObject(post.author);
assert.isObject(post.group);
});
handle.stop();
done();
}
})
})
});

View file

@ -0,0 +1,46 @@
import { postList } from './bootstrap/server.js';
import { createQuery } from 'meteor/cultofcoders:grapher';
describe('Named Query', function () {
it('Should return the proper values', function () {
const createdQuery = createQuery({
postList: {
title: 'User Post - 3'
}
});
const directQuery = postList.clone({
title: 'User Post - 3'
});
_.each([createdQuery, directQuery], (query) => {
const data = query.fetch();
assert.isTrue(data.length > 1);
_.each(data, post => {
assert.equal(post.title, 'User Post - 3');
assert.isObject(post.author);
assert.isObject(post.group);
})
})
});
it('Exposure embodyment should work properly', function () {
const query = createQuery({
postListExposure: {
title: 'User Post - 3'
}
});
const data = query.fetch();
assert.isTrue(data.length > 1);
_.each(data, post => {
assert.equal(post.title, 'User Post - 3');
assert.isObject(post.author);
assert.isObject(post.group);
})
})
});

View file

@ -1,17 +1,24 @@
import Query from './query.js'; import Query from './query.js';
import NamedQueryStore from '../namedQuery/store.js';
export default (data, ...args) => { export default (data, ...args) => {
if (_.keys(data).length > 1) { if (_.keys(data).length > 1) {
throw new Meteor.Error('invalid-query', 'When using createQuery you should only have one main root point.') throw new Meteor.Error('invalid-query', 'When using createQuery you should only have one main root point.')
} }
const collectionName = _.first(_.keys(data)); const entryPointName = _.first(_.keys(data));
const collection = Mongo.Collection.get(collectionName); const collection = Mongo.Collection.get(entryPointName);
if (!collection) { if (!collection) {
throw new Meteor.Error('collection-not-found', `We could not find any collection with the name ${collectionName}`) const namedQuery = NamedQueryStore.get(entryPointName);
if (!namedQuery) {
throw new Meteor.Error('entry-point-not-found', `We could not find any collection or frozen-query with the name ${entryPointName}`)
} else {
return namedQuery.clone(data[entryPointName], ...args);
}
} }
return new Query(collection, data[collectionName], ...args); return new Query(collection, data[entryPointName], ...args);
} }

View file

@ -3,12 +3,5 @@ import Query from './query.js';
_.extend(Mongo.Collection.prototype, { _.extend(Mongo.Collection.prototype, {
createQuery(body, params = {}) { createQuery(body, params = {}) {
return new Query(this, body, params); return new Query(this, body, params);
},
createQueryFactory(body, params) {
const collection = this;
return () => {
return new Query(collection, body, params);
}
} }
}); });

View file

@ -1,5 +1,5 @@
function applyFilterRecursive(data, params) { function applyFilterRecursive(data, params = {}) {
if (data.$filter) { if (_.isFunction(data.$filter)) {
data.$filters = data.$filters || {}; data.$filters = data.$filters || {};
data.$options = data.$options || {}; data.$options = data.$options || {};

View file

@ -4,12 +4,19 @@ import applyFilterFunction from './lib/applyFilterFunction.js';
import hypernova from './hypernova/hypernova.js'; import hypernova from './hypernova/hypernova.js';
export default class Query { export default class Query {
constructor(collection, body, params = {}, options = {}) { constructor(collection, body, params = {}) {
this.collection = collection; this.collection = collection;
this.body = body; this.body = body;
this.subscriptionHandle = null; this.subscriptionHandle = null;
this._params = params; this._params = params;
this.debug = options.debug; }
clone(newParams) {
return new Query(
this.collection,
_.clone(this.body),
_.extend({}, this.params, newParams)
);
} }
get name() { get name() {
@ -80,17 +87,29 @@ export default class Query {
} }
} }
/**
* @param args
* @returns {*}
*/
fetchOne(...args) {
return _.first(this.fetch(...args));
}
/** /**
* Gets the count of matching elements. * Gets the count of matching elements.
* @param callback * @param callback
* @returns {any} * @returns {any}
*/ */
getCount(callback) { getCount(callback) {
if (Meteor.isClient && !callback) { if (Meteor.isClient) {
throw new Meteor.Error('not-allowed', 'You are on client so you must either provide a callback to get the count.'); if (!callback) {
throw new Meteor.Error('not-allowed', 'You are on client so you must either provide a callback to get the count.');
}
return Meteor.call(this.name + '.count', applyFilterFunction(this.body, this.params), callback);
} }
return Meteor.call(this.name + '.count', applyFilterFunction(this.body, this.params), callback); return this.collection.find(this.body.$filters || {}, {}).count();
} }
/** /**
@ -148,6 +167,6 @@ export default class Query {
applyFilterFunction(this.body, this.params) applyFilterFunction(this.body, this.params)
); );
return hypernova(node, options.userId, !!this.debug); return hypernova(node, options.userId);
} }
} }

View file

@ -60,6 +60,7 @@ describe('Query Client Tests', function () {
}) })
}); });
handle.stop();
done(); done();
} }
}) })

View file

@ -4,3 +4,7 @@ import './lib/query/extension.js';
export { export {
default as createQuery default as createQuery
} from './lib/query/createQuery.js'; } from './lib/query/createQuery.js';
export {
default as createNamedQuery
} from './lib/namedQuery/createNamedQuery.js';

View file

@ -1,6 +1,8 @@
import './lib/exposure/extension.js'; import './lib/exposure/extension.js';
import './lib/links/extension.js'; import './lib/links/extension.js';
import './lib/query/extension.js'; import './lib/query/extension.js';
import './lib/namedQuery/expose/extension.js';
import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions'; import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions';
checkNpmVersions({ checkNpmVersions({
@ -11,6 +13,10 @@ export {
default as createQuery default as createQuery
} from './lib/query/createQuery.js'; } from './lib/query/createQuery.js';
export {
default as createNamedQuery
} from './lib/namedQuery/createNamedQuery.js';
export { export {
default as Exposure default as Exposure
} from './lib/exposure/exposure.js'; } from './lib/exposure/exposure.js';

View file

@ -47,18 +47,25 @@ Package.onTest(function (api) {
api.use('practicalmeteor:chai'); api.use('practicalmeteor:chai');
// LINKS // LINKS
api.mainModule('lib/links/tests/main.js', 'server'); api.addFiles('lib/links/tests/main.js', 'server');
// EXPOSURE // EXPOSURE
api.mainModule('lib/exposure/testing/server.js', 'server'); api.addFiles('lib/exposure/testing/server.js', 'server');
api.mainModule('lib/exposure/testing/client.js', 'client'); api.addFiles('lib/exposure/testing/client.js', 'client');
// QUERY // QUERY
api.addFiles('lib/query/testing/bootstrap/index.js'); api.addFiles('lib/query/testing/bootstrap/index.js');
// When you play with tests you should comment this to make tests go faster. // When you play with tests you should comment this to make tests go faster.
api.addFiles('lib/query/testing/bootstrap/fixtures.js', 'server'); api.addFiles('lib/query/testing/bootstrap/fixtures.js', 'server');
api.addFiles('lib/query/testing/server.test.js', 'server');
api.addFiles('lib/query/testing/client.test.js', 'client');
// NAMED QUERY
api.addFiles('lib/namedQuery/testing/bootstrap/both.js');
api.addFiles('lib/namedQuery/testing/bootstrap/client.js', 'client');
api.addFiles('lib/namedQuery/testing/bootstrap/server.js', 'server');
api.addFiles('lib/namedQuery/testing/server.test.js', 'server');
api.addFiles('lib/namedQuery/testing/client.test.js', 'client');
api.mainModule('lib/query/testing/server.test.js', 'server');
api.mainModule('lib/query/testing/client.test.js', 'client');
}); });