diff --git a/package.json b/package.json index 7b829a5..7e57588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@imqueue/sequelize", - "version": "3.1.1", + "version": "3.1.2", "description": "Sequelize ORM refines for @imqueue", "main": "index.js", "scripts": { diff --git a/src/decorators/CreatedBy.ts b/src/decorators/CreatedBy.ts index 8cadadf..555e73b 100644 --- a/src/decorators/CreatedBy.ts +++ b/src/decorators/CreatedBy.ts @@ -22,7 +22,7 @@ * to get commercial licensing options. */ import { currentMetadata } from '@imqueue/rpc'; -import { BeforeCreate } from 'sequelize-typescript'; +import { BeforeBulkCreate, BeforeCreate } from 'sequelize-typescript'; // noinspection JSUnusedGlobalSymbols /** @@ -30,16 +30,23 @@ import { BeforeCreate } from 'sequelize-typescript'; * in-flight IMQ request metadata (`currentMetadata()?.userId`) — so the id never * travels through method arguments and cannot be spoofed by a caller. * - * A property decorator, reusable on any model field. It registers a per-field - * `beforeCreate` hook on the owning model. The hook is a no-op when there is no - * acting user (system / unattributed writes) and never overwrites a value the - * application set explicitly. + * A property decorator, reusable on any model field. The hook is a no-op when + * there is no acting user (system / unattributed writes) and never overwrites a + * value the application set explicitly. + * + * Two hooks are registered, because rows are inserted in two ways: + * - instance / single `create()` -> `beforeCreate` (receives the instance) + * - static `Model.bulkCreate(records, …)` -> `beforeBulkCreate` (receives the + * built instances; a plain `bulkCreate` does not fire `beforeCreate`, so the + * per-instance hook alone would be bypassed). Sequelize filters the written + * columns down to `options.fields` when the caller supplies it, so an injected + * field is dropped unless also added there. * * Mechanism: a property decorator receives the prototype, but Sequelize hook - * decorators must target a static method on the constructor, so a generated - * static method is attached to `target.constructor` and registered through the - * public `@BeforeCreate`. Hooks are installed by sequelize-typescript's - * `installHooks` during `Sequelize#addModels`. + * decorators must target a static method on the constructor, so generated static + * methods are attached to `target.constructor` and registered through the public + * `@BeforeCreate` / `@BeforeBulkCreate`. Hooks are installed by + * sequelize-typescript's `installHooks` during `Sequelize#addModels`. * * @param {any} target - the decorated model's prototype * @param {string} propertyName - the decorated column property name @@ -48,6 +55,7 @@ import { BeforeCreate } from 'sequelize-typescript'; export function CreatedBy(target: any, propertyName: string): void { const ctor = target.constructor; const hookName = `__stampCreatedBy$${propertyName}`; + const bulkHook = `__stampCreatedByOnBulkCreate$${propertyName}`; if (ctor[hookName]) { return; @@ -61,5 +69,26 @@ export function CreatedBy(target: any, propertyName: string): void { } }; + ctor[bulkHook] = function (instances: any[], options: any): void { + const userId = currentMetadata()?.userId; + + if (userId == null || !Array.isArray(instances)) { + return; + } + + for (const instance of instances) { + if (instance && instance[propertyName] == null) { + instance[propertyName] = userId; + } + } + + if (options && Array.isArray(options.fields) + && !options.fields.includes(propertyName) + ) { + options.fields.push(propertyName); + } + }; + BeforeCreate(ctor, hookName); + BeforeBulkCreate(ctor, bulkHook); } diff --git a/src/decorators/UpdatedBy.ts b/src/decorators/UpdatedBy.ts index 1fc4390..c323819 100644 --- a/src/decorators/UpdatedBy.ts +++ b/src/decorators/UpdatedBy.ts @@ -23,6 +23,7 @@ */ import { currentMetadata } from '@imqueue/rpc'; import { + BeforeBulkCreate, BeforeBulkUpdate, BeforeCreate, BeforeUpdate, @@ -39,8 +40,11 @@ import { * - on UPDATE it always overwrites with the current actor — "last modified by" * must reflect who actually ran the update and not be caller-spoofable. * - * Three hooks are registered, because models are written in three ways: - * - INSERT -> `beforeCreate` (set-if-empty) + * Four hooks are registered, because models are written in four ways: + * - single INSERT -> `beforeCreate` (set-if-empty) + * - `Model.bulkCreate(records, …)` -> `beforeBulkCreate` (set-if-empty on the + * built instances; a plain `bulkCreate` does not fire `beforeCreate`, so the + * per-instance hook alone would be bypassed) * - instance `save()` / `update()` -> `beforeUpdate` (receives the instance) * - static `Model.update(values, …)` -> `beforeBulkUpdate` (receives options; * the values to write live on `options.attributes`, and Sequelize filters the @@ -56,6 +60,7 @@ import { export function UpdatedBy(target: any, propertyName: string): void { const ctor = target.constructor; const createHook = `__stampUpdatedByOnCreate$${propertyName}`; + const bulkCreateHook = `__stampUpdatedByOnBulkCreate$${propertyName}`; const singleHook = `__stampUpdatedBy$${propertyName}`; const bulkHook = `__stampBulkUpdatedBy$${propertyName}`; @@ -71,6 +76,26 @@ export function UpdatedBy(target: any, propertyName: string): void { } }; + ctor[bulkCreateHook] = function (instances: any[], options: any): void { + const userId = currentMetadata()?.userId; + + if (userId == null || !Array.isArray(instances)) { + return; + } + + for (const instance of instances) { + if (instance && instance[propertyName] == null) { + instance[propertyName] = userId; + } + } + + if (options && Array.isArray(options.fields) + && !options.fields.includes(propertyName) + ) { + options.fields.push(propertyName); + } + }; + ctor[singleHook] = function (instance: any): void { const userId = currentMetadata()?.userId; @@ -96,6 +121,7 @@ export function UpdatedBy(target: any, propertyName: string): void { }; BeforeCreate(ctor, createHook); + BeforeBulkCreate(ctor, bulkCreateHook); BeforeUpdate(ctor, singleHook); BeforeBulkUpdate(ctor, bulkHook); }