sequelize-hierarchy-next
Version:
Nested hierarchies for Sequelize
334 lines (288 loc) • 9.75 kB
JavaScript
/* --------------------
* Sequelize hierarchy
* Hooks on individual models
* ------------------*/
;
// Imports
const {
valueFilteredByFields, addOptions, addToFields, inFields,
removeSpacing, replaceTableNames, replaceFieldNames
} = require('./utils');
// Constants
const PARENT = Symbol('PARENT');
// Exports
module.exports = (Sequelize, patches) => {
const {HierarchyError} = Sequelize,
{findOne, findAll, query} = patches;
const beforeUpdate = _beforeUpdate;
return {
beforeCreate,
afterCreate,
beforeUpdate,
beforeBulkCreate,
beforeBulkUpdate
};
async function beforeCreate(item, options) {
const model = this, // eslint-disable-line no-invalid-this
{primaryKey, foreignKey, levelFieldName} = model.hierarchy,
values = item.dataValues,
parentId = valueFilteredByFields(foreignKey, item, options);
// If no parent, set level and exit - no ancestor records to create
if (!parentId) {
values[levelFieldName] = 1;
return;
}
// Check that not trying to make item a child of itself
const itemId = valueFilteredByFields(primaryKey, item, options);
if (parentId === itemId) throw new HierarchyError('Parent cannot be a child of itself');
// Set level based on parent
const parent = await findOne(
model,
addOptions({where: {[primaryKey]: parentId}, attributes: [levelFieldName]}, options)
);
if (!parent) throw new HierarchyError('Parent does not exist');
// Set hierarchy level
values[levelFieldName] = parent[levelFieldName] + 1;
addToFields(levelFieldName, options);
}
async function afterCreate(item, options) {
const model = this, // eslint-disable-line no-invalid-this
{
primaryKey, foreignKey, levelFieldName, through, throughKey, throughForeignKey
} = model.hierarchy,
values = item.dataValues,
parentId = valueFilteredByFields(foreignKey, item, options);
// If no parent, exit - no hierarchy to create
if (!parentId) return;
// Create row in hierarchy table for parent
const itemId = values[primaryKey];
// Get ancestors
let ancestors;
if (values[levelFieldName] === 2) {
// If parent is at top level - no ancestors
ancestors = [];
} else {
// Get parent's ancestors
ancestors = await findAll(
through,
addOptions({where: {[throughKey]: parentId}, attributes: [throughForeignKey]}, options)
);
}
// Add parent as ancestor
ancestors.push({[throughForeignKey]: parentId});
// Save ancestors
ancestors = ancestors.map(ancestor => ({
[throughForeignKey]: ancestor[throughForeignKey],
[throughKey]: itemId
}));
await through.bulkCreate(ancestors, addOptions({}, options));
}
async function _beforeUpdate(item, options) {
const model = this, // eslint-disable-line no-invalid-this
{sequelize} = model,
{
primaryKey, foreignKey, levelFieldName, through, throughKey, throughForeignKey
} = model.hierarchy,
values = item.dataValues;
// NB This presumes item has not been updated since it was originally retrieved
const itemId = values[primaryKey],
parentId = values[foreignKey];
let oldParentId = item._previousDataValues[foreignKey],
oldLevel = item._previousDataValues[levelFieldName];
// If parent not being updated, exit - no change to make
if (
(oldParentId !== undefined && parentId === oldParentId)
|| !inFields(foreignKey, options)
) return;
if (oldParentId === undefined || oldLevel === undefined) {
const itemRecord = await findOne(model, addOptions({
where: {[primaryKey]: itemId}
}, options));
oldParentId = itemRecord[foreignKey];
oldLevel = itemRecord[levelFieldName];
}
// If parent not changing, exit - no change to make
if (parentId === oldParentId) return;
// Get level (1 more than parent)
let level;
if (parentId === null) {
level = 1;
} else {
// Check that not trying to make item a child of itself
if (parentId === itemId) throw new HierarchyError('Parent cannot be a child of itself');
// Use parent already fetched by `beforeBulkUpdate` hook, if present
let parent = options[PARENT];
if (!parent) {
parent = await findOne(
model,
addOptions({
where: {[primaryKey]: parentId}, attributes: [levelFieldName, foreignKey]
}, options)
);
if (!parent) throw new HierarchyError('Parent does not exist');
}
level = parent[levelFieldName] + 1;
// Check that not trying to make item a child of one of its own descendents
let illegal;
if (parent[foreignKey] === itemId) {
illegal = true;
} else if (level > oldLevel + 2) {
illegal = await findOne(
through,
addOptions({where: {[throughKey]: parentId, [throughForeignKey]: itemId}}, options)
);
}
if (illegal) throw new HierarchyError('Parent cannot be a descendent of itself');
}
// Set hierarchy level
if (level !== oldLevel) {
values[levelFieldName] = level;
addToFields(levelFieldName, options);
// Update hierarchy level for all descendents
let sql = removeSpacing(`
UPDATE *item
SET *level = *level + :levelChange
WHERE *id IN (
SELECT *itemId
FROM *through AS ancestors
WHERE ancestors.*ancestorId = :id
)
`);
sql = replaceTableNames(sql, {item: model, through}, sequelize);
sql = replaceFieldNames(sql, {level: levelFieldName, id: primaryKey}, model);
sql = replaceFieldNames(sql, {itemId: throughKey, ancestorId: throughForeignKey}, through);
await query(
sequelize,
sql,
addOptions({replacements: {id: itemId, levelChange: level - oldLevel}}, options)
);
}
// Delete ancestors from hierarchy table for item and all descendents
if (oldParentId !== null) {
const {dialect} = sequelize.options;
// eslint-disable-next-line no-nested-ternary
let sql = dialect === 'postgres' ? `
DELETE FROM *through
USING *through AS descendents, *through AS ancestors
WHERE descendents.*itemId = *through.*itemId
AND ancestors.*ancestorId = *through.*ancestorId
AND ancestors.*itemId = :id
AND (
descendents.*ancestorId = :id
OR descendents.*itemId = :id
)`
: dialect === 'sqlite' ? `
DELETE FROM *through
WHERE EXISTS (
SELECT *
FROM *through AS deleters
INNER JOIN *through AS descendents
ON descendents.*itemId = deleters.*itemId
INNER JOIN *through AS ancestors
ON ancestors.*ancestorId = deleters.*ancestorId
WHERE deleters.*itemId = *through.*itemId
AND deleters.*ancestorId = *through.*ancestorId
AND ancestors.*ancestorId = *through.*ancestorId
AND ancestors.*itemId = :id
AND (
descendents.*ancestorId = :id
OR descendents.*itemId = :id
)
)`
// eslint-disable-next-line indent
: /* MySQL */ `
DELETE deleters
FROM *through AS deleters
INNER JOIN *through AS descendents ON descendents.*itemId = deleters.*itemId
INNER JOIN *through AS ancestors
ON ancestors.*ancestorId = deleters.*ancestorId
WHERE ancestors.*itemId = :id
AND (
descendents.*ancestorId = :id
OR descendents.*itemId = :id
)
`;
sql = removeSpacing(sql);
sql = replaceTableNames(sql, {through}, sequelize);
sql = replaceFieldNames(sql, {itemId: throughKey, ancestorId: throughForeignKey}, through);
await query(
sequelize,
sql,
addOptions({replacements: {id: itemId}}, options)
);
}
// Insert ancestors into hierarchy table for item and all descendents
if (parentId !== null) {
let sql = removeSpacing(`
INSERT INTO *through (*itemId, *ancestorId)
SELECT descendents.*itemId, ancestors.*ancestorId
FROM (
SELECT *itemId
FROM *through
WHERE *ancestorId = :id
UNION ALL
SELECT :id
) AS descendents,
(
SELECT *ancestorId
FROM *through
WHERE *itemId = :parentId
UNION ALL
SELECT :parentId
) AS ancestors
`);
sql = replaceTableNames(sql, {through}, sequelize);
sql = replaceFieldNames(sql, {itemId: throughKey, ancestorId: throughForeignKey}, through);
await query(
sequelize,
sql,
addOptions({replacements: {id: itemId, parentId}}, options)
);
}
}
function beforeBulkCreate(daos, options) {
// Set individualHooks = true so that beforeCreate and afterCreate hooks run
options.individualHooks = true;
}
async function beforeBulkUpdate(options) {
const model = this, // eslint-disable-line no-invalid-this
{primaryKey, foreignKey, levelFieldName} = model.hierarchy;
// If not updating `parentId`, exit
if (!inFields(foreignKey, options)) return;
// Fetch items to be updated
const items = await findAll(model, addOptions({
where: options.where,
attributes: [primaryKey, foreignKey, levelFieldName]
}, options));
// Get level
const {attributes} = options,
parentId = attributes[foreignKey];
let level;
if (parentId === null) {
level = 1;
} else {
const parent = await findOne(
model,
addOptions({
// NB `foreignKey` is used in `beforeUpdate`
where: {[primaryKey]: parentId}, attributes: [levelFieldName, foreignKey]
}, options)
);
if (!parent) throw new HierarchyError('Parent does not exist');
level = parent[levelFieldName] + 1;
// Record parent on options to be used by `beforeUpdate`
options[PARENT] = parent;
}
// Set level
attributes[levelFieldName] = level;
addToFields(levelFieldName, options);
// Run `beforeUpdate` hook on each item in series
options = Object.assign({}, options);
delete options.where;
delete options.attributes;
for (const item of items) {
Object.assign(item, attributes);
await beforeUpdate.call(model, item, options);
}
}
};