grapher/lib/links/linker.js
2018-10-19 10:22:46 +03:00

472 lines
14 KiB
JavaScript

import LinkMany from './linkTypes/linkMany.js';
import LinkManyMeta from './linkTypes/linkManyMeta.js';
import LinkOne from './linkTypes/linkOne.js';
import LinkOneMeta from './linkTypes/linkOneMeta.js';
import { LinkConfigSchema, LinkConfigDefaults } from './config.schema.js';
import smartArguments from './linkTypes/lib/smartArguments';
import dot from 'dot-object';
import { check } from 'meteor/check';
import { _ } from 'meteor/underscore';
export default class Linker {
/**
* @param mainCollection
* @param linkName
* @param linkConfig
*/
constructor(mainCollection, linkName, linkConfig) {
this.mainCollection = mainCollection;
this.linkConfig = Object.assign({}, LinkConfigDefaults, linkConfig);
this.linkName = linkName;
// check linkName must not exist in schema
this._validateAndClean();
// initialize cascade removal hooks.
this._initAutoremove();
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
if (!linkConfig.autoremove) {
this._handleReferenceRemovalForVirtualLinks();
}
} else {
this._initIndex();
}
}
/**
* Values which represent for the relation a single link
* @returns {string[]}
*/
get oneTypes() {
return ['one', '1'];
}
/**
* Returns the strategies: one, many, one-meta, many-meta
* @returns {string}
*/
get strategy() {
let strategy = this.isMany() ? 'many' : 'one';
if (this.linkConfig.metadata) {
strategy += '-meta';
}
return strategy;
}
/**
* Returns the field name in the document where the actual relationships are stored.
* @returns string
*/
get linkStorageField() {
if (this.isVirtual()) {
return this.linkConfig.relatedLinker.linkStorageField;
}
return this.linkConfig.field;
}
/**
* The collection that is linked with the current collection
* @returns Mongo.Collection
*/
getLinkedCollection() {
return this.linkConfig.collection;
}
/**
* If the relationship for this link is of "many" type.
*/
isMany() {
return !this.isSingle();
}
/**
* If the relationship for this link contains metadata
*/
isMeta() {
if (this.isVirtual()) {
return this.linkConfig.relatedLinker.isMeta();
}
return !!this.linkConfig.metadata;
}
/**
* @returns {boolean}
*/
isSingle() {
if (this.isVirtual()) {
return this.linkConfig.relatedLinker.isSingle();
}
return _.contains(this.oneTypes, this.linkConfig.type);
}
/**
* @returns {boolean}
*/
isVirtual() {
return !!this.linkConfig.inversedBy;
}
/**
* Should return a single result.
*/
isOneResult() {
return (
(this.isVirtual() &&
this.linkConfig.relatedLinker.linkConfig.unique) ||
(!this.isVirtual() && this.isSingle())
);
}
/**
* @param object
* @param collection To impersonate the getLinkedCollection() of the "Linker"
*
* @returns {LinkOne|LinkMany|LinkManyMeta|LinkOneMeta|LinkResolve}
*/
createLink(object, collection = null) {
let helperClass = this._getHelperClass();
return new helperClass(this, object, collection);
}
/**
* @returns {*}
* @private
*/
_validateAndClean() {
if (!this.linkConfig.collection) {
throw new Meteor.Error(
'invalid-config',
`For the link ${
this.linkName
} you did not provide a collection.`
);
}
if (typeof this.linkConfig.collection === 'string') {
const collectionName = this.linkConfig.collection;
this.linkConfig.collection = Mongo.Collection.get(collectionName);
if (!this.linkConfig.collection) {
throw new Meteor.Error(
'invalid-collection',
`Could not find a collection with the name: ${collectionName}`
);
}
}
if (this.isVirtual()) {
return this._prepareVirtual();
} else {
if (!this.linkConfig.type) {
this.linkConfig.type = 'one';
}
if (!this.linkConfig.field) {
this.linkConfig.field = this._generateFieldName();
} else {
if (this.linkConfig.field == this.linkName) {
throw new Meteor.Error(
'invalid-config',
`For the link ${
this.linkName
} you must not use the same name for the field, otherwise it will cause conflicts when fetching data`
);
}
}
}
check(this.linkConfig, LinkConfigSchema);
}
/**
* We need to apply same type of rules in this case.
* @private
*/
_prepareVirtual() {
const { collection, inversedBy } = this.linkConfig;
let linker = collection.getLinker(inversedBy);
if (!linker) {
// it is possible that the collection doesn't have a linker created yet.
// so we will create it on startup after all links have been defined
Meteor.startup(() => {
linker = collection.getLinker(inversedBy);
if (!linker) {
throw new Meteor.Error(
`You tried setting up an inversed link in "${
this.mainCollection._name
}" pointing to collection: "${
collection._name
}" link: "${inversedBy}", but no such link was found. Maybe a typo ?`
);
} else {
this._setupVirtualConfig(linker);
}
});
} else {
this._setupVirtualConfig(linker);
}
}
/**
* @param linker
* @private
*/
_setupVirtualConfig(linker) {
const virtualLinkConfig = linker.linkConfig;
if (!virtualLinkConfig) {
throw new Meteor.Error(
`There is no link-config for the related collection on ${inversedBy}. Make sure you added the direct links before specifying virtual ones.`
);
}
_.extend(this.linkConfig, {
metadata: virtualLinkConfig.metadata,
relatedLinker: linker,
});
}
/**
* Depending on the strategy, we create the proper helper class
* @private
*/
_getHelperClass() {
switch (this.strategy) {
case 'many-meta':
return LinkManyMeta;
case 'many':
return LinkMany;
case 'one-meta':
return LinkOneMeta;
case 'one':
return LinkOne;
}
throw new Meteor.Error(
'invalid-strategy',
`${this.strategy} is not a valid strategy`
);
}
/**
* If field name not present, we generate it.
* @private
*/
_generateFieldName() {
let cleanedCollectionName = this.linkConfig.collection._name.replace(
/\./g,
'_'
);
let defaultFieldPrefix = this.linkName + '_' + cleanedCollectionName;
switch (this.strategy) {
case 'many-meta':
return `${defaultFieldPrefix}_metas`;
case 'many':
return `${defaultFieldPrefix}_ids`;
case 'one-meta':
return `${defaultFieldPrefix}_meta`;
case 'one':
return `${defaultFieldPrefix}_id`;
}
}
/**
* When a link that is declared virtual is removed, the reference will be removed from every other link.
* @private
*/
_handleReferenceRemovalForVirtualLinks() {
this.mainCollection.after.remove((userId, doc) => {
// this problem may occur when you do a .remove() before Meteor.startup()
if (!this.linkConfig.relatedLinker) {
console.warn(
`There was an error finding the link for removal for collection: "${
this.mainCollection._name
}" with link: "${
this.linkName
}". This may occur when you do a .remove() before Meteor.startup()`
);
return;
}
let accessor = this.createLink(doc);
_.each(accessor.fetch(), linkedObj => {
const { relatedLinker } = this.linkConfig;
// We do this check, to avoid self-referencing hell when defining virtual links
// Virtual links if not found "compile-time", we will try again to reprocess them on Meteor.startup
// if a removal happens before Meteor.startup this may fail
if (relatedLinker) {
let link = relatedLinker.createLink(linkedObj);
if (relatedLinker.isSingle()) {
link.unset();
} else {
link.remove(doc);
}
}
});
});
}
_initIndex() {
if (Meteor.isServer) {
let field = this.linkConfig.field;
if (this.linkConfig.metadata) {
field = field + '._id';
}
if (this.linkConfig.index) {
if (this.isVirtual()) {
throw new Meteor.Error(
'You cannot set index on an inversed link.'
);
}
let options;
if (this.linkConfig.unique) {
options = { unique: true };
}
this.mainCollection._ensureIndex({ [field]: 1 }, options);
} else {
if (this.linkConfig.unique) {
if (this.isVirtual()) {
throw new Meteor.Error(
'You cannot set unique property on an inversed link.'
);
}
this.mainCollection._ensureIndex(
{
[field]: 1,
},
{ unique: true }
);
}
}
}
}
_initAutoremove() {
if (!this.linkConfig.autoremove) {
return;
}
if (!this.isVirtual()) {
this.mainCollection.after.remove((userId, doc) => {
this.getLinkedCollection().remove({
_id: {
$in: smartArguments.getIds(doc[this.linkStorageField]),
},
});
});
} else {
this.mainCollection.after.remove((userId, doc) => {
const linker = this.mainCollection.getLink(doc, this.linkName);
const ids = linker
.find({}, { fields: { _id: 1 } })
.fetch()
.map(item => item._id);
this.getLinkedCollection().remove({
_id: { $in: ids },
});
});
}
}
/**
* Initializes denormalization using herteby:denormalize
* @private
*/
_initDenormalization() {
if (!this.linkConfig.denormalize || !Meteor.isServer) {
return;
}
const packageExists = !!Package['herteby:denormalize'];
if (!packageExists) {
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.denormalize;
let cacheConfig;
let referenceFieldSuffix = '';
if (this.isMeta()) {
referenceFieldSuffix = this.isSingle() ? '._id' : ':_id';
}
if (this.isVirtual()) {
let inversedLink = this.linkConfig.relatedLinker.linkConfig;
let type =
inversedLink.type == 'many' ? 'many-inverse' : 'inversed';
cacheConfig = {
type: type,
collection: this.linkConfig.collection,
fields: body,
referenceField: inversedLink.field + referenceFieldSuffix,
cacheField: field,
bypassSchema: !!bypassSchema,
};
} else {
cacheConfig = {
type: this.linkConfig.type,
collection: this.linkConfig.collection,
fields: body,
referenceField: this.linkConfig.field + referenceFieldSuffix,
cacheField: field,
bypassSchema: !!bypassSchema,
};
}
if (this.isVirtual()) {
Meteor.startup(() => {
this.mainCollection.cache(cacheConfig);
});
} else {
this.mainCollection.cache(cacheConfig);
}
}
/**
* Verifies if this linker is denormalized. It can be denormalized from the inverse side as well.
*
* @returns {boolean}
* @private
*/
isDenormalized() {
return !!this.linkConfig.denormalize;
}
/**
* Verifies if the body of the linked element does not contain fields outside the cache body
*
* @param body
* @returns {boolean}
* @private
*/
isSubBodyDenormalized(body) {
const cacheBody = this.linkConfig.denormalize.body;
const cacheBodyFields = _.keys(dot.dot(cacheBody));
const bodyFields = _.keys(dot.dot(_.omit(body, '_id')));
return _.difference(bodyFields, cacheBodyFields).length === 0;
}
}