Began rewrite of all parenting functions to nested sets
What have I gotten myself into :(
This commit is contained in:
@@ -50,3 +50,4 @@ littledata:synced-cron
|
||||
typescript@4.9.4
|
||||
seba:minifiers-autoprefixer
|
||||
mixmax:smart-disconnect
|
||||
zodern:types
|
||||
|
||||
@@ -125,3 +125,4 @@ url@1.3.2
|
||||
webapp@1.13.5
|
||||
webapp-hashing@1.1.1
|
||||
zer0th:meteor-vuetify-loader@0.1.41
|
||||
zodern:types@1.0.9
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import SimpleSchema from 'simpl-schema';
|
||||
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
|
||||
|
||||
const RefSchema = new SimpleSchema({
|
||||
id: {
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
// TODO: Rather than indexing this field, index `ancestors.0.id` to only
|
||||
// index the root of the ancestor heirarchy to significantly reduce
|
||||
// index size and improve performance
|
||||
// All queries on an ancestor document need to target `ancestors.0.id` first
|
||||
// before targeting a younger ancestor
|
||||
index: 1
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
max: STORAGE_LIMITS.collectionName,
|
||||
},
|
||||
});
|
||||
|
||||
let ChildSchema = new SimpleSchema({
|
||||
order: {
|
||||
type: Number,
|
||||
},
|
||||
parent: {
|
||||
type: RefSchema,
|
||||
optional: true,
|
||||
},
|
||||
// Change this from ancestor list to left and right indices
|
||||
ancestors: {
|
||||
type: Array,
|
||||
defaultValue: [],
|
||||
maxCount: STORAGE_LIMITS.ancestorCount,
|
||||
},
|
||||
'ancestors.$': {
|
||||
type: RefSchema,
|
||||
},
|
||||
});
|
||||
|
||||
export default ChildSchema;
|
||||
export { RefSchema };
|
||||
78
app/imports/api/parenting/ChildSchema.ts
Normal file
78
app/imports/api/parenting/ChildSchema.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import SimpleSchema from 'simpl-schema';
|
||||
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
|
||||
|
||||
const RefSchema = new SimpleSchema({
|
||||
id: {
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
max: STORAGE_LIMITS.collectionName,
|
||||
},
|
||||
});
|
||||
|
||||
const ChildSchema = new SimpleSchema({
|
||||
root: {
|
||||
type: RefSchema,
|
||||
},
|
||||
'root.id': {
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
index: 1,
|
||||
},
|
||||
// Parent id of a document in the same collection
|
||||
// Undefined parent id implies the root is the parent
|
||||
parentId: {
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
optional: true,
|
||||
},
|
||||
/**
|
||||
* The tree structure goes as follows where the numbering follows a counterclockwise depth first
|
||||
* path around the tree. The canonical structure comes from the root and parentId references,
|
||||
* while the left and right numbering is used to optimize ancestor queries.
|
||||
*
|
||||
* Left can be used as the canonical ordering of properties in an expanded tree folder view.
|
||||
*
|
||||
* 1 Books 12
|
||||
* ┃
|
||||
* 2 Programming 11
|
||||
* ┏━━━━━━━━┻━━━━━━━━━┓
|
||||
* 3 Languages 4 5 Databases 10
|
||||
* ┏━━━━━━━┻━━━━━━━┓
|
||||
* 6 MongoDB 7 8 dbm 9
|
||||
*/
|
||||
left: {
|
||||
type: Number,
|
||||
index: 1,
|
||||
},
|
||||
right: {
|
||||
type: Number,
|
||||
index: 1,
|
||||
}
|
||||
});
|
||||
|
||||
export interface Reference {
|
||||
collection: string,
|
||||
id: string,
|
||||
}
|
||||
|
||||
export interface TreeDoc {
|
||||
_id: string,
|
||||
root: Reference,
|
||||
parentId?: string,
|
||||
left: number,
|
||||
right: number,
|
||||
}
|
||||
|
||||
export const treeDocFields = {
|
||||
_id: 1,
|
||||
root: 1,
|
||||
parentId: 1,
|
||||
left: 1,
|
||||
right: 1,
|
||||
}
|
||||
|
||||
export default ChildSchema;
|
||||
export { RefSchema };
|
||||
@@ -1,12 +0,0 @@
|
||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
||||
|
||||
const docNotFoundError = function({id, collection}){
|
||||
throw new Meteor.Error('document-not-found',
|
||||
`No document could be found with id: ${id} in ${collection}`
|
||||
);
|
||||
};
|
||||
|
||||
export default function fetchDocByRef({id, collection}, options){
|
||||
return getCollectionByName(collection).findOne(id, options) ||
|
||||
docNotFoundError({id, collection});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
const collectionDoesntExistError = function(collectionName){
|
||||
throw new Meteor.Error('bad-collection-reference',
|
||||
`Parent references collection ${collectionName}, which does not exist`
|
||||
);
|
||||
};
|
||||
|
||||
const getCollectionByName = function(name){
|
||||
return Mongo.Collection.get(name) || collectionDoesntExistError(name);
|
||||
};
|
||||
|
||||
export default getCollectionByName;
|
||||
@@ -1,27 +0,0 @@
|
||||
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
|
||||
|
||||
export default function getDescendantsInDepthFirstOrder({
|
||||
collection,
|
||||
ancestorId,
|
||||
filter,
|
||||
options = {fields: {order: 1, ancestors: 1}},
|
||||
}){
|
||||
let forest = nodesToTree({collection, ancestorId, filter, options});
|
||||
let orderMemo = getDocsInDepthFirstOrder(forest);
|
||||
return orderMemo;
|
||||
}
|
||||
|
||||
export function getDocsInDepthFirstOrder(forest){
|
||||
let docs = [];
|
||||
forest.forEach(node => {
|
||||
addNodeAndTraverse(node, docs)
|
||||
});
|
||||
return docs;
|
||||
}
|
||||
|
||||
function addNodeAndTraverse(node, docs){
|
||||
docs.push(node.node);
|
||||
node.children.forEach(child => {
|
||||
addNodeAndTraverse(child, docs)
|
||||
});
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { union, difference, sortBy, findLast, intersection } from 'lodash';
|
||||
|
||||
export function nodeArrayToTree(nodes) {
|
||||
// Store a dict and list of all the nodes
|
||||
let nodeIndex = {};
|
||||
let nodeList = [];
|
||||
nodes.forEach(node => {
|
||||
let treeNode = {
|
||||
node: node,
|
||||
children: [],
|
||||
};
|
||||
nodeIndex[node._id] = treeNode;
|
||||
nodeList.push(treeNode);
|
||||
});
|
||||
// Create a forest of trees
|
||||
let forest = [];
|
||||
// Either the node is a child of its nearest found ancestor, or in the forest as a root
|
||||
nodeList.forEach(treeNode => {
|
||||
let ancestorInForest = findLast(
|
||||
treeNode.node.ancestors,
|
||||
ancestor => !!nodeIndex[ancestor.id]
|
||||
);
|
||||
if (ancestorInForest) {
|
||||
nodeIndex[ancestorInForest.id].children.push(treeNode);
|
||||
} else {
|
||||
forest.push(treeNode);
|
||||
}
|
||||
});
|
||||
forest.nodeIndex = nodeIndex;
|
||||
return forest;
|
||||
}
|
||||
|
||||
// Fetch the documents from a collection, and return the tree of those documents
|
||||
export default function nodesToTree({
|
||||
collection, ancestorId, filter, options = {},
|
||||
includeFilteredDocAncestors = false, includeFilteredDocDescendants = false
|
||||
}) {
|
||||
// Setup the filter
|
||||
let collectionFilter = {
|
||||
'ancestors.id': ancestorId,
|
||||
'removed': { $ne: true },
|
||||
};
|
||||
if (filter) {
|
||||
collectionFilter = {
|
||||
...collectionFilter,
|
||||
...filter,
|
||||
}
|
||||
}
|
||||
// Set up the options
|
||||
let collectionSort = {
|
||||
order: 1
|
||||
};
|
||||
if (options && options.sort) {
|
||||
collectionSort = {
|
||||
...collectionSort,
|
||||
...options.sort,
|
||||
}
|
||||
}
|
||||
let collectionOptions = {
|
||||
sort: collectionSort,
|
||||
}
|
||||
if (options) {
|
||||
collectionOptions = {
|
||||
...collectionOptions,
|
||||
...options,
|
||||
}
|
||||
}
|
||||
// Find all the nodes that match the filter
|
||||
let docs = collection.find(collectionFilter, collectionOptions).map(doc => {
|
||||
if (!filter) return doc;
|
||||
// Mark the nodes that were found by the custom filter
|
||||
doc._matchedDocumentFilter = true;
|
||||
return doc;
|
||||
});
|
||||
let ancestors = [];
|
||||
let ancestorIds = [];
|
||||
let docIds = [];
|
||||
if (filter && (includeFilteredDocAncestors || includeFilteredDocDescendants)) {
|
||||
docIds = docs.map(doc => doc._id)
|
||||
}
|
||||
if (filter && includeFilteredDocAncestors) {
|
||||
// Add all ancestor ids to an array
|
||||
docs.forEach(doc => {
|
||||
ancestorIds = union(ancestorIds, doc.ancestors.map(ref => ref.id));
|
||||
});
|
||||
// Get all the docs that are also ancestors and mark them
|
||||
docs.forEach(doc => {
|
||||
if (ancestorIds.includes(doc._id)) {
|
||||
doc._ancestorOfMatchedDocument = true;
|
||||
}
|
||||
});
|
||||
// Remove the ancestor IDs of docs we have already found
|
||||
ancestorIds = difference(ancestorIds, docIds);
|
||||
// Get the ancestor docs from the collection, don't worry about `removed` docs,
|
||||
// if their descendant was not removed, neither are they
|
||||
ancestors = collection.find({ _id: { $in: ancestorIds } }).map(doc => {
|
||||
// Mark that the nodes are ancestors of the found nodes
|
||||
doc._ancestorOfMatchedDocument = true;
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
let descendants = [];
|
||||
if (filter && includeFilteredDocDescendants) {
|
||||
let exludeIds = union(ancestorIds, docIds);
|
||||
descendants = collection.find({
|
||||
'_id': { $nin: exludeIds },
|
||||
'ancestors.id': { $in: docIds },
|
||||
'removed': { $ne: true },
|
||||
}).map(doc => {
|
||||
// Mark that the nodes are descendants of the found nodes
|
||||
doc._descendantOfMatchedDocument = true;
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
let nodes = sortBy([
|
||||
...ancestors,
|
||||
...docs,
|
||||
...descendants
|
||||
], 'order');
|
||||
// Find all the nodes
|
||||
return nodeArrayToTree(nodes);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
|
||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
||||
import getDescendantsInDepthFirstOrder from '/imports/api/parenting/getDescendantsInDepthFirstOrder.js'
|
||||
|
||||
// Docs keep track of their depth-first order amongst their entire ancestor tree
|
||||
export function compareOrder(docA, docB){
|
||||
// < 0 if A comes before B
|
||||
// = 0 if A and B are the same order
|
||||
// > 0 if B comes before A
|
||||
|
||||
// They must share a root ancestor to be meaningfully sorted
|
||||
if (docA.ancestors[0].id !== docB.ancestors[0].id){
|
||||
return 0;
|
||||
} else {
|
||||
return docA.order - docB.order;
|
||||
}
|
||||
}
|
||||
|
||||
export function getHighestOrder({collection, ancestorId}){
|
||||
const highestOrderedDoc = collection.findOne({
|
||||
'ancestors.id': ancestorId,
|
||||
}, {
|
||||
fields: {order: 1},
|
||||
sort: {order: -1},
|
||||
});
|
||||
return highestOrderedDoc ? highestOrderedDoc.order : -1;
|
||||
}
|
||||
|
||||
export function setDocToLastOrder({collection, doc}){
|
||||
doc.order = getHighestOrder({
|
||||
collection,
|
||||
ancestorId: doc.ancestors[0].id,
|
||||
}) + 1;
|
||||
}
|
||||
|
||||
// update the order of a doc, and shift the related docs around to suit the new
|
||||
// order
|
||||
function cheapUpdateDocOrder({docRef, order}){
|
||||
let doc = fetchDocByRef(docRef, {fields: {
|
||||
order: 1,
|
||||
parent: 1,
|
||||
}});
|
||||
let collection = getCollectionByName(docRef.collection);
|
||||
const currentOrder = doc.order;
|
||||
if (currentOrder === order){
|
||||
return;
|
||||
} else {
|
||||
// First move the documents that are in the way
|
||||
let inBetweenSelector, increment;
|
||||
if (order > currentOrder){
|
||||
// Move in-between docs backward
|
||||
inBetweenSelector = {
|
||||
$gt: currentOrder,
|
||||
$lte: order
|
||||
};
|
||||
increment = -1;
|
||||
} else if (order < currentOrder){
|
||||
// Move in-between docs forward
|
||||
inBetweenSelector = {
|
||||
$lt: currentOrder,
|
||||
$gte: order
|
||||
};
|
||||
increment = 1;
|
||||
}
|
||||
collection.update({
|
||||
'ancestors.id': doc.ancestors[0].id,
|
||||
order: inBetweenSelector,
|
||||
}, {
|
||||
$inc: {order: increment},
|
||||
}, {
|
||||
multi: true,
|
||||
selector: {type: 'any'},
|
||||
});
|
||||
// Then move the document itself
|
||||
collection.update(doc._id, {$set: {order}}, {selector: {type: 'any'}});
|
||||
}
|
||||
}
|
||||
|
||||
export function cheapRemovedDocAtOrder({collection, doc}){
|
||||
// Decrement the order of all docs after the removed doc
|
||||
collection.update({
|
||||
'ancestors.id': doc.ancestors[0].id,
|
||||
order: {$gt: doc.order},
|
||||
}, {
|
||||
$inc: {order: -1},
|
||||
}, {
|
||||
multi: true,
|
||||
selector: {type: 'any'},
|
||||
});
|
||||
}
|
||||
|
||||
export function cheapInsertedDocAtOrder({collection, ancestorId, order}){
|
||||
// Increment the order of all docs after the inserted doc
|
||||
collection.update({
|
||||
'ancestors.id': ancestorId,
|
||||
order: {$gte: order},
|
||||
}, {
|
||||
$inc: {order: 1},
|
||||
}, {
|
||||
multi: true,
|
||||
selector: {type: 'any'},
|
||||
});
|
||||
}
|
||||
|
||||
// Update the order a single doc and re-order the entire related doc list
|
||||
// with the change
|
||||
export function safeUpdateDocOrder({docRef, order}){
|
||||
let collection = getCollectionByName(docRef.collection);
|
||||
// Put the new doc half a step in front of its new order
|
||||
// to ensure it's in front of whichever doc was there before
|
||||
collection.update(docRef.id, {
|
||||
$set: {order}
|
||||
}, {
|
||||
selector: {type: 'any'}
|
||||
});
|
||||
// reorder all related docs so that order is back to being a continous
|
||||
// set of whole numbers
|
||||
let movedDoc = fetchDocByRef(docRef, {fields: {ancestors: 1}});
|
||||
let ancestorId = movedDoc.ancestors[0].id;
|
||||
reorderDocs({collection, ancestorId});
|
||||
}
|
||||
|
||||
export function reorderDocs({collection, ancestorId}){
|
||||
let orderedDocs = getDescendantsInDepthFirstOrder({collection, ancestorId});
|
||||
let bulkWrite = [];
|
||||
orderedDocs.forEach((doc, index) => {
|
||||
if (doc.order !== index){
|
||||
bulkWrite.push({
|
||||
updateOne : {
|
||||
filter: {_id: doc._id},
|
||||
update: {$set: {order: index}},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
if (Meteor.isServer && bulkWrite.length){
|
||||
collection.rawCollection().bulkWrite(
|
||||
bulkWrite,
|
||||
{ordered : false},
|
||||
function(e){
|
||||
if (e) {
|
||||
console.error('Bulk write failed: ');
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
bulkWrite.forEach(op => {
|
||||
collection.update(
|
||||
op.updateOne.filter,
|
||||
op.updateOne.update,
|
||||
{selector: {type: 'any'}}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,9 @@ import SimpleSchema from 'simpl-schema';
|
||||
import { union } from 'lodash';
|
||||
import { ValidatedMethod } from 'meteor/mdg:validated-method';
|
||||
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
|
||||
import { updateParent } from '/imports/api/parenting/parenting.js';
|
||||
import { reorderDocs, safeUpdateDocOrder } from '/imports/api/parenting/order.js';
|
||||
import { RefSchema } from '/imports/api/parenting/ChildSchema.js';
|
||||
import { changeParent, fetchDocByRef, getCollectionByName } from '/imports/api/parenting/parentingFunctions';
|
||||
import { RefSchema } from '/imports/api/parenting/ChildSchema';
|
||||
import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js';
|
||||
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
|
||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
||||
import Creatures from '/imports/api/creature/creatures/Creatures.js';
|
||||
|
||||
const organizeDoc = new ValidatedMethod({
|
||||
@@ -33,47 +30,45 @@ const organizeDoc = new ValidatedMethod({
|
||||
numRequests: 5,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
run({ docRef, parentRef, order, skipRecompute, skipClient }) {
|
||||
async run({ docRef, parentId, order, skipRecompute, skipClient }) {
|
||||
if (skipClient && this.isSimulation) {
|
||||
return;
|
||||
}
|
||||
let doc = fetchDocByRef(docRef);
|
||||
let collection = getCollectionByName(docRef.collection);
|
||||
const collection = getCollectionByName(docRef.collection);
|
||||
const [doc, parent] = await Promise.all([
|
||||
collection.findOneAsync(docRef.id),
|
||||
collection.findOneAsync(parentId),
|
||||
]);
|
||||
|
||||
if (!doc) throw new Meteor.Error('Document not found', 'The property to move could not be found');
|
||||
if (!parent) throw new Meteor.Error('Document not found', 'The new parent could not be found');
|
||||
// The user must be able to edit both the doc and its parent to move it
|
||||
// successfully
|
||||
assertDocEditPermission(doc, this.userId);
|
||||
let parent = fetchDocByRef(parentRef);
|
||||
assertDocEditPermission(parent, this.userId);
|
||||
await Promise.all([
|
||||
assertDocEditPermission(doc, this.userId),
|
||||
// Only check parent if it has a different root
|
||||
doc.root.id !== parent.root.id && assertDocEditPermission(parent, this.userId),
|
||||
]);
|
||||
|
||||
// Change the doc's parent
|
||||
updateParent({ docRef, parentRef });
|
||||
// Change the doc's order to be a half step ahead of its target location
|
||||
collection.update(doc._id, { $set: { order } }, { selector: { type: 'any' } });
|
||||
|
||||
// Reorder both ancestors' documents
|
||||
let oldAncestorId = doc.ancestors[0].id;
|
||||
reorderDocs({ collection, ancestorId: oldAncestorId });
|
||||
|
||||
let newAncestorId = getRootId(parent);
|
||||
if (newAncestorId !== oldAncestorId) {
|
||||
reorderDocs({ collection, ancestorId: newAncestorId });
|
||||
}
|
||||
await changeParent(doc, parent, collection, order);
|
||||
|
||||
// Figure out which creatures need to be recalculated after this move
|
||||
let docCreatures = getCreatureAncestors(doc);
|
||||
let parentCreatures = getCreatureAncestors(parent);
|
||||
if (!skipRecompute) {
|
||||
let creaturesToRecompute = union(docCreatures, parentCreatures);
|
||||
if (!skipRecompute && docRef.collection === 'creatures') {
|
||||
const creaturesToRecompute = union[doc.root.id, parent.root.id];
|
||||
// Mark the creatures for recompute
|
||||
Creatures.update({
|
||||
await Creatures.updateAsync({
|
||||
_id: { $in: creaturesToRecompute }
|
||||
}, {
|
||||
$set: { dirty: true },
|
||||
}, {
|
||||
multi: true
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// TODO, rewrite
|
||||
const reorderDoc = new ValidatedMethod({
|
||||
name: 'organize.reorderDoc',
|
||||
validate: new SimpleSchema({
|
||||
@@ -104,27 +99,4 @@ const reorderDoc = new ValidatedMethod({
|
||||
},
|
||||
});
|
||||
|
||||
function getRootId(doc) {
|
||||
if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]) {
|
||||
return doc.ancestors[0].id;
|
||||
} else {
|
||||
return doc._id;
|
||||
}
|
||||
}
|
||||
|
||||
function getCreatureAncestors(doc) {
|
||||
let ids = [];
|
||||
if (doc.type === 'pc' || doc.type === 'npc' || doc.type === 'monster') {
|
||||
ids.push(doc._id);
|
||||
}
|
||||
if (doc.ancestors) {
|
||||
doc.ancestors.forEach(ancestorRef => {
|
||||
if (ancestorRef.collection === 'creatures') {
|
||||
ids.push(ancestorRef.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export { organizeDoc, reorderDoc };
|
||||
@@ -1,217 +0,0 @@
|
||||
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
|
||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
const generalParents = [
|
||||
'attribute',
|
||||
'buff',
|
||||
'classLevel',
|
||||
'feature',
|
||||
'folder',
|
||||
'root',
|
||||
'item',
|
||||
'spell',
|
||||
];
|
||||
|
||||
// Which types are allowed as parents for other types
|
||||
const allowedParenting = {
|
||||
folder: [...generalParents, 'container'],
|
||||
rollResult: ['roll', 'rollResult'],
|
||||
container: ['root', 'folder'],
|
||||
item: ['root', 'container', 'folder'],
|
||||
};
|
||||
|
||||
const allParentTypes = new Set(flatten(Object.values(allowedParenting)));
|
||||
|
||||
export function canBeParent(type){
|
||||
return true;
|
||||
//TODO until there is a good reason to disallow certain parenting options,
|
||||
// this should just let the user do whatever
|
||||
return type && allParentTypes.has(type);
|
||||
}
|
||||
|
||||
export function getAllowedParents({childType}){
|
||||
return allowedParenting[childType] || generalParents;
|
||||
}
|
||||
|
||||
export function isParentAllowed({parentType = 'root', childType}){
|
||||
return true;
|
||||
//TODO until there is a good reason to disallow certain parenting options,
|
||||
// this should just let the user do whatever
|
||||
if (!childType) throw 'childType is required';
|
||||
let allowedParents = getAllowedParents({childType});
|
||||
return allowedParents.includes(parentType);
|
||||
}
|
||||
|
||||
export function fetchParent({id, collection}){
|
||||
return fetchDocByRef({id, collection});
|
||||
}
|
||||
|
||||
export function fetchChildren({ collection, parentId, filter = {}, options = {sort: {order: 1}} }){
|
||||
filter['parent.id'] = parentId;
|
||||
let children = [];
|
||||
children.push(
|
||||
...collection.find({
|
||||
'parent.id': parentId
|
||||
}, options).fetch()
|
||||
);
|
||||
return children;
|
||||
}
|
||||
|
||||
export function updateChildren({collection, parentId, filter = {}, modifier, options={}}){
|
||||
filter['parent.id'] = parentId;
|
||||
options.multi = true;
|
||||
collection.update(filter, modifier, options);
|
||||
}
|
||||
|
||||
export function fetchDescendants({ collection, ancestorId, filter = {}, options}){
|
||||
filter['ancestors.id'] = ancestorId;
|
||||
let descendants = [];
|
||||
descendants.push(...collection.find(filter, options).fetch());
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export function updateDescendants({collection, ancestorId, filter = {}, modifier, options={}}){
|
||||
filter['ancestors.id'] = ancestorId;
|
||||
options.multi = true;
|
||||
options.selector = {type: 'any'};
|
||||
collection.update(filter, modifier, options);
|
||||
}
|
||||
|
||||
export function forEachDescendant({collection, ancestorId, filter = {}, options}, callback){
|
||||
filter['ancestors.id'] = ancestorId;
|
||||
collection.find(filter, options).forEach(callback);
|
||||
}
|
||||
|
||||
// 1 database read
|
||||
export function getAncestry({parentRef, inheritedFields = {}}){
|
||||
let parentDoc = fetchDocByRef(parentRef, {fields: inheritedFields});
|
||||
let parent = { ...parentRef};
|
||||
for (let field in inheritedFields){
|
||||
if (inheritedFields[field]){
|
||||
parent[field] = parentDoc[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Ancestors is [...parent's ancestors, parent ref]
|
||||
let ancestors = parentDoc.ancestors || [];
|
||||
ancestors.push(parent);
|
||||
|
||||
return {parentDoc, parent, ancestors};
|
||||
}
|
||||
|
||||
export function setLineageOfDocs({docArray, oldParent, newAncestry}){
|
||||
const newParent = newAncestry[newAncestry.length - 1];
|
||||
docArray.forEach(doc => {
|
||||
if(doc.parent.id === oldParent.id){
|
||||
doc.parent = newParent;
|
||||
}
|
||||
let oldAncestors = doc.ancestors;
|
||||
let oldParentIndex = oldAncestors.findIndex(a => a.id === oldParent.id);
|
||||
if (oldParentIndex === -1) return;
|
||||
doc.ancestors = [...newAncestry, ...oldAncestors.slice(oldParentIndex + 1)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give documents new random ids and transform their references.
|
||||
* Transform collections of re-IDed docs according to the collection map
|
||||
*/
|
||||
export function renewDocIds({docArray, collectionMap, idMap = {}}){
|
||||
// idMap is a map of {oldId: newId}
|
||||
// Get a random generator that's consistent on client and server
|
||||
let randomSrc = DDP.randomStream('renewDocIds');
|
||||
|
||||
// Give new ids and map the changes as {oldId: newId}
|
||||
docArray.forEach(doc => {
|
||||
let oldId = doc._id;
|
||||
let newId = idMap[oldId] || randomSrc.id();
|
||||
doc._id = newId;
|
||||
idMap[oldId] = newId;
|
||||
});
|
||||
|
||||
// Remap all references using the new IDs
|
||||
const remapReference = ref => {
|
||||
if (idMap[ref.id]){
|
||||
ref.id = idMap[ref.id];
|
||||
ref.collection = collectionMap && collectionMap[ref.collection] || ref.collection;
|
||||
}
|
||||
}
|
||||
docArray.forEach(doc => {
|
||||
remapReference(doc.parent);
|
||||
doc.ancestors.forEach(remapReference);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateParent({docRef, parentRef}){
|
||||
let collection = getCollectionByName(docRef.collection);
|
||||
let oldDoc = fetchDocByRef(docRef, {fields: {
|
||||
parent: 1,
|
||||
ancestors: 1,
|
||||
type: 1,
|
||||
}});
|
||||
let updateOptions = { selector: {type: 'any'} };
|
||||
|
||||
// Skip if we aren't changing the parent id
|
||||
if (oldDoc.parent.id === parentRef.id) return;
|
||||
|
||||
// Get the parent and its ancestry
|
||||
let {parentDoc, parent, ancestors} = getAncestry({parentRef});
|
||||
|
||||
// Check that the doc isn't its own ancestor
|
||||
ancestors.forEach(ancestor => {
|
||||
if (docRef.id === ancestor.id){
|
||||
throw new Meteor.Error('invalid parenting',
|
||||
'A doc can\'t be its own ancestor')
|
||||
}
|
||||
});
|
||||
|
||||
// If the doc and its parent are in the same collection, apply the allowed
|
||||
// parent rules based on type
|
||||
if (docRef.collection === parentRef.collection){
|
||||
let parentAllowed = isParentAllowed({
|
||||
parentType: parentDoc.type,
|
||||
childType: oldDoc.type
|
||||
});
|
||||
if (!parentAllowed){
|
||||
throw new Meteor.Error('invalid parenting',
|
||||
`Can't make ${oldDoc.type} a child of ${parentDoc.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// update the document's parenting
|
||||
collection.update(docRef.id, {
|
||||
$set: {parent, ancestors}
|
||||
}, updateOptions);
|
||||
|
||||
// Remove the old ancestors from the descendants
|
||||
updateDescendants({
|
||||
collection,
|
||||
ancestorId: docRef.id,
|
||||
modifier: {$pullAll: {
|
||||
ancestors: oldDoc.ancestors,
|
||||
}},
|
||||
options: updateOptions,
|
||||
});
|
||||
|
||||
// Add the new ancestors to the descendants
|
||||
updateDescendants({
|
||||
collection,
|
||||
ancestorId: docRef.id,
|
||||
modifier: {$push: {
|
||||
ancestors: {
|
||||
$each: ancestors,
|
||||
$position: 0,
|
||||
},
|
||||
}},
|
||||
options: updateOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function getName(doc){
|
||||
if (doc.name) return name;
|
||||
var i = doc.ancestors.length;
|
||||
while(i--) {
|
||||
if (doc.ancestors[i].name) return doc.ancestors[i].name;
|
||||
}
|
||||
}
|
||||
77
app/imports/api/parenting/parentingFunctions.test.ts
Normal file
77
app/imports/api/parenting/parentingFunctions.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { docsToForest, calculateNestedSetOperations } from '/imports/api/parenting/parentingFunctions'
|
||||
import { TreeDoc } from '/imports/api/parenting/ChildSchema';
|
||||
import { assert } from 'chai';
|
||||
|
||||
function doc(_id, left, right, parentId?): TreeDoc {
|
||||
return { _id, root: { id: 'root', collection: 'col' }, left, right, parentId };
|
||||
}
|
||||
|
||||
function op(_id, left, right) {
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { _id },
|
||||
update: { $set: { left, right } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Parenting with nested sets', function () {
|
||||
/**
|
||||
* Test the following structure
|
||||
*
|
||||
* 1 Books 12
|
||||
* ┃
|
||||
* 2 Programming 11
|
||||
* ┏━━━━━━━━┻━━━━━━━━━┓
|
||||
* 3 Languages 4 5 Databases 10
|
||||
* ┏━━━━━━━┻━━━━━━━┓
|
||||
* 6 MongoDB 7 8 dbm 9
|
||||
*/
|
||||
it('Takes a set of documents and builds the forest', function () {
|
||||
const docArray: Array<TreeDoc> = [
|
||||
doc('Books', 1, 12),
|
||||
doc('Programming', 2, 11),
|
||||
doc('Languages', 3, 4),
|
||||
doc('Databases', 5, 10),
|
||||
doc('MongoDB', 6, 7),
|
||||
doc('dbm', 8, 9),
|
||||
];
|
||||
const forest = docsToForest(docArray);
|
||||
assert.deepEqual(forest, [
|
||||
{
|
||||
doc: doc('Books', 1, 12), children: [
|
||||
{
|
||||
doc: doc('Programming', 2, 11), children: [
|
||||
{ doc: doc('Languages', 3, 4), children: [] },
|
||||
{
|
||||
doc: doc('Databases', 5, 10), children: [
|
||||
{ doc: doc('MongoDB', 6, 7), children: [] },
|
||||
{ doc: doc('dbm', 8, 9), children: [] },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
});
|
||||
it('Can recalculate left and right for docs with set parents', function () {
|
||||
const docArray = [
|
||||
doc('Books', 71, 33, undefined),
|
||||
doc('Programming', 72, 33, 'Books'),
|
||||
doc('Languages', 73, 33, 'Programming'),
|
||||
doc('Databases', 74, 33, 'Programming'),
|
||||
doc('MongoDB', 75, 33, 'Databases'),
|
||||
doc('dbm', 76, 33, 'Databases'),
|
||||
];
|
||||
const ops = calculateNestedSetOperations(docArray);
|
||||
assert.deepEqual(ops, [
|
||||
op('Books', 1, 12),
|
||||
op('Programming', 2, 11),
|
||||
op('Languages', 3, 4),
|
||||
op('Databases', 5, 10),
|
||||
op('MongoDB', 6, 7),
|
||||
op('dbm', 8, 9),
|
||||
]);
|
||||
});
|
||||
});
|
||||
494
app/imports/api/parenting/parentingFunctions.ts
Normal file
494
app/imports/api/parenting/parentingFunctions.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import { chain, reverse } from 'lodash';
|
||||
import { TreeDoc, treeDocFields, Reference } from '/imports/api/parenting/ChildSchema';
|
||||
|
||||
export function getCollectionByName(name: string): Mongo.Collection<TreeDoc> {
|
||||
const collection = Mongo.Collection.get(name)
|
||||
if (!collection) {
|
||||
throw new Meteor.Error('bad-collection-reference',
|
||||
`Parent references collection ${name}, which does not exist`
|
||||
);
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
export async function fetchDocByRef(ref: Reference, options?: Mongo.Options<object>) {
|
||||
const doc = await getCollectionByName(ref.collection).findOneAsync(ref.id, options);
|
||||
if (!doc) {
|
||||
throw new Meteor.Error('document-not-found',
|
||||
`No document could be found with id: ${ref.id} in ${ref.collection}`
|
||||
);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
interface TreeNode<T> {
|
||||
doc: T,
|
||||
children: TreeNode<T>[]
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param nodes An array of documents that share a common root. Must already be sorted by `.left` in ascending order
|
||||
* @returns An array of tree nodes that each contain a document and its children. Children are
|
||||
* assigned based on the nearest ancestor included in the input, which may or may not be their
|
||||
* actual direct parents
|
||||
*/
|
||||
export function docsToForest(docs: Array<TreeDoc>): TreeNode<TreeDoc>[] {
|
||||
if (!docs.length) return [];
|
||||
const forest: TreeNode<TreeDoc>[] = [];
|
||||
const ancestorStack: TreeNode<TreeDoc>[] = [];
|
||||
let currentAncestor: TreeNode<TreeDoc> | undefined;
|
||||
docs.forEach(doc => {
|
||||
const node: TreeNode<TreeDoc> = {
|
||||
doc,
|
||||
children: [],
|
||||
};
|
||||
// Remove ancestors from the stack until we find one that contains the current document
|
||||
// Ancestor contains document if ancestor.left < doc.left and ancestor.right > doc.right
|
||||
// ancestor.left < doc.left is ensured already, because we sorted by doc.left
|
||||
while (currentAncestor && currentAncestor.doc.right < doc.left) {
|
||||
currentAncestor = ancestorStack.pop();
|
||||
}
|
||||
// Add this child to its place in the forest, either as a child of the ancestor or as the root
|
||||
// of a new tree
|
||||
if (currentAncestor) {
|
||||
currentAncestor.children.push(node);
|
||||
} else {
|
||||
forest.push(node);
|
||||
}
|
||||
// Move the last ancestor onto the stack and make this node the new one
|
||||
if (currentAncestor) ancestorStack.push(currentAncestor);
|
||||
currentAncestor = node;
|
||||
});
|
||||
return forest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the documents from a collection, and return the tree of those documents, potentially
|
||||
* including their ancestors or descendants as required
|
||||
* @param param options
|
||||
* @returns An array of tree nodes that each contain a document and its children. Children are
|
||||
* assigned based on the nearest ancestor included in the input, which may or may not be their
|
||||
* actual direct parents
|
||||
*/
|
||||
type FilteredDoc = {
|
||||
_descendantOfMatchedDocument?: boolean,
|
||||
_matchedDocumentFilter?: boolean,
|
||||
_ancestorOfMatchedDocument?: boolean,
|
||||
} & TreeDoc;
|
||||
|
||||
export default async function filterToForest(
|
||||
collection: Mongo.Collection<TreeDoc>,
|
||||
rootId: string,
|
||||
filter: Mongo.Query<TreeDoc>,
|
||||
options: Mongo.Options<object> = {},
|
||||
includeFilteredDocAncestors = false,
|
||||
includeFilteredDocDescendants = false
|
||||
): Promise<TreeNode<FilteredDoc>[]> {
|
||||
// Setup the filter
|
||||
let collectionFilter = {
|
||||
'root.id': rootId,
|
||||
'removed': { $ne: true },
|
||||
};
|
||||
if (filter) {
|
||||
collectionFilter = {
|
||||
...collectionFilter,
|
||||
...filter,
|
||||
}
|
||||
}
|
||||
// Set up the options
|
||||
let collectionSort = {
|
||||
left: 1
|
||||
};
|
||||
if (options && options.sort) {
|
||||
collectionSort = {
|
||||
...collectionSort,
|
||||
...options.sort,
|
||||
}
|
||||
}
|
||||
let collectionOptions: Mongo.Options<object> = {
|
||||
sort: collectionSort,
|
||||
}
|
||||
if (options) {
|
||||
collectionOptions = {
|
||||
...collectionOptions,
|
||||
...options,
|
||||
}
|
||||
}
|
||||
// Find all the docs that match the filter
|
||||
const docs: TreeDoc[] = await collection.find(collectionFilter, collectionOptions)
|
||||
.mapAsync(doc => {
|
||||
if (!filter) return doc;
|
||||
// Mark the docs that were found by the custom filter
|
||||
doc._matchedDocumentFilter = true;
|
||||
return doc;
|
||||
});
|
||||
|
||||
// Get the doc ancestors
|
||||
let ancestors: object[] = [];
|
||||
if (filter && includeFilteredDocAncestors) {
|
||||
ancestors = await collection.find(getFilter.ancestorsOfAll(docs), collectionOptions).mapAsync(doc => {
|
||||
// Mark that the nodes are ancestors of the found nodes
|
||||
doc._ancestorOfMatchedDocument = true;
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
// Get the doc descendants
|
||||
let descendants: FilteredDoc[] = [];
|
||||
if (filter && includeFilteredDocDescendants) {
|
||||
descendants = await collection.find({
|
||||
'removed': { $ne: true },
|
||||
...getFilter.descendantsOfAll(docs),
|
||||
}).mapAsync((doc: FilteredDoc) => {
|
||||
// Mark that the nodes are descendants of the found nodes
|
||||
doc._descendantOfMatchedDocument = true;
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
const nodes = chain([
|
||||
ancestors,
|
||||
docs,
|
||||
descendants
|
||||
]).uniqBy('_id')
|
||||
.sortBy('left')
|
||||
.value();
|
||||
// Find all the nodes
|
||||
return docsToForest(nodes);
|
||||
}
|
||||
|
||||
type ForestAndOrphans = { forest: TreeNode<TreeDoc>[], orphanIds: string[] }
|
||||
/**
|
||||
* Takes a complete set of documents and builds a forest using just their `.parentIds`
|
||||
* Uses `.left` for sibling order within a parent only.
|
||||
* Orphans whose direct parents can't be found are collected separately
|
||||
* @param docs An array of all document that share a common root already sorted by `.left` in
|
||||
* ascending order
|
||||
* @returns forest: An array of tree nodes that each contain a document and its children.
|
||||
* orphans: an array of the same, but their parents weren't in the input array
|
||||
*/
|
||||
export function docsToForestByParentId(docs: TreeDoc[]): ForestAndOrphans {
|
||||
// Collect all the docs in a dict by id
|
||||
const nodesById = <{ [_id: string]: TreeNode<TreeDoc> }>{};
|
||||
docs.forEach(doc => {
|
||||
nodesById[doc._id] = { doc, children: [] };
|
||||
});
|
||||
// Assign the docs to their parent or the forest or orphanage
|
||||
const forest: TreeNode<TreeDoc>[] = [];
|
||||
const orphanIds: string[] = [];
|
||||
docs.forEach(doc => {
|
||||
const node = nodesById[doc._id];
|
||||
if (!doc.parentId) {
|
||||
// Root is parent
|
||||
forest.push(node);
|
||||
} else if (nodesById[doc.parentId]) {
|
||||
// Parent is found
|
||||
nodesById[doc.parentId].children.push(node);
|
||||
} else {
|
||||
// Parent is missing, unset it, and store orphan
|
||||
node.doc.parentId = undefined;
|
||||
orphanIds.push(node.doc._id);
|
||||
forest.push(node);
|
||||
}
|
||||
});
|
||||
return { forest, orphanIds };
|
||||
}
|
||||
|
||||
export const getFilter = {
|
||||
/**
|
||||
*
|
||||
* @param doc A document or array of documents that share a root
|
||||
* @returns A query filter that finds all the ancestors of the doc(s)
|
||||
*/
|
||||
ancestors(doc: TreeDoc): Mongo.Query<TreeDoc> {
|
||||
return {
|
||||
'root.id': doc.root.id,
|
||||
left: { $lt: doc.left },
|
||||
right: { $gt: doc.right },
|
||||
};
|
||||
},
|
||||
ancestorsOfAll(docs: Array<TreeDoc>): Mongo.Query<TreeDoc> {
|
||||
// The ancestors of no documents is a query that returns nothing
|
||||
if (docs.length === 0) {
|
||||
return { _id: '' };
|
||||
}
|
||||
// Fallback to the simpler filter for a single document
|
||||
if (docs.length === 1) {
|
||||
return getFilter.ancestors(docs[0]);
|
||||
}
|
||||
// Build a filter that selects all ancestors
|
||||
const filter = {
|
||||
'root.id': docs[0].root.id,
|
||||
$or: <object[]>[],
|
||||
};
|
||||
docs.forEach(doc => {
|
||||
filter.$or.push({
|
||||
left: { $lt: doc.left },
|
||||
right: { $gt: doc.right },
|
||||
});
|
||||
});
|
||||
return filter;
|
||||
},
|
||||
descendants(doc: TreeDoc): Mongo.Query<TreeDoc> {
|
||||
return {
|
||||
'root.id': doc.root.id,
|
||||
left: { $gt: doc.left },
|
||||
right: { $lt: doc.right },
|
||||
};
|
||||
},
|
||||
descendantsOfAll(docs: Array<TreeDoc>): Mongo.Query<TreeDoc> {
|
||||
// The descendants of no documents is a query that returns nothing
|
||||
if (docs.length === 0) {
|
||||
return { _id: '' };
|
||||
}
|
||||
// Fallback to the simpler filter for a single document
|
||||
if (docs.length === 1) {
|
||||
return getFilter.descendants(docs[0]);
|
||||
}
|
||||
// Build a filter that selects all descendants
|
||||
const filter = {
|
||||
'root.id': docs[0].root.id,
|
||||
$or: <object[]>[],
|
||||
};
|
||||
docs.forEach(doc => {
|
||||
filter.$or.push({
|
||||
left: { $gt: doc.left },
|
||||
right: { $lt: doc.right },
|
||||
});
|
||||
});
|
||||
return filter;
|
||||
},
|
||||
children(doc: TreeDoc): Mongo.Query<TreeDoc> {
|
||||
return {
|
||||
'root.id': doc.root.id,
|
||||
parentId: doc._id,
|
||||
};
|
||||
},
|
||||
parent(doc: TreeDoc): Mongo.Query<TreeDoc> {
|
||||
return {
|
||||
_id: doc.parentId,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export async function fetchParent({ id, collection }) {
|
||||
return await fetchDocByRef({ id, collection });
|
||||
}
|
||||
|
||||
/**
|
||||
* Give documents new random ids and transform their references.
|
||||
* Transform collections of re-IDed docs according to the collection map
|
||||
*/
|
||||
export function renewDocIds({ docArray, collectionMap, idMap = {} }) {
|
||||
// idMap is a map of {oldId: newId}
|
||||
// Get a random generator that's consistent on client and server
|
||||
const randomSrc = DDP.randomStream('renewDocIds');
|
||||
|
||||
// Give new ids and map the changes as {oldId: newId}
|
||||
docArray.forEach(doc => {
|
||||
const oldId = doc._id;
|
||||
const newId = idMap[oldId] || randomSrc.id();
|
||||
doc._id = newId;
|
||||
idMap[oldId] = newId;
|
||||
});
|
||||
|
||||
// Remap all references using the new IDs
|
||||
const remapReference = ref => {
|
||||
if (idMap[ref.id]) {
|
||||
ref.id = idMap[ref.id];
|
||||
ref.collection = collectionMap && collectionMap[ref.collection] || ref.collection;
|
||||
}
|
||||
}
|
||||
docArray.forEach(doc => {
|
||||
remapReference(doc.parent);
|
||||
remapReference(doc.root);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the doc to be a child of the parent, and then rebuilds the nested sets of the roots
|
||||
* of both doc and parent
|
||||
* @param doc The doc to move
|
||||
* @param parent The new parent of the doc
|
||||
* @param collection
|
||||
* @returns
|
||||
*/
|
||||
export async function changeParent(doc: TreeDoc, parent: TreeDoc, collection: Mongo.Collection<TreeDoc>, order?: number) {
|
||||
// Skip if we aren't changing the parent id
|
||||
if (doc.parentId === parent._id) return;
|
||||
|
||||
// Store the original roots
|
||||
const rootChange = doc.root.id !== parent.root.id;
|
||||
|
||||
// Check that the doc isn't becoming its own ancestor
|
||||
if (parent.left > doc.left && parent.right < doc.right) {
|
||||
throw new Meteor.Error('invalid parenting', 'A doc can\'t be its own ancestor');
|
||||
}
|
||||
|
||||
// update the document's parenting and root if necessary
|
||||
const update: Mongo.Modifier<TreeDoc> = {
|
||||
$set: { parentId: parent._id }
|
||||
};
|
||||
if (rootChange && update.$set) {
|
||||
update.$set.root = parent.root;
|
||||
}
|
||||
if (order && update.$set) {
|
||||
update.$set.left = order;
|
||||
}
|
||||
await collection.updateAsync(doc._id, update);
|
||||
|
||||
// Rebuild the nested sets of everything on the root document(s)
|
||||
rebuildNestedSets(collection, doc.root.id);
|
||||
if (rootChange) {
|
||||
rebuildNestedSets(collection, parent.root.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function compareOrder(docA, docB) {
|
||||
// < 0 if A comes before B
|
||||
// = 0 if A and B are the same order
|
||||
// > 0 if B comes before A
|
||||
|
||||
// They must share a root ancestor to be meaningfully sorted
|
||||
if (docA.root.id !== docB.root.id) {
|
||||
return 0;
|
||||
} else {
|
||||
return docA.left - docB.left;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Just set left to Number.MAX_SAFE_INTEGER instead
|
||||
*/
|
||||
export function setDocToLastOrder(collection: Mongo.Collection<TreeDoc>, doc: TreeDoc) {
|
||||
doc.left = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
export async function rebuildNestedSets(collection: Mongo.Collection<TreeDoc>, rootId: string) {
|
||||
const docs = collection.find({
|
||||
'root.id': rootId,
|
||||
removed: { $ne: true }
|
||||
}, {
|
||||
fields: {
|
||||
root: 1,
|
||||
parentId: 1,
|
||||
left: 1,
|
||||
right: 1,
|
||||
},
|
||||
sort: {
|
||||
//Reverse sorting so that arrays can be used as stacks with the first item on top
|
||||
left: 1,
|
||||
},
|
||||
}).fetch();
|
||||
|
||||
const operations = calculateNestedSetOperations(docs);
|
||||
|
||||
await writeBulkOperations(collection, operations);
|
||||
}
|
||||
|
||||
export function calculateNestedSetOperations(docs: TreeDoc[]) {
|
||||
// Walk around the tree numbering left on the way down and right on the way up like so:
|
||||
/*
|
||||
* 1 Books 12
|
||||
* ┃
|
||||
* 2 Programming 11
|
||||
* ┏━━━━━━━━┻━━━━━━━━━┓
|
||||
* 3 Languages 4 5 Databases 10
|
||||
* ┏━━━━━━━┻━━━━━━━┓
|
||||
* 6 MongoDB 7 8 dbm 9
|
||||
*/
|
||||
// Get the forest, but in reverse order so that the stack always has the first documents on top
|
||||
const { forest: stack, orphanIds } = docsToForestByParentId(reverse(docs));
|
||||
const removeMissingParentsOp = orphanIds.length ? {
|
||||
updateMany: {
|
||||
filter: { _id: { $in: orphanIds } },
|
||||
update: { $unset: { parentId: 1 } },
|
||||
}
|
||||
} : undefined;
|
||||
const visitedNodes = new Set();
|
||||
const visitedChildren = new Set();
|
||||
const opsById: { [_id: string]: any } = {}
|
||||
let count = 1;
|
||||
|
||||
while (stack.length) {
|
||||
const top = stack[stack.length - 1];
|
||||
if (visitedNodes.has(top)) {
|
||||
// We've arrived at this node again for some reason, this shouldn't happen
|
||||
console.log('visited already, parent loop maybe?')
|
||||
stack.pop();
|
||||
} else if (visitedChildren.has(top)) {
|
||||
// We've arrived at this node after visiting the children,
|
||||
// we must be on the way up, mark the right number
|
||||
visitedNodes.add(top);
|
||||
stack.pop();
|
||||
if (top.doc.right !== count) {
|
||||
opsById[top.doc._id].updateOne.update.$set.right = count;
|
||||
}
|
||||
count += 1;
|
||||
} else {
|
||||
// We're arriving at this node for the first time
|
||||
// We must be on the way down, mark the left number and go visit the children
|
||||
visitedChildren.add(top);
|
||||
stack.push(...top.children);
|
||||
if (top.doc.left !== count) {
|
||||
opsById[top.doc._id] = {
|
||||
updateOne: {
|
||||
filter: { _id: top.doc._id },
|
||||
update: { $set: { left: count } },
|
||||
}
|
||||
};
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const operations = [...Object.values(opsById)];
|
||||
if (removeMissingParentsOp) operations.push(removeMissingParentsOp);
|
||||
return operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write some number of bulk operations to the collection, uses a bulk write on the server
|
||||
* and iterates through regular updates on the client
|
||||
* Resolves once all writes have completed
|
||||
* @param collection The collection to write to
|
||||
* @param operations An array of bulk operations to write
|
||||
* @returns Promise<undefined>
|
||||
*/
|
||||
async function writeBulkOperations(collection: Mongo.Collection<TreeDoc>, operations) {
|
||||
if (Meteor.isServer && operations.length) {
|
||||
return new Promise((resolve, reject) => {
|
||||
collection.rawCollection().bulkWrite(
|
||||
operations,
|
||||
{ ordered: false },
|
||||
function (e) {
|
||||
if (e) {
|
||||
reject(e);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
const promises = operations.map(op => {
|
||||
if (op.updateOne) {
|
||||
return collection.updateAsync(
|
||||
op.updateOne.filter,
|
||||
op.updateOne.update,
|
||||
{ selector: { type: 'any' } }
|
||||
);
|
||||
} else if (op.updateMany) {
|
||||
return collection.updateAsync(
|
||||
op.updateMany.filter,
|
||||
op.updateMany.update,
|
||||
{
|
||||
selector: { type: 'any' },
|
||||
multi: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
});
|
||||
return Promise.all(promises);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
||||
import { updateDescendants } from '/imports/api/parenting/parenting.js';
|
||||
|
||||
export function softRemove({_id, collection}){
|
||||
let removalDate = new Date();
|
||||
if (typeof collection === 'string') {
|
||||
collection = getCollectionByName(collection);
|
||||
}
|
||||
// Remove this document
|
||||
collection.update(
|
||||
_id, {
|
||||
$set: {
|
||||
removed: true,
|
||||
removedAt: removalDate,
|
||||
},
|
||||
$unset: {
|
||||
removedWith: 1,
|
||||
}
|
||||
}, {
|
||||
selector: {type: 'any'},
|
||||
},
|
||||
);
|
||||
// Remove all the descendants that have not yet been removed, and set them to be
|
||||
// removed with this document
|
||||
updateDescendants({
|
||||
collection,
|
||||
ancestorId: _id,
|
||||
filter: {removed: {$ne: true}},
|
||||
modifier: {$set: {
|
||||
removed: true,
|
||||
removedAt: removalDate,
|
||||
removedWith: _id,
|
||||
}},
|
||||
});
|
||||
}
|
||||
|
||||
const restoreError = function(){
|
||||
throw new Meteor.Error('restore-failed',
|
||||
'Could not restore this document, maybe it was removed by a parent?'
|
||||
);
|
||||
};
|
||||
|
||||
export function restore({ _id, collection, extraUpdates}){
|
||||
if (typeof collection === 'string') {
|
||||
collection = getCollectionByName(collection);
|
||||
}
|
||||
const update = {
|
||||
$unset: {
|
||||
removed: 1,
|
||||
removedAt: 1,
|
||||
},
|
||||
...extraUpdates
|
||||
}
|
||||
|
||||
let numUpdated = collection.update({
|
||||
_id,
|
||||
removedWith: {$exists: false}
|
||||
}, update , {
|
||||
selector: {type: 'any'},
|
||||
},);
|
||||
if (numUpdated === 0) restoreError();
|
||||
updateDescendants({
|
||||
collection,
|
||||
ancestorId: _id,
|
||||
filter: {
|
||||
removedWith: _id,
|
||||
},
|
||||
modifier: { $unset: {
|
||||
removed: 1,
|
||||
removedAt: 1,
|
||||
removedWith: 1,
|
||||
}},
|
||||
});
|
||||
}
|
||||
90
app/imports/api/parenting/softRemove.ts
Normal file
90
app/imports/api/parenting/softRemove.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { getCollectionByName, getFilter } from '/imports/api/parenting/parentingFunctions';
|
||||
import { TreeDoc } from '/imports/api/parenting/ChildSchema';
|
||||
|
||||
export async function softRemove(collection: Mongo.Collection<TreeDoc> | string, doc?: TreeDoc | string) {
|
||||
const removalDate = new Date();
|
||||
if (typeof collection === 'string') {
|
||||
collection = getCollectionByName(collection);
|
||||
}
|
||||
if (typeof doc === 'string') {
|
||||
doc = await collection.findOneAsync(doc);
|
||||
}
|
||||
if (!doc) {
|
||||
throw new Meteor.Error('not found', 'The document to remove was not found');
|
||||
}
|
||||
// Remove this document
|
||||
const removeDocPromise = collection.updateAsync(
|
||||
doc._id,
|
||||
{
|
||||
$set: {
|
||||
removed: true,
|
||||
removedAt: removalDate,
|
||||
},
|
||||
$unset: {
|
||||
removedWith: 1,
|
||||
}
|
||||
}, {
|
||||
selector: { type: 'any' },
|
||||
},
|
||||
);
|
||||
// Remove all the descendants that have not yet been removed, and set them to be
|
||||
// removed with this document
|
||||
const removeDescendantsPromise = collection.updateAsync({
|
||||
...getFilter.descendants(doc),
|
||||
removed: { $ne: true },
|
||||
}, {
|
||||
$set: {
|
||||
removed: true,
|
||||
removedAt: removalDate,
|
||||
removedWith: doc._id,
|
||||
}
|
||||
}, {
|
||||
selector: { type: 'any' },
|
||||
multi: true,
|
||||
});
|
||||
return Promise.all([removeDocPromise, removeDescendantsPromise]);
|
||||
}
|
||||
|
||||
const restoreError = function () {
|
||||
throw new Meteor.Error('restore-failed',
|
||||
'Could not restore this document, maybe it was removed by a parent?'
|
||||
);
|
||||
};
|
||||
|
||||
export async function restore(collection: Mongo.Collection<TreeDoc> | string, doc: TreeDoc | string, extraUpdates) {
|
||||
if (typeof collection === 'string') {
|
||||
collection = getCollectionByName(collection);
|
||||
}
|
||||
if (typeof doc === 'string') {
|
||||
const foundDoc = await collection.findOneAsync(doc)
|
||||
if (!foundDoc) {
|
||||
throw new Meteor.Error('not found', 'The document to remove was not found');
|
||||
}
|
||||
doc = foundDoc;
|
||||
}
|
||||
const numUpdated: number = await collection.updateAsync({
|
||||
_id: doc._id,
|
||||
removedWith: { $exists: false }
|
||||
}, {
|
||||
$unset: {
|
||||
removed: 1,
|
||||
removedAt: 1,
|
||||
},
|
||||
...extraUpdates
|
||||
}, {
|
||||
selector: { type: 'any' },
|
||||
});
|
||||
if (numUpdated === 0) restoreError();
|
||||
return collection.updateAsync({
|
||||
removedWith: doc._id,
|
||||
}, {
|
||||
$unset: {
|
||||
removed: 1,
|
||||
removedAt: 1,
|
||||
removedWith: 1,
|
||||
}
|
||||
}, {
|
||||
selector: { type: 'any' },
|
||||
multi: true,
|
||||
});
|
||||
}
|
||||
1687
app/package-lock.json
generated
1687
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@
|
||||
"@babel/runtime": "^7.21.5",
|
||||
"@chenfengyuan/vue-countdown": "^1.1.5",
|
||||
"@tozd/vue-observer-utils": "^0.5.0",
|
||||
"@types/meteor": "^2.9.4",
|
||||
"aws-sdk": "^2.1373.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"chroma-js": "^2.4.2",
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"baseUrl": ".",
|
||||
"preserveSymlinks": true,
|
||||
"paths": {
|
||||
"/*": [
|
||||
"./*"
|
||||
],
|
||||
"meteor/*": [
|
||||
"node_modules/@types/meteor/*",
|
||||
".meteor/local/types/packages.d.ts"
|
||||
],
|
||||
"meteor/aldeed:collection2": [
|
||||
"packages\\collection2\\collection2.js"
|
||||
"packages/collection2/collection2.js"
|
||||
]
|
||||
},
|
||||
"checkJs": false,
|
||||
Reference in New Issue
Block a user