Skip to content

Commit

Permalink
Merge pull request #403 from bhunjadi/feature/link-foreign-field
Browse files Browse the repository at this point in the history
Links with `foreignIdentityField`
  • Loading branch information
StorytellerCZ authored Nov 6, 2023
2 parents 931801e + fb271e9 commit c27e240
Show file tree
Hide file tree
Showing 25 changed files with 588 additions and 55 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store

.idea/
node_modules/
node_modules/
.vscode/
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,26 @@ Result:
}
]
```

### Testing

You can create `test` directory and configure dependencies (working directory is the root of this repo):
```
# create meteor app for testing
# you can add a specific release with --release flag, this will just create the app with the latest release
meteor create --bare test
cd test
# install npm dependencies used for testing
meteor npm i --save [email protected] [email protected] [email protected] chai
# Running tests (always from ./test directory)
METEOR_PACKAGE_DIRS="../" TEST_BROWSER_DRIVER=chrome meteor test-packages --once --driver-package meteortesting:mocha ../
```

If you use `TEST_BROWSER_DRIVER=chrome` you have to have chrome installed in the test environment. Otherwise, you can just run tests in your browser.

Another option is to use `puppeteer` as a driver. You'll have to install it with `meteor npm i puppeteer@10`. Note that the latest versions don't work with Node 14.

With `--port=X` you can run tests on port X.

Omit `--once` and mocha will run in watch mode.
37 changes: 37 additions & 0 deletions docs/linking_collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,43 @@ Meteor.users.createQuery({

`paymentProfile` inside `user` will be an object because it knows it should be unique.

## Foreign identity field

When you add a link by default grapher tries to match against `_id` of the linked collection.

Consider a system with two collections:

- `Appointments` - a collection of appointments with startDate, endDate, etc.
- `Tasks` - a collection of tasks which has `referenceId` field which is `_id` of the appointment or some other entity. Tasks generally don't know anything about the appointment, they just have a reference to it.

We can utilize `foreignIdentityField` option and do this:

```js
Appointments.addLinks({
tasks: {
collection: Tasks,
type: "many",
field: "_id", // field from Appointments collection
foreignIdentityField: "referenceId", // field from Tasks collection
},
});
```

Now you can query for appointments and get all tasks for each appointment:

```js
Appointments.createQuery({
$filters: { ... },
tasks: {
title: 1,
},
startDate: 1,
endDate: 1,
}).fetch();
```

If your foreign identity field is unique inside linked collection (in this case Tasks), you can use `type: "one"` and get a single task instead of an array.

## Data Consistency

We clean out leftover links from deleted collection items.
Expand Down
3 changes: 2 additions & 1 deletion lib/links/config.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ export const LinkConfigSchema = {
);
})
),
foreignIdentityField: Match.Maybe(String),
field: Match.Maybe(String),
metadata: Match.Maybe(Boolean),
inversedBy: Match.Maybe(String),
index: Match.Maybe(Boolean),
unique: Match.Maybe(Boolean),
autoremove: Match.Maybe(Boolean),
denormalize: Match.Maybe(Match.ObjectIncluding(DenormalizeSchema)),
};
};
41 changes: 24 additions & 17 deletions lib/links/lib/createSearchFilters.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,43 @@
import sift from 'sift';
import dot from 'dot-object';

export default function createSearchFilters(object, fieldStorage, strategy, isVirtual, metaFilters) {
if (!isVirtual) {
export default function createSearchFilters(object, linker, metaFilters) {
const fieldStorage = linker.linkStorageField;

const strategy = linker.strategy;
if (!linker.isVirtual()) {
switch (strategy) {
case 'one': return createOne(object, fieldStorage);
case 'one': return createOne(object, linker);
case 'one-meta': return createOneMeta(object, fieldStorage, metaFilters);
case 'many': return createMany(object, fieldStorage);
case 'many': return createMany(object, linker);
case 'many-meta': return createManyMeta(object, fieldStorage, metaFilters);
default:
throw new Meteor.Error(`Invalid linking strategy: ${strategy}`)
}
} else {
switch (strategy) {
case 'one': return createOneVirtual(object, fieldStorage);
case 'one': return createOneVirtual(object, linker);
case 'one-meta': return createOneMetaVirtual(object, fieldStorage, metaFilters);
case 'many': return createManyVirtual(object, fieldStorage);
case 'many': return createManyVirtual(object, linker);
case 'many-meta': return createManyMetaVirtual(object, fieldStorage, metaFilters);
default:
throw new Meteor.Error(`Invalid linking strategy: ${strategy}`)
}
}
}

export function createOne(object, fieldStorage) {
export function createOne(object, linker) {
return {
_id: dot.pick(fieldStorage, object)
// Using {$in: []} as a workaround because foreignIdentityField which is not _id is not required to be set
// and {something: undefined} in query returns all the records.
// $in: [] ensures that nothing will be returned for this query
[linker.foreignIdentityField]: dot.pick(linker.linkStorageField, object) || {$in: []},
};
}

export function createOneVirtual(object, fieldStorage) {
export function createOneVirtual(object, linker) {
return {
[fieldStorage]: object._id
[linker.linkStorageField]: object[linker.foreignIdentityField] || {$in: []}
};
}

Expand Down Expand Up @@ -62,25 +68,26 @@ export function createOneMetaVirtual(object, fieldStorage, metaFilters) {
return filters;
}

export function createMany(object, fieldStorage) {
const [root, ...nested] = fieldStorage.split('.');
export function createMany(object, linker) {
const [root, ...nested] = linker.linkStorageField.split('.');
if (nested.length > 0) {
const arr = object[root];
const ids = arr ? _.uniq(_.union(arr.map(obj => _.isObject(obj) ? dot.pick(nested.join('.'), obj) : []))) : [];
return {
_id: {$in: ids}
[linker.foreignIdentityField]: {$in: ids}
};
}
const value = object[linker.linkStorageField];
return {
_id: {
$in: object[fieldStorage] || []
[linker.foreignIdentityField]: {
$in: _.isArray(value) ? value : (value ? [value] : []),
}
};
}

export function createManyVirtual(object, fieldStorage) {
export function createManyVirtual(object, linker) {
return {
[fieldStorage]: object._id
[linker.linkStorageField]: object[linker.foreignIdentityField] || {$in: []},
};
}

Expand Down
14 changes: 9 additions & 5 deletions lib/links/linkTypes/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ export default class Link {

const searchFilters = createSearchFilters(
this.object,
this.linkStorageField,
linker.strategy,
linker.isVirtual(),
this.linker,
$metaFilters
);

let appliedFilters = _.extend({}, filters, searchFilters);

// console.log('search filters', searchFilters);

// see https://github.com/cult-of-coders/grapher/issues/134
// happens due to recursive importing of modules
// TODO: find another way to do this
Expand Down Expand Up @@ -133,9 +133,13 @@ export default class Link {
ids = [ids];
}

// console.log('validate ids', ids);

const foreignIdentityField = this.linker.foreignIdentityField;

const validIds = this.linkedCollection.find({
_id: {$in: ids}
}, {fields: {_id: 1}}).fetch().map(doc => doc._id);
[foreignIdentityField]: {$in: ids}
}, {fields: {[foreignIdentityField]: 1}}).fetch().map(doc => doc[foreignIdentityField]);

if (validIds.length != ids.length) {
throw new Meteor.Error('not-found', `You tried to create links with non-existing id(s) inside "${this.linkedCollection._name}": ${_.difference(ids, validIds).join(', ')}`)
Expand Down
10 changes: 4 additions & 6 deletions lib/links/linkTypes/lib/smartArguments.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,19 @@ export default new class {
getIds(what, options) {
if (Array.isArray(what)) {
return _.map(what, (subWhat) => {
return this.getId(subWhat, options)
})
return this.getId(subWhat, options);
}).filter(id => typeof id === 'string');
} else {
return [this.getId(what, options)];
return [this.getId(what, options)].filter(id => typeof id === 'string');
}

throw new Meteor.Error('invalid-type', `Unrecognized type: ${typeof what} for managing links`);
}

getId(what, options) {
if (typeof what === 'string') {
return what;
}

if (typeof what === 'object') {
if (_.isObject(what)) {
if (!what._id && options.saveToDatabase) {
what._id = options.collection.insert(what);
}
Expand Down
23 changes: 16 additions & 7 deletions lib/links/linkTypes/linkMany.js
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default class LinkMany extends Link {
if (this.isVirtual) throw new Meteor.Error('not-allowed', 'Add/Remove operations should be done from the owner of the relationship');

this.clean();

const field = this.linkStorageField;
const [root, ...nested] = field.split('.');

Expand All @@ -67,15 +68,23 @@ export default class LinkMany extends Link {
// update the field
this.object[root] = _.filter(
this.object[root],
_id => !_.contains(_ids, nested.length > 0 ? dot.pick(nested.join('.'), _id) : _id)
_id => !_.contains(_ids, nested.length > 0 ? dot.pick(nested.join('.'), _id) : _id)
);

// update the db
let modifier = {
$pullAll: {
[root]: nested.length > 0 ? { [nested.join('.')]: _ids } : _ids,
},
};
let modifier;
if (this.linker.foreignIdentityField === '_id') {
// update the db
modifier = {
$pullAll: {
[root]: nested.length > 0 ? { [nested.join('.')]: _ids } : _ids,
},
};
}
else {
modifier = {
$unset: {[root]: 1},
};
}

this.linker.mainCollection.update(this.object._id, modifier);

Expand Down
23 changes: 18 additions & 5 deletions lib/links/linker.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ export default class Linker {
return this.linkConfig.field;
}

/**
* Returns foreign field for querying linked collection
*/
get foreignIdentityField() {
if (this.isVirtual()) {
return this.linkConfig.relatedLinker.linkConfig.foreignIdentityField || '_id';
}
return this.linkConfig.foreignIdentityField || '_id';
}

/**
* The collection that is linked with the current collection
* @returns Mongo.Collection
Expand Down Expand Up @@ -377,11 +387,14 @@ export default class Linker {

if (!this.isVirtual()) {
this.mainCollection.after.remove((userId, doc) => {
this.getLinkedCollection().remove({
_id: {
$in: smartArguments.getIds(doc[this.linkStorageField]),
},
});
const ids = smartArguments.getIds(doc[this.linkStorageField]);
if (ids.length > 0) {
this.getLinkedCollection().remove({
[this.foreignIdentityField]: {
$in: ids,
},
});
}
});
} else {
this.mainCollection.after.remove((userId, doc) => {
Expand Down
Loading

0 comments on commit c27e240

Please sign in to comment.