diff --git a/src/model.d.ts b/src/model.d.ts index 57249e000254..4b62e1dc19fb 100644 --- a/src/model.d.ts +++ b/src/model.d.ts @@ -1078,6 +1078,12 @@ export interface BulkCreateOptions extends Logging, Transacti * Return all columns or only the specified columns for the affected rows (only for postgres) */ returning?: boolean | (keyof TAttributes)[]; + + /** + * Optional override for the conflict fields in the ON CONFLICT part of the query. + * Only supported in Postgres >= 9.5 and SQLite >= 3.24.0 + */ + conflictAttributes?: Array; } /** diff --git a/src/model.js b/src/model.js index 2cab12accbc7..3231ced57577 100644 --- a/src/model.js +++ b/src/model.js @@ -2817,23 +2817,29 @@ class Model { if (options.updateOnDuplicate) { options.updateOnDuplicate = options.updateOnDuplicate.map(attr => model.rawAttributes[attr].field || attr); - const upsertKeys = []; + if (options.conflictAttributes) { + options.upsertKeys = options.conflictAttributes.map( + attrName => model.rawAttributes[attrName].field || attrName + ); + } else { + const upsertKeys = []; - for (const i of model._indexes) { - if (i.unique && !i.where) { // Don't infer partial indexes - upsertKeys.push(...i.fields); + for (const i of model._indexes) { + if (i.unique && !i.where) { // Don't infer partial indexes + upsertKeys.push(...i.fields); + } } - } - const firstUniqueKey = Object.values(model.uniqueKeys).find(c => c.fields.length > 0); + const firstUniqueKey = Object.values(model.uniqueKeys).find(c => c.fields.length > 0); - if (firstUniqueKey && firstUniqueKey.fields) { - upsertKeys.push(...firstUniqueKey.fields); - } + if (firstUniqueKey && firstUniqueKey.fields) { + upsertKeys.push(...firstUniqueKey.fields); + } - options.upsertKeys = upsertKeys.length > 0 - ? upsertKeys - : Object.values(model.primaryKeys).map(x => x.field); + options.upsertKeys = upsertKeys.length > 0 + ? upsertKeys + : Object.values(model.primaryKeys).map(x => x.field); + } } // Map returning attributes to fields diff --git a/test/integration/model/bulk-create.test.js b/test/integration/model/bulk-create.test.js index 42720424818c..513e0877bf88 100644 --- a/test/integration/model/bulk-create.test.js +++ b/test/integration/model/bulk-create.test.js @@ -64,7 +64,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { await transaction.rollback(); }); } - + it('should not alter options', async function() { const User = this.sequelize.define('User'); await User.sync({ force: true }); @@ -732,6 +732,104 @@ describe(Support.getTestDialectTeaser('Model'), () => { this.User.bulkCreate(data, { updateOnDuplicate: [] }) ).to.be.rejectedWith('updateOnDuplicate option only supports non-empty array.'); }); + + if (current.dialect.supports.inserts.conflictFields) { + it('should respect the conflictAttributes option', async function() { + const Permissions = this.sequelize.define( + 'permissions', + { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id' + }, + permissions: { + type: new DataTypes.ENUM('owner', 'admin', 'member'), + allowNull: false, + default: 'member' + } + }, + { + timestamps: false + } + ); + + await Permissions.sync({ force: true }); + + // We don't want to create this index with the table, since we don't want our sequelize instance + // to know it exists. This prevents it from being inferred. + await this.sequelize.queryInterface.addIndex( + 'permissions', + ['user_id'], + { + unique: true + } + ); + + const initialPermissions = [ + { + userId: 1, + permissions: 'member' + }, + { + userId: 2, + permissions: 'admin' + }, + { + userId: 3, + permissions: 'owner' + } + ]; + + const initialResults = await Permissions.bulkCreate(initialPermissions, { + conflictAttributes: ['userId'], + updateOnDuplicate: ['permissions'] + }); + + expect(initialResults.length).to.eql(3); + + for (let i = 0; i < 3; i++) { + const result = initialResults[i]; + const exp = initialPermissions[i]; + + expect(result).to.not.eql(null); + expect(result.userId).to.eql(exp.userId); + expect(result.permissions).to.eql(exp.permissions); + } + + const newPermissions = [ + { + userId: 1, + permissions: 'owner' + }, + { + userId: 2, + permissions: 'member' + }, + { + userId: 3, + permissions: 'admin' + } + ]; + + const newResults = await Permissions.bulkCreate(newPermissions, { + conflictAttributes: ['userId'], + updateOnDuplicate: ['permissions'] + }); + + expect(newResults.length).to.eql(3); + + for (let i = 0; i < 3; i++) { + const result = newResults[i]; + const exp = newPermissions[i]; + + expect(result).to.not.eql(null); + expect(result.id).to.eql(initialResults[i].id); + expect(result.userId).to.eql(exp.userId); + expect(result.permissions).to.eql(exp.permissions); + } + }); + } }); } diff --git a/test/types/bulk-create.ts b/test/types/bulk-create.ts new file mode 100644 index 000000000000..cd76fad9eb5e --- /dev/null +++ b/test/types/bulk-create.ts @@ -0,0 +1,60 @@ +import { + Model, + InferAttributes, + CreationOptional, + InferCreationAttributes, +} from 'sequelize'; +import { sequelize } from './connection'; +import type { MakeNullishOptional } from 'sequelize/types/utils'; + +class TestModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare testString: CreationOptional; + declare testEnum: CreationOptional<'d' | 'e' | 'f' | null>; +} + +type wat = InferCreationAttributes; + +sequelize.transaction(async trx => { + const newItems: Array< + MakeNullishOptional> + > = [ + { + testEnum: 'e', + testString: 'abc', + }, + { + testEnum: null, + testString: undefined, + }, + ]; + + const res1: Array = await TestModel.bulkCreate(newItems, { + benchmark: true, + fields: ['testEnum'], + hooks: true, + logging: true, + returning: true, + transaction: trx, + validate: true, + ignoreDuplicates: true, + }); + + const res2: Array = await TestModel.bulkCreate(newItems, { + benchmark: true, + fields: ['testEnum'], + hooks: true, + logging: true, + returning: false, + transaction: trx, + validate: true, + updateOnDuplicate: ['testEnum', 'testString'], + }); + + const res3: Array = await TestModel.bulkCreate(newItems, { + conflictAttributes: ['testEnum', 'testString'], + }); +}); diff --git a/test/unit/model/bulk-create.test.js b/test/unit/model/bulk-create.test.js index e7cfa83b0370..a19d3bbffd8a 100644 --- a/test/unit/model/bulk-create.test.js +++ b/test/unit/model/bulk-create.test.js @@ -15,6 +15,11 @@ describe(Support.getTestDialectTeaser('Model'), () => { type: DataTypes.INTEGER(11).UNSIGNED, allowNull: false, field: 'account_id' + }, + purchaseCount: { + type: DataTypes.INTEGER(11).UNSIGNED, + allowNull: false, + underscored: true } }, { timestamps: false }); @@ -32,13 +37,28 @@ describe(Support.getTestDialectTeaser('Model'), () => { describe('validations', () => { it('should not fail for renamed fields', async function() { await this.Model.bulkCreate([ - { accountId: 42 } + { accountId: 42, purchaseCount: 4 } ], { validate: true }); expect(this.stub.getCall(0).args[1]).to.deep.equal([ - { account_id: 42, id: null } + { account_id: 42, purchaseCount: 4, id: null } ]); }); + + if (current.dialect.supports.inserts.updateOnDuplicate) { + it('should map conflictAttributes to column names', async function() { + // Note that the model also has an id key as its primary key. + await this.Model.bulkCreate([{ accountId: 42, purchaseCount: 3 }], { + conflictAttributes: ['accountId'], + updateOnDuplicate: ['purchaseCount'] + }); + + expect( + // Not worth checking that the reference of the array matches - just the contents. + this.stub.getCall(0).args[2].upsertKeys + ).to.deep.equal(['account_id']); + }); + } }); }); });