Skip to content

Commit

Permalink
feat(postgres, sqlite): allow override of conflict keys for bulkCreate (
Browse files Browse the repository at this point in the history
  • Loading branch information
wbourne0 committed Mar 23, 2023
1 parent 46d3553 commit 2e50bd9
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 15 deletions.
6 changes: 6 additions & 0 deletions src/model.d.ts
Expand Up @@ -1078,6 +1078,12 @@ export interface BulkCreateOptions<TAttributes = any> 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<keyof TAttributes>;
}

/**
Expand Down
30 changes: 18 additions & 12 deletions src/model.js
Expand Up @@ -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
Expand Down
100 changes: 99 additions & 1 deletion test/integration/model/bulk-create.test.js
Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
}
});
}
});
}

Expand Down
60 changes: 60 additions & 0 deletions 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<TestModel>,
InferCreationAttributes<TestModel>
> {
declare id: CreationOptional<number>;
declare testString: CreationOptional<string | null>;
declare testEnum: CreationOptional<'d' | 'e' | 'f' | null>;
}

type wat = InferCreationAttributes<TestModel>;

sequelize.transaction(async trx => {
const newItems: Array<
MakeNullishOptional<InferCreationAttributes<TestModel>>
> = [
{
testEnum: 'e',
testString: 'abc',
},
{
testEnum: null,
testString: undefined,
},
];

const res1: Array<TestModel> = await TestModel.bulkCreate(newItems, {
benchmark: true,
fields: ['testEnum'],
hooks: true,
logging: true,
returning: true,
transaction: trx,
validate: true,
ignoreDuplicates: true,
});

const res2: Array<TestModel> = await TestModel.bulkCreate(newItems, {
benchmark: true,
fields: ['testEnum'],
hooks: true,
logging: true,
returning: false,
transaction: trx,
validate: true,
updateOnDuplicate: ['testEnum', 'testString'],
});

const res3: Array<TestModel> = await TestModel.bulkCreate(newItems, {
conflictAttributes: ['testEnum', 'testString'],
});
});
24 changes: 22 additions & 2 deletions test/unit/model/bulk-create.test.js
Expand Up @@ -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 });

Expand All @@ -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']);
});
}
});
});
});

0 comments on commit 2e50bd9

Please sign in to comment.