diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..ce2684934d6f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* -lf \ No newline at end of file diff --git a/.github/stale.yml b/.github/stale.yml index dca733fa73dd..8442c6125e98 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,13 +1,14 @@ - # Configuration for probot-stale - https://github.com/probot/stale -# Number of days of inactivity before an issue becomes stale -# daysUntilStale: 90 -daysUntilStale: 900000 # Temporarily disable +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 60 + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 7 -# Number of days of inactivity before a stale issue is closed -# daysUntilClose: 7 -daysUntilClose: 70000 # Temporarily disable +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: [] # Issues with these labels will never be considered stale exemptLabels: @@ -21,10 +22,16 @@ exemptLabels: - good first issue - suggestion +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: true + # Set to true to ignore issues in a milestone (defaults to false) exemptMilestones: true -# Label to use when marking an issue as stale +# Set to true to ignore issues with an assignee (defaults to false) +exemptAssignees: true + +# Label to use when marking as stale staleLabel: stale # Limit to only `issues` or `pulls` @@ -36,5 +43,5 @@ markComment: > recent activity. It will be closed if no further activity occurs. If this is still an issue, just leave a comment 🙂 -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 diff --git a/README.md b/README.md index b1ae2d0b4297..957d12f1bc1d 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ [![npm version](https://badgen.net/npm/v/sequelize)](https://www.npmjs.com/package/sequelize) [![Build Status](https://github.com/sequelize/sequelize/workflows/CI/badge.svg)](https://github.com/sequelize/sequelize/actions?query=workflow%3ACI) - [![npm downloads](https://badgen.net/npm/dm/sequelize)](https://www.npmjs.com/package/sequelize) +[![sponsor](https://img.shields.io/opencollective/all/sequelize?label=sponsors)](https://opencollective.com/sequelize) [![Merged PRs](https://badgen.net/github/merged-prs/sequelize/sequelize)](https://github.com/sequelize/sequelize) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) + Sequelize is a promise-based [Node.js](https://nodejs.org/en/about/) [ORM tool](https://en.wikipedia.org/wiki/Object-relational_mapping) for [Postgres](https://en.wikipedia.org/wiki/PostgreSQL), [MySQL](https://en.wikipedia.org/wiki/MySQL), [MariaDB](https://en.wikipedia.org/wiki/MariaDB), [SQLite](https://en.wikipedia.org/wiki/SQLite) and [Microsoft SQL Server](https://en.wikipedia.org/wiki/Microsoft_SQL_Server). It features solid transaction support, relations, eager and lazy loading, read replication and more. diff --git a/docs/manual/core-concepts/model-querying-basics.md b/docs/manual/core-concepts/model-querying-basics.md index 3f5c1a0ff664..325130befab5 100644 --- a/docs/manual/core-concepts/model-querying-basics.md +++ b/docs/manual/core-concepts/model-querying-basics.md @@ -699,3 +699,14 @@ await User.min('age', { where: { age: { [Op.gt]: 5 } } }); // 10 await User.sum('age'); // 55 await User.sum('age', { where: { age: { [Op.gt]: 5 } } }); // 50 ``` + +### `increment`, `decrement` + +Sequelize also provides the `increment` convenience method. + +Let's assume we have a user, whose age is 10. + +```js +await User.increment({age: 5}, { where: { id: 1 } }) // Will increase age to 15 +await User.increment({age: -5}, { where: { id: 1 } }) // Will decrease age to 5 +``` diff --git a/docs/manual/other-topics/hooks.md b/docs/manual/other-topics/hooks.md index 71791719bb4f..8f60a206d5a3 100644 --- a/docs/manual/other-topics/hooks.md +++ b/docs/manual/other-topics/hooks.md @@ -367,7 +367,8 @@ User.addHook('afterCreate', async (user, options) => { await sequelize.transaction(async t => { await User.create({ username: 'someguy', - mood: 'happy', + mood: 'happy' + }, { transaction: t }); }); diff --git a/docs/manual/other-topics/resources.md b/docs/manual/other-topics/resources.md index 291f25712526..a90b3d40773c 100644 --- a/docs/manual/other-topics/resources.md +++ b/docs/manual/other-topics/resources.md @@ -60,3 +60,4 @@ * [sequelize-deep-update](https://www.npmjs.com/package/sequelize-deep-update) - Update a sequelize instance and its included associated instances with new properties. * [sequelize-noupdate-attributes](https://www.npmjs.com/package/sequelize-noupdate-attributes) - Adds no update/readonly attributes support to models. * [sequelize-joi](https://www.npmjs.com/package/sequelize-joi) - Allows specifying [Joi](https://github.com/hapijs/joi) validation schema for JSONB model attributes in Sequelize. +* [sqlcommenter-sequelize](https://github.com/google/sqlcommenter/tree/master/nodejs/sqlcommenter-nodejs/packages/sqlcommenter-sequelize) A [sqlcommenter](https://google.github.io/sqlcommenter/) plugin with [support for Sequelize](https://google.github.io/sqlcommenter/node/sequelize/) to augment SQL statements with comments that can be used later to correlate application code with SQL statements. \ No newline at end of file diff --git a/lib/dialects/abstract/query-generator.js b/lib/dialects/abstract/query-generator.js index c9793a45693e..e6e9e02df1a4 100644 --- a/lib/dialects/abstract/query-generator.js +++ b/lib/dialects/abstract/query-generator.js @@ -156,7 +156,7 @@ class QueryGenerator { fields.push(this.quoteIdentifier(key)); // SERIALS' can't be NULL in postgresql, use DEFAULT where supported - if (modelAttributeMap && modelAttributeMap[key] && modelAttributeMap[key].autoIncrement === true && !value) { + if (modelAttributeMap && modelAttributeMap[key] && modelAttributeMap[key].autoIncrement === true && value == null) { if (!this._dialect.supports.autoIncrement.defaultValue) { fields.splice(-1, 1); } else if (this._dialect.supports.DEFAULT) { @@ -276,7 +276,8 @@ class QueryGenerator { this._dialect.supports.bulkDefault && serials[key] === true ) { - return fieldValueHash[key] || 'DEFAULT'; + // fieldValueHashes[key] ?? 'DEFAULT' + return fieldValueHash[key] != null ? fieldValueHash[key] : 'DEFAULT'; } return this.escape(fieldValueHash[key], fieldMappedAttributes[key], { context: 'INSERT' }); diff --git a/lib/dialects/abstract/query-interface.js b/lib/dialects/abstract/query-interface.js index e1876d5cef64..f77bd92f93f6 100644 --- a/lib/dialects/abstract/query-interface.js +++ b/lib/dialects/abstract/query-interface.js @@ -1011,7 +1011,9 @@ class QueryInterface { } } if (dataType instanceof DataTypes.INTEGER || dataType instanceof DataTypes.BIGINT) { - return parseInt(result, 10); + if (result !== null) { + return parseInt(result, 10); + } } if (dataType instanceof DataTypes.DATE) { if (result !== null && !(result instanceof Date)) { diff --git a/lib/dialects/postgres/connection-manager.js b/lib/dialects/postgres/connection-manager.js index 0cf2f793743d..a07669efcdcc 100644 --- a/lib/dialects/postgres/connection-manager.js +++ b/lib/dialects/postgres/connection-manager.js @@ -113,8 +113,10 @@ class ConnectionManager extends AbstractConnectionManager { // This should help with backends incorrectly considering idle clients to be dead and prematurely disconnecting them. // this feature has been added in pg module v6.0.0, check pg/CHANGELOG.md 'keepAlive', - // Times out queries after a set time in milliseconds. Added in pg v7.3 + // Times out queries after a set time in milliseconds in the database end. Added in pg v7.3 'statement_timeout', + // Times out queries after a set time in milliseconds in client end, query would be still running in database end. + 'query_timeout', // Terminate any session with an open transaction that has been idle for longer than the specified duration in milliseconds. Added in pg v7.17.0 only supported in postgres >= 10 'idle_in_transaction_session_timeout' ])); diff --git a/lib/dialects/postgres/query.js b/lib/dialects/postgres/query.js index e4e4135fe6d1..535cab312c5c 100644 --- a/lib/dialects/postgres/query.js +++ b/lib/dialects/postgres/query.js @@ -264,6 +264,12 @@ class Query extends AbstractQuery { } if (this.isInsertQuery() || this.isUpdateQuery() || this.isUpsertQuery()) { if (this.instance && this.instance.dataValues) { + // If we are creating an instance, and we get no rows, the create failed but did not throw. + // This probably means a conflict happened and was ignored, to avoid breaking a transaction. + if (this.isInsertQuery() && rowCount === 0) { + throw new sequelizeErrors.EmptyResultError(); + } + for (const key in rows[0]) { if (Object.prototype.hasOwnProperty.call(rows[0], key)) { const record = rows[0][key]; diff --git a/lib/dialects/sqlite/connection-manager.js b/lib/dialects/sqlite/connection-manager.js index ecb4371c034a..eb6017bbd197 100644 --- a/lib/dialects/sqlite/connection-manager.js +++ b/lib/dialects/sqlite/connection-manager.js @@ -45,7 +45,15 @@ class ConnectionManager extends AbstractConnectionManager { async getConnection(options) { options = options || {}; options.uuid = options.uuid || 'default'; - options.storage = this.sequelize.options.storage || this.sequelize.options.host || ':memory:'; + + if (!!this.sequelize.options.storage !== null && this.sequelize.options.storage !== undefined) { + // Check explicitely for the storage option to not be set since an empty string signals + // SQLite will create a temporary disk-based database in that case. + options.storage = this.sequelize.options.storage; + } else { + options.storage = this.sequelize.options.host || ':memory:'; + } + options.inMemory = options.storage === ':memory:' ? 1 : 0; const dialectOptions = this.sequelize.options.dialectOptions; diff --git a/lib/model.js b/lib/model.js index e206a8c687f8..effca70af190 100644 --- a/lib/model.js +++ b/lib/model.js @@ -528,7 +528,7 @@ class Model { if (include.subQuery !== false && options.hasDuplicating && options.topLimit) { if (include.duplicating) { - include.subQuery = false; + include.subQuery = include.subQuery || false; include.subQueryFilter = include.hasRequired; } else { include.subQuery = include.hasRequired; @@ -538,7 +538,6 @@ class Model { include.subQuery = include.subQuery || false; if (include.duplicating) { include.subQueryFilter = include.subQuery; - include.subQuery = false; } else { include.subQueryFilter = false; include.subQuery = include.subQuery || include.hasParentRequired && include.hasRequired && !include.separate; @@ -2377,7 +2376,7 @@ class Model { } /** - * A more performant findOrCreate that will not work under a transaction (at least not in postgres) + * A more performant findOrCreate that may not work under a transaction (working in postgres) * Will execute a find call, if empty then attempt to create, if unique constraint then attempt to find again * * @see @@ -2406,10 +2405,20 @@ class Model { if (found) return [found, false]; try { - const created = await this.create(values, options); + const createOptions = { ...options }; + + // To avoid breaking a postgres transaction, run the create with `ignoreDuplicates`. + if (this.sequelize.options.dialect === 'postgres' && options.transaction) { + createOptions.ignoreDuplicates = true; + } + + const created = await this.create(values, createOptions); return [created, true]; } catch (err) { - if (!(err instanceof sequelizeErrors.UniqueConstraintError)) throw err; + if (!(err instanceof sequelizeErrors.UniqueConstraintError || err instanceof sequelizeErrors.EmptyResultError)) { + throw err; + } + const foundAgain = await this.findOne(options); return [foundAgain, false]; } diff --git a/test/integration/dialects/postgres/connection-manager.test.js b/test/integration/dialects/postgres/connection-manager.test.js index 0dbd9641980a..b6924416abb2 100644 --- a/test/integration/dialects/postgres/connection-manager.test.js +++ b/test/integration/dialects/postgres/connection-manager.test.js @@ -39,6 +39,14 @@ if (dialect.match(/^postgres/)) { // `notice` is Postgres's default expect(result[0].client_min_messages).to.equal('notice'); }); + + it('should time out the query request when the query runs beyond the configured query_timeout', async () => { + const sequelize = Support.createSequelizeInstance({ + dialectOptions: { query_timeout: 100 } + }); + const error = await sequelize.query('select pg_sleep(2)').catch(e => e); + expect(error.message).to.equal('Query read timeout'); + }); }); describe('Dynamic OIDs', () => { diff --git a/test/integration/model/create.test.js b/test/integration/model/create.test.js index d495ceb7a933..a5a417f69e35 100644 --- a/test/integration/model/create.test.js +++ b/test/integration/model/create.test.js @@ -568,31 +568,50 @@ describe(Support.getTestDialectTeaser('Model'), () => { }); describe('findCreateFind', () => { - (dialect !== 'sqlite' ? it : it.skip)('should work with multiple concurrent calls', async function() { - const [first, second, third] = await Promise.all([ - this.User.findOrCreate({ where: { uniqueName: 'winner' } }), - this.User.findOrCreate({ where: { uniqueName: 'winner' } }), - this.User.findOrCreate({ where: { uniqueName: 'winner' } }) - ]); + if (dialect !== 'sqlite') { + it('should work with multiple concurrent calls', async function() { + const [ + [instance1, created1], + [instance2, created2], + [instance3, created3] + ] = await Promise.all([ + this.User.findCreateFind({ where: { uniqueName: 'winner' } }), + this.User.findCreateFind({ where: { uniqueName: 'winner' } }), + this.User.findCreateFind({ where: { uniqueName: 'winner' } }) + ]); - const firstInstance = first[0], - firstCreated = first[1], - secondInstance = second[0], - secondCreated = second[1], - thirdInstance = third[0], - thirdCreated = third[1]; + // All instances are the same + expect(instance1.id).to.equal(1); + expect(instance2.id).to.equal(1); + expect(instance3.id).to.equal(1); + // Only one of the createdN values is true + expect(!!(created1 ^ created2 ^ created3)).to.be.true; + }); - expect([firstCreated, secondCreated, thirdCreated].filter(value => { - return value; - }).length).to.equal(1); + if (current.dialect.supports.transactions) { + it('should work with multiple concurrent calls within a transaction', async function() { + const t = await this.sequelize.transaction(); + const [ + [instance1, created1], + [instance2, created2], + [instance3, created3] + ] = await Promise.all([ + this.User.findCreateFind({ transaction: t, where: { uniqueName: 'winner' } }), + this.User.findCreateFind({ transaction: t, where: { uniqueName: 'winner' } }), + this.User.findCreateFind({ transaction: t, where: { uniqueName: 'winner' } }) + ]); - expect(firstInstance).to.be.ok; - expect(secondInstance).to.be.ok; - expect(thirdInstance).to.be.ok; + await t.commit(); - expect(firstInstance.id).to.equal(secondInstance.id); - expect(secondInstance.id).to.equal(thirdInstance.id); - }); + // All instances are the same + expect(instance1.id).to.equal(1); + expect(instance2.id).to.equal(1); + expect(instance3.id).to.equal(1); + // Only one of the createdN values is true + expect(!!(created1 ^ created2 ^ created3)).to.be.true; + }); + } + } }); describe('create', () => { diff --git a/test/integration/model/notExist.test.js b/test/integration/model/notExist.test.js new file mode 100644 index 000000000000..b8b0dd017f01 --- /dev/null +++ b/test/integration/model/notExist.test.js @@ -0,0 +1,58 @@ +'use strict'; + +const chai = require('chai'), + expect = chai.expect, + Support = require('../support'), + DataTypes = require('../../../lib/data-types'); + +describe(Support.getTestDialectTeaser('Model'), () => { + beforeEach(async function() { + this.Order = this.sequelize.define('Order', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + sequence: DataTypes.INTEGER, + amount: DataTypes.DECIMAL, + type: DataTypes.STRING + }); + + await this.sequelize.sync({ force: true }); + + await this.Order.bulkCreate([ + { sequence: 1, amount: 3, type: 'A' }, + { sequence: 2, amount: 4, type: 'A' }, + { sequence: 3, amount: 5, type: 'A' }, + { sequence: 4, amount: 1, type: 'A' }, + { sequence: 1, amount: 2, type: 'B' }, + { sequence: 2, amount: 6, type: 'B' } + ]); + }); + + describe('max', () => { + it('should type exist', async function() { + await expect(this.Order.sum('sequence', { where: { type: 'A' } })).to.eventually.be.equal(10); + await expect(this.Order.max('sequence', { where: { type: 'A' } })).to.eventually.be.equal(4); + await expect(this.Order.min('sequence', { where: { type: 'A' } })).to.eventually.be.equal(1); + await expect(this.Order.sum('amount', { where: { type: 'A' } })).to.eventually.be.equal(13); + await expect(this.Order.max('amount', { where: { type: 'A' } })).to.eventually.be.equal(5); + await expect(this.Order.min('amount', { where: { type: 'A' } })).to.eventually.be.equal(1); + + await expect(this.Order.sum('sequence', { where: { type: 'B' } })).to.eventually.be.equal(3); + await expect(this.Order.max('sequence', { where: { type: 'B' } })).to.eventually.be.equal(2); + await expect(this.Order.min('sequence', { where: { type: 'B' } })).to.eventually.be.equal(1); + await expect(this.Order.sum('amount', { where: { type: 'B' } })).to.eventually.be.equal(8); + await expect(this.Order.max('amount', { where: { type: 'B' } })).to.eventually.be.equal(6); + await expect(this.Order.min('amount', { where: { type: 'B' } })).to.eventually.be.equal(2); + }); + + it('should type not exist', async function() { + // DataTypes.INTEGER or DataTypes.BIGINT: previous version should use `.to.eventually.be.NaN` + await expect(this.Order.sum('sequence', { where: { type: 'C' } })).to.eventually.be.equal(0); + await expect(this.Order.max('sequence', { where: { type: 'C' } })).to.eventually.be.equal(0); + await expect(this.Order.min('sequence', { where: { type: 'C' } })).to.eventually.be.equal(0); + + // DataTypes.DECIMAL or DataTypes.FLOAT: previous and PR#13422 both use `to.eventually.be.equal(0)` + await expect(this.Order.sum('amount', { where: { type: 'C' } })).to.eventually.be.equal(0); + await expect(this.Order.max('amount', { where: { type: 'C' } })).to.eventually.be.equal(0); + await expect(this.Order.min('amount', { where: { type: 'C' } })).to.eventually.be.equal(0); + }); + }); +}); diff --git a/test/integration/query-interface.test.js b/test/integration/query-interface.test.js index 16ab9f55b5de..efc7f4056ad4 100644 --- a/test/integration/query-interface.test.js +++ b/test/integration/query-interface.test.js @@ -35,22 +35,14 @@ describe(Support.getTestDialectTeaser('QueryInterface'), () => { describe('showAllTables', () => { it('should not contain views', async function() { - async function cleanup() { - // NOTE: The syntax "DROP VIEW [IF EXISTS]"" is not part of the standard - // and might not be available on all RDBMSs. Therefore "DROP VIEW" is - // the compatible option, which can throw an error in case the VIEW does - // not exist. In case of error, it is ignored. - try { - await this.sequelize.query('DROP VIEW V_Fail'); - } catch (error) { - // Ignore error. - } + async function cleanup(sequelize) { + await sequelize.query('DROP VIEW IF EXISTS V_Fail'); } await this.queryInterface.createTable('my_test_table', { name: DataTypes.STRING }); - await cleanup(); + await cleanup(this.sequelize); await this.sequelize.query('CREATE VIEW V_Fail AS SELECT 1 Id'); let tableNames = await this.queryInterface.showAllTables(); - await cleanup(); + await cleanup(this.sequelize); if (tableNames[0] && tableNames[0].tableName) { tableNames = tableNames.map(v => v.tableName); } diff --git a/test/support.js b/test/support.js index e62f0861362a..57b351a8d984 100644 --- a/test/support.js +++ b/test/support.js @@ -113,7 +113,7 @@ const Support = { sequelizeOptions.native = true; } - if (config.storage) { + if (config.storage || config.storage === '') { sequelizeOptions.storage = config.storage; } diff --git a/test/unit/dialects/sqlite/connection-manager.test.js b/test/unit/dialects/sqlite/connection-manager.test.js new file mode 100644 index 000000000000..793b20bdd17c --- /dev/null +++ b/test/unit/dialects/sqlite/connection-manager.test.js @@ -0,0 +1,31 @@ +'use strict'; + +const chai = require('chai'), + expect = chai.expect, + Support = require('../../support'), + Sequelize = Support.Sequelize, + dialect = Support.getTestDialect(), + sinon = require('sinon'); + +if (dialect === 'sqlite') { + describe('[SQLITE Specific] ConnectionManager', () => { + describe('getConnection', () => { + it('should forward empty string storage to SQLite connector to create temporary disk-based database', () => { + // storage='' means anonymous disk-based database + const sequelize = new Sequelize('', '', '', { dialect: 'sqlite', storage: '' }); + + sinon.stub(sequelize.connectionManager, 'lib').value({ + Database: function FakeDatabase(_s, _m, cb) { + cb(); + return {}; + } + }); + sinon.stub(sequelize.connectionManager, 'connections').value({ default: { run: () => {} } }); + + const options = {}; + sequelize.dialect.connectionManager.getConnection(options); + expect(options.storage).to.be.equal(''); + }); + }); + }); +} diff --git a/test/unit/model/find-create-find.test.js b/test/unit/model/find-create-find.test.js index bc1df8cda81f..521dfa2116be 100644 --- a/test/unit/model/find-create-find.test.js +++ b/test/unit/model/find-create-find.test.js @@ -2,8 +2,8 @@ const chai = require('chai'), expect = chai.expect, + { EmptyResultError, UniqueConstraintError } = require('../../../lib/errors'), Support = require('../support'), - UniqueConstraintError = require('../../../lib/errors').UniqueConstraintError, current = Support.sequelize, sinon = require('sinon'); @@ -46,22 +46,24 @@ describe(Support.getTestDialectTeaser('Model'), () => { expect(createSpy).to.have.been.calledWith(where); }); - it('should do a second find if create failed do to unique constraint', async function() { - const result = {}, - where = { prop: Math.random().toString() }, - findSpy = this.sinon.stub(Model, 'findOne'); + [EmptyResultError, UniqueConstraintError].forEach(Error => { + it(`should do a second find if create failed due to an error of type ${Error.name}`, async function() { + const result = {}, + where = { prop: Math.random().toString() }, + findSpy = this.sinon.stub(Model, 'findOne'); - this.sinon.stub(Model, 'create').rejects(new UniqueConstraintError()); + this.sinon.stub(Model, 'create').rejects(new Error()); - findSpy.onFirstCall().resolves(null); - findSpy.onSecondCall().resolves(result); + findSpy.onFirstCall().resolves(null); + findSpy.onSecondCall().resolves(result); - await expect(Model.findCreateFind({ - where - })).to.eventually.eql([result, false]); + await expect(Model.findCreateFind({ + where + })).to.eventually.eql([result, false]); - expect(findSpy).to.have.been.calledTwice; - expect(findSpy.getCall(1).args[0].where).to.equal(where); + expect(findSpy).to.have.been.calledTwice; + expect(findSpy.getCall(1).args[0].where).to.equal(where); + }); }); }); }); diff --git a/test/unit/sql/insert.test.js b/test/unit/sql/insert.test.js index 34b09acdaf27..c9ba66b98e9e 100644 --- a/test/unit/sql/insert.test.js +++ b/test/unit/sql/insert.test.js @@ -36,6 +36,26 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }); }); + + it('allow insert primary key with 0', () => { + const M = Support.sequelize.define('m', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + } + }); + + expectsql(sql.insertQuery(M.tableName, { id: 0 }, M.rawAttributes), + { + query: { + mssql: 'SET IDENTITY_INSERT [ms] ON; INSERT INTO [ms] ([id]) VALUES ($1); SET IDENTITY_INSERT [ms] OFF;', + postgres: 'INSERT INTO "ms" ("id") VALUES ($1);', + default: 'INSERT INTO `ms` (`id`) VALUES ($1);' + }, + bind: [0] + }); + }); }); describe('dates', () => { @@ -161,5 +181,24 @@ describe(Support.getTestDialectTeaser('SQL'), () => { sqlite: 'INSERT INTO `users` (`user_name`,`pass_word`) VALUES (\'testuser\',\'12345\') ON CONFLICT (`user_name`) DO UPDATE SET `user_name`=EXCLUDED.`user_name`,`pass_word`=EXCLUDED.`pass_word`,`updated_at`=EXCLUDED.`updated_at`;' }); }); + + it('allow bulk insert primary key with 0', () => { + const M = Support.sequelize.define('m', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + } + }); + + expectsql(sql.bulkInsertQuery(M.tableName, [{ id: 0 }, { id: null }], {}, M.fieldRawAttributesMap), + { + query: { + mssql: 'SET IDENTITY_INSERT [ms] ON; INSERT INTO [ms] DEFAULT VALUES;INSERT INTO [ms] ([id]) VALUES (0),(NULL);; SET IDENTITY_INSERT [ms] OFF;', + postgres: 'INSERT INTO "ms" ("id") VALUES (0),(DEFAULT);', + default: 'INSERT INTO `ms` (`id`) VALUES (0),(NULL);' + } + }); + }); }); }); diff --git a/test/unit/sql/select.test.js b/test/unit/sql/select.test.js index a511bbb66ef3..727ae7790c75 100644 --- a/test/unit/sql/select.test.js +++ b/test/unit/sql/select.test.js @@ -449,7 +449,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }); }); - it('include (subQuery alias)', () => { + describe('include (subQuery alias)', () => { const User = Support.sequelize.define('User', { name: DataTypes.STRING, age: DataTypes.INTEGER @@ -466,29 +466,107 @@ describe(Support.getTestDialectTeaser('SQL'), () => { User.Posts = User.hasMany(Post, { foreignKey: 'user_id', as: 'postaliasname' }); - expectsql(sql.selectQuery('User', { - table: User.getTableName(), - model: User, - attributes: ['name', 'age'], + it('w/o filters', () => { + expectsql(sql.selectQuery('User', { + table: User.getTableName(), + model: User, + attributes: ['name', 'age'], + include: Model._validateIncludedElements({ + include: [{ + attributes: ['title'], + association: User.Posts, + subQuery: true, + required: true + }], + as: 'User' + }).include, + subQuery: true + }, User), { + default: 'SELECT [User].* FROM ' + + '(SELECT [User].[name], [User].[age], [User].[id] AS [id], [postaliasname].[id] AS [postaliasname.id], [postaliasname].[title] AS [postaliasname.title] FROM [User] AS [User] ' + + 'INNER JOIN [Post] AS [postaliasname] ON [User].[id] = [postaliasname].[user_id] ' + + `WHERE ( SELECT [user_id] FROM [Post] AS [postaliasname] WHERE ([postaliasname].[user_id] = [User].[id])${sql.addLimitAndOffset({ limit: 1, tableAs: 'postaliasname' }, User)} ) IS NOT NULL) AS [User];` + }); + }); + + it('w/ nested column filter', () => { + expectsql(sql.selectQuery('User', { + table: User.getTableName(), + model: User, + attributes: ['name', 'age'], + where: { '$postaliasname.title$': 'test' }, + include: Model._validateIncludedElements({ + include: [{ + attributes: ['title'], + association: User.Posts, + subQuery: true, + required: true + }], + as: 'User' + }).include, + subQuery: true + }, User), { + default: 'SELECT [User].* FROM ' + + '(SELECT [User].[name], [User].[age], [User].[id] AS [id], [postaliasname].[id] AS [postaliasname.id], [postaliasname].[title] AS [postaliasname.title] FROM [User] AS [User] ' + + 'INNER JOIN [Post] AS [postaliasname] ON [User].[id] = [postaliasname].[user_id] ' + + `WHERE [postaliasname].[title] = ${sql.escape('test')} AND ( SELECT [user_id] FROM [Post] AS [postaliasname] WHERE ([postaliasname].[user_id] = [User].[id])${sql.addLimitAndOffset({ limit: 1, tableAs: 'postaliasname' }, User)} ) IS NOT NULL) AS [User];` + }); + }); + }); + + it('include w/ subQuery + nested filter + paging', () => { + const User = Support.sequelize.define('User', { + scopeId: DataTypes.INTEGER + }); + + const Company = Support.sequelize.define('Company', { + name: DataTypes.STRING, + public: DataTypes.BOOLEAN, + scopeId: DataTypes.INTEGER + }); + + const Profession = Support.sequelize.define('Profession', { + name: DataTypes.STRING, + scopeId: DataTypes.INTEGER + }); + + User.Company = User.belongsTo(Company, { foreignKey: 'companyId' }); + User.Profession = User.belongsTo(Profession, { foreignKey: 'professionId' }); + Company.Users = Company.hasMany(User, { as: 'Users', foreignKey: 'companyId' }); + Profession.Users = Profession.hasMany(User, { as: 'Users', foreignKey: 'professionId' }); + + expectsql(sql.selectQuery('Company', { + table: Company.getTableName(), + model: Company, + attributes: ['name', 'public'], + where: { '$Users.Profession.name$': 'test', [Op.and]: { scopeId: [42] } }, include: Model._validateIncludedElements({ include: [{ - attributes: ['title'], - association: User.Posts, + association: Company.Users, + attributes: [], + include: [{ + association: User.Profession, + attributes: [], + required: true + }], subQuery: true, required: true }], - as: 'User' + model: Company }).include, + limit: 5, + offset: 0, subQuery: true - }, User), { - default: 'SELECT [User].*, [postaliasname].[id] AS [postaliasname.id], [postaliasname].[title] AS [postaliasname.title] FROM ' + - '(SELECT [User].[name], [User].[age], [User].[id] AS [id] FROM [User] AS [User] ' + - 'WHERE ( SELECT [user_id] FROM [Post] AS [postaliasname] WHERE ([postaliasname].[user_id] = [User].[id]) LIMIT 1 ) IS NOT NULL) AS [User] ' + - 'INNER JOIN [Post] AS [postaliasname] ON [User].[id] = [postaliasname].[user_id];', - mssql: 'SELECT [User].*, [postaliasname].[id] AS [postaliasname.id], [postaliasname].[title] AS [postaliasname.title] FROM ' + - '(SELECT [User].[name], [User].[age], [User].[id] AS [id] FROM [User] AS [User] ' + - 'WHERE ( SELECT [user_id] FROM [Post] AS [postaliasname] WHERE ([postaliasname].[user_id] = [User].[id]) ORDER BY [postaliasname].[id] OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY ) IS NOT NULL) AS [User] ' + - 'INNER JOIN [Post] AS [postaliasname] ON [User].[id] = [postaliasname].[user_id];' + }, Company), { + default: 'SELECT [Company].* FROM (' + + 'SELECT [Company].[name], [Company].[public], [Company].[id] AS [id] FROM [Company] AS [Company] ' + + 'INNER JOIN [Users] AS [Users] ON [Company].[id] = [Users].[companyId] ' + + 'INNER JOIN [Professions] AS [Users->Profession] ON [Users].[professionId] = [Users->Profession].[id] ' + + `WHERE ([Company].[scopeId] IN (42)) AND [Users->Profession].[name] = ${sql.escape('test')} AND ( ` + + 'SELECT [Users].[companyId] FROM [Users] AS [Users] ' + + 'INNER JOIN [Professions] AS [Profession] ON [Users].[professionId] = [Profession].[id] ' + + `WHERE ([Users].[companyId] = [Company].[id])${sql.addLimitAndOffset({ limit: 1, tableAs: 'Users' }, User)} ` + + `) IS NOT NULL${sql.addLimitAndOffset({ limit: 5, offset: 0, tableAs: 'Company' }, Company)}) AS [Company];` }); }); diff --git a/types/lib/errors.d.ts b/types/lib/errors.d.ts index a575486bec62..b4b84a781aee 100644 --- a/types/lib/errors.d.ts +++ b/types/lib/errors.d.ts @@ -156,6 +156,16 @@ export class ExclusionConstraintError extends DatabaseError { constructor(options: { parent?: Error; message?: string; constraint?: string; fields?: string[]; table?: string }); } +/** + * Thrown when constraint name is not found in the database + */ +export class UnknownConstraintError extends DatabaseError { + public constraint: string; + public fields: { [field: string]: string }; + public table: string; + constructor(options: { parent?: Error; message?: string; constraint?: string; fields?: string[]; table?: string }); +} + /** * Thrown when attempting to update a stale model instance */ diff --git a/types/lib/hooks.d.ts b/types/lib/hooks.d.ts index fd0ef7aef816..480495bfa461 100644 --- a/types/lib/hooks.d.ts +++ b/types/lib/hooks.d.ts @@ -6,6 +6,7 @@ import Model, { CreateOptions, DestroyOptions, RestoreOptions, + UpsertOptions, FindOptions, InstanceDestroyOptions, InstanceRestoreOptions, @@ -34,6 +35,8 @@ export interface ModelHooks { afterRestore(instance: M, options: InstanceRestoreOptions): HookReturn; beforeUpdate(instance: M, options: InstanceUpdateOptions): HookReturn; afterUpdate(instance: M, options: InstanceUpdateOptions): HookReturn; + beforeUpsert(attributes: M, options: UpsertOptions): HookReturn; + afterUpsert(attributes: [ M, boolean | null ], options: UpsertOptions): HookReturn; beforeSave( instance: M, options: InstanceUpdateOptions | CreateOptions diff --git a/types/lib/model.d.ts b/types/lib/model.d.ts index 8e4b4f55b6c4..46da75e54c29 100644 --- a/types/lib/model.d.ts +++ b/types/lib/model.d.ts @@ -161,6 +161,9 @@ export interface WhereOperators { /** Example: `[Op.not]: true,` becomes `IS NOT TRUE` */ [Op.not]?: null | boolean | string | number | Literal | WhereOperators; + /** Example: `[Op.is]: null,` becomes `IS NULL` */ + [Op.is]?: null; + /** Example: `[Op.between]: [6, 10],` becomes `BETWEEN 6 AND 10` */ [Op.between]?: Rangable; @@ -735,7 +738,7 @@ export interface UpsertOptions extends Logging, Transactionab /** * Options for Model.bulkCreate method */ -export interface BulkCreateOptions extends Logging, Transactionable, Hookable { +export interface BulkCreateOptions extends Logging, Transactionable, Hookable, SearchPathable { /** * Fields to insert (defaults to all fields) */ @@ -1106,13 +1109,13 @@ export interface ModelValidateOptions { /** * check the value is not one of these */ - notIn?: ReadonlyArray | { msg: string; args: ReadonlyArray }; + notIn?: ReadonlyArray | { msg: string; args: ReadonlyArray }; /** * check the value is one of these */ - isIn?: ReadonlyArray | { msg: string; args: ReadonlyArray }; - + isIn?: ReadonlyArray | { msg: string; args: ReadonlyArray }; + /** * don't allow specific substrings */ @@ -1994,7 +1997,7 @@ export abstract class Model( this: ModelStatic, diff --git a/types/test/hooks.ts b/types/test/hooks.ts index cec4d0cdfa4b..340038759c41 100644 --- a/types/test/hooks.ts +++ b/types/test/hooks.ts @@ -1,6 +1,6 @@ import { expectTypeOf } from "expect-type"; import { SemiDeepWritable } from "./type-helpers/deep-writable"; -import { Model, SaveOptions, Sequelize, FindOptions, ModelCtor, ModelType, ModelDefined, ModelStatic } from "sequelize"; +import { Model, SaveOptions, Sequelize, FindOptions, ModelCtor, ModelType, ModelDefined, ModelStatic, UpsertOptions } from "sequelize"; import { ModelHooks } from "../lib/hooks"; import { DeepWriteable } from '../lib/utils'; import { Config } from '../lib/sequelize'; @@ -20,7 +20,15 @@ import { Config } from '../lib/sequelize'; afterFind(m, options) { expectTypeOf(m).toEqualTypeOf(); expectTypeOf(options).toEqualTypeOf(); - } + }, + beforeUpsert(m, options) { + expectTypeOf(m).toEqualTypeOf(); + expectTypeOf(options).toEqualTypeOf(); + }, + afterUpsert(m, options) { + expectTypeOf(m).toEqualTypeOf<[ TestModel, boolean | null ]>(); + expectTypeOf(options).toEqualTypeOf(); + }, }; const sequelize = new Sequelize('uri', { hooks }); @@ -29,6 +37,8 @@ import { Config } from '../lib/sequelize'; TestModel.addHook('beforeSave', hooks.beforeSave!); TestModel.addHook('afterSave', hooks.afterSave!); TestModel.addHook('afterFind', hooks.afterFind!); + TestModel.addHook('beforeUpsert', hooks.beforeUpsert!); + TestModel.addHook('afterUpsert', hooks.afterUpsert!); TestModel.beforeSave(hooks.beforeSave!); TestModel.afterSave(hooks.afterSave!); @@ -60,6 +70,7 @@ import { Config } from '../lib/sequelize'; hooks.beforeFindAfterOptions = (...args) => { expectTypeOf(args).toEqualTypeOf>() }; hooks.beforeSync = (...args) => { expectTypeOf(args).toEqualTypeOf>() }; hooks.beforeBulkSync = (...args) => { expectTypeOf(args).toEqualTypeOf>() }; + hooks.beforeUpsert = (...args) => { expectTypeOf(args).toEqualTypeOf>() }; } { diff --git a/types/test/model.ts b/types/test/model.ts index afd9a1f07d51..af7cbf89d8e8 100644 --- a/types/test/model.ts +++ b/types/test/model.ts @@ -58,7 +58,7 @@ MyModel.count({ include: [MyModel], where: { '$num$': [10, 120] } }); MyModel.build({ int: 10 }, { include: OtherModel }); -MyModel.bulkCreate([{ int: 10 }], { include: OtherModel }); +MyModel.bulkCreate([{ int: 10 }], { include: OtherModel, searchPath: 'public' }); MyModel.update({}, { where: { foo: 'bar' }, paranoid: false}); diff --git a/types/test/validators.ts b/types/test/validators.ts new file mode 100644 index 000000000000..56254de307db --- /dev/null +++ b/types/test/validators.ts @@ -0,0 +1,22 @@ +import { DataTypes, Model, Sequelize } from 'sequelize'; + +const sequelize = new Sequelize('mysql://user:user@localhost:3306/mydb'); + +/** + * Test for isIn/notIn validation - should accept any[] + */ +class ValidatedUser extends Model {} +ValidatedUser.init({ + name: { + type: DataTypes.STRING, + validate: { + isIn: [['first', 1, null]] + } + }, + email: { + type: DataTypes.STRING, + validate: { + notIn: [['second', 2, null]] + } + }, +}, { sequelize }); \ No newline at end of file diff --git a/types/test/where.ts b/types/test/where.ts index 58b30117ff74..6748f30c88ce 100644 --- a/types/test/where.ts +++ b/types/test/where.ts @@ -50,6 +50,7 @@ expectTypeOf({ [Op.lte]: 10, // <= 10 [Op.ne]: 20, // != 20 [Op.not]: true, // IS NOT TRUE + [Op.is]: null, // IS NULL [Op.between]: [6, 10], // BETWEEN 6 AND 10 [Op.notBetween]: [11, 15], // NOT BETWEEN 11 AND 15 [Op.in]: [1, 2], // IN [1, 2]