'use strict';

const chai = require('chai');

const expect = chai.expect;
const Support = require('./support');

const dialect = Support.getTestDialect();
const { Sequelize, QueryTypes, DataTypes, Transaction } = require('@sequelize/core');
const sinon = require('sinon');

const current = Support.sequelize;
const delay = require('delay');
const pSettle = require('p-settle');

if (current.dialect.supports.transactions) {

  describe(Support.getTestDialectTeaser('Transaction'), () => {
    beforeEach(function () {
      this.sinon = sinon.createSandbox();
    });

    afterEach(function () {
      this.sinon.restore();
    });

    describe('constructor', () => {
      it('stores options', function () {
        const transaction = new Transaction(this.sequelize);
        expect(transaction.options).to.be.an.instanceOf(Object);
      });

      it('generates an identifier', function () {
        const transaction = new Transaction(this.sequelize);
        expect(transaction.id).to.exist;
      });

      it('should call dialect specific generateTransactionId method', function () {
        const transaction = new Transaction(this.sequelize);
        expect(transaction.id).to.exist;
        if (dialect === 'mssql') {
          expect(transaction.id).to.have.lengthOf(20);
        }
      });
    });

    describe('commit', () => {
      it('is a commit method available', () => {
        expect(Transaction).to.respondTo('commit');
      });
    });

    describe('rollback', () => {
      it('is a rollback method available', () => {
        expect(Transaction).to.respondTo('rollback');
      });
    });

    describe('autoCallback', () => {
      it('supports automatically committing', async function () {
        await this.sequelize.transaction(async () => {});
      });

      it('supports automatically rolling back with a thrown error', async function () {
        let t;

        await expect(this.sequelize.transaction(transaction => {
          t = transaction;
          throw new Error('Yolo');
        })).to.eventually.be.rejected;

        expect(t.finished).to.be.equal('rollback');
      });

      it('supports automatically rolling back with a rejection', async function () {
        let t;

        await expect(this.sequelize.transaction(async transaction => {
          t = transaction;
          throw new Error('Swag');
        })).to.eventually.be.rejected;

        expect(t.finished).to.be.equal('rollback');
      });

      it('supports running hooks when a transaction is committed', async function () {
        const hook = sinon.spy();
        let transaction;

        await expect((async () => {
          await this.sequelize.transaction(t => {
            transaction = t;
            transaction.afterCommit(hook);

            return this.sequelize.query('SELECT 1+1', { transaction, type: QueryTypes.SELECT });
          });

          expect(hook).to.have.been.calledOnce;
          expect(hook).to.have.been.calledWith(transaction);
        })()).to.eventually.be.fulfilled;
      });

      it('does not run hooks when a transaction is rolled back', async function () {
        const hook = sinon.spy();

        await expect(this.sequelize.transaction(async transaction => {
          transaction.afterCommit(hook);
          throw new Error('Rollback');
        })).to.eventually.be.rejected;

        expect(hook).to.not.have.been.called;
      });

      it('does not run hooks when a transaction is rolled back from database', async function () {
        this.sinon.stub(this.sequelize.queryInterface, 'commitTransaction').rejects(new Error('Oh no, an error!'));
        const hook = sinon.spy();

        await expect(
          (async function () {
            await this.sequelize.transaction(transaction => {
              transaction.afterCommit(hook);
            });
          }()),
        ).to.eventually.be.rejected;

        expect(hook).to.not.have.been.called;
      });

      if (dialect === 'postgres') {
        // See #3689, #3726 and #6972 (https://github.com/sequelize/sequelize/pull/6972/files#diff-533eac602d424db379c3d72af5089e9345fd9d3bbe0a26344503c22a0a5764f7L75)
        it('does not try to rollback a transaction that failed upon committing with SERIALIZABLE isolation level (#3689)', async function () {
          // See https://wiki.postgresql.org/wiki/SSI

          const hook1 = sinon.spy();
          const hook2 = sinon.spy();

          const Dots = this.sequelize.define('dots', { color: DataTypes.STRING });
          await Dots.sync({ force: true });

          const initialData = [
            { color: 'red' },
            { color: 'green' },
            { color: 'green' },
            { color: 'red' },
            { color: 'green' },
            { color: 'red' },
            { color: 'green' },
            { color: 'green' },
            { color: 'green' },
            { color: 'red' },
            { color: 'red' },
            { color: 'red' },
            { color: 'green' },
            { color: 'red' },
            { color: 'red' },
            { color: 'red' },
            { color: 'green' },
            { color: 'red' },
          ];

          await Dots.bulkCreate(initialData);

          const isolationLevel = Transaction.ISOLATION_LEVELS.SERIALIZABLE;

          let firstTransactionGotNearCommit = false;
          let secondTransactionGotNearCommit = false;

          const firstTransaction = async () => {
            await this.sequelize.transaction({ isolationLevel }, async t => {
              t.afterCommit(hook1);
              await Dots.update({ color: 'red' }, {
                where: { color: 'green' },
                transaction: t,
              });
              await delay(1500);
              firstTransactionGotNearCommit = true;
            });
          };

          const secondTransaction = async () => {
            await delay(500);
            await this.sequelize.transaction({ isolationLevel }, async t => {
              t.afterCommit(hook2);
              await Dots.update({ color: 'green' }, {
                where: { color: 'red' },
                transaction: t,
              });

              // Sanity check - in this test we want this line to be reached before the
              // first transaction gets to commit
              expect(firstTransactionGotNearCommit).to.be.false;

              secondTransactionGotNearCommit = true;
            });
          };

          await expect(
            Promise.all([firstTransaction(), secondTransaction()]),
          ).to.eventually.be.rejectedWith('could not serialize access due to read/write dependencies among transactions');

          expect(firstTransactionGotNearCommit).to.be.true;
          expect(secondTransactionGotNearCommit).to.be.true;

          // Only the second transaction worked
          expect(await Dots.count({ where: { color: 'red' } })).to.equal(0);
          expect(await Dots.count({ where: { color: 'green' } })).to.equal(initialData.length);

          expect(hook1).to.not.have.been.called;
          expect(hook2).to.have.been.called;
        });
      }

    });

    it('does not allow queries after commit', async function () {
      const t = await this.sequelize.startUnmanagedTransaction();
      await this.sequelize.query('SELECT 1+1', { transaction: t, raw: true });
      await t.commit();
      await expect(this.sequelize.query('SELECT 1+1', { transaction: t, raw: true })).to.be.eventually.rejectedWith(
        Error,
        /commit has been called on this transaction\([^)]+\), you can no longer use it\. \(The rejected query is attached as the 'sql' property of this error\)/,
      ).and.have.deep.property('sql').that.equal('SELECT 1+1');
    });

    it('does not allow queries immediately after commit call', async function () {
      await expect((async () => {
        const t = await this.sequelize.startUnmanagedTransaction();
        await this.sequelize.query('SELECT 1+1', { transaction: t, raw: true });
        await Promise.all([
          expect(t.commit()).to.eventually.be.fulfilled,
          expect(this.sequelize.query('SELECT 1+1', { transaction: t, raw: true })).to.be.eventually.rejectedWith(
            Error,
            /commit has been called on this transaction\([^)]+\), you can no longer use it\. \(The rejected query is attached as the 'sql' property of this error\)/,
          ).and.have.deep.property('sql').that.equal('SELECT 1+1'),
        ]);
      })()).to.be.eventually.fulfilled;
    });

    it('does not allow queries after rollback', async function () {
      await expect(
        (async () => {
          const t = await this.sequelize.startUnmanagedTransaction();
          await this.sequelize.query('SELECT 1+1', { transaction: t, raw: true });
          await t.rollback();

          return await this.sequelize.query('SELECT 1+1', { transaction: t, raw: true });
        })(),
      ).to.eventually.be.rejected;
    });

    it('should not rollback if connection was not acquired', async function () {
      this.sinon.stub(this.sequelize.connectionManager, '_connect')
        .returns(new Promise(() => {}));

      const transaction = new Transaction(this.sequelize);

      await expect(transaction.rollback())
        .to.eventually.be.rejectedWith('Transaction cannot be rolled back because it never started');
    });

    it('does not allow queries immediately after rollback call', async function () {
      await expect(
        this.sequelize.startUnmanagedTransaction().then(async t => {
          await Promise.all([
            expect(t.rollback()).to.eventually.be.fulfilled,
            expect(this.sequelize.query('SELECT 1+1', { transaction: t, raw: true })).to.be.eventually.rejectedWith(
              Error,
              /rollback has been called on this transaction\([^)]+\), you can no longer use it\. \(The rejected query is attached as the 'sql' property of this error\)/,
            ).and.have.deep.property('sql').that.equal('SELECT 1+1'),
          ]);
        }),
      ).to.eventually.be.fulfilled;
    });

    it('does not allow commits after commit', async function () {
      await expect(
        (async () => {
          const t = await this.sequelize.startUnmanagedTransaction();
          await t.commit();

          return await t.commit();
        })(),
      ).to.be.rejectedWith('Transaction cannot be committed because it has been finished with state: commit');
    });

    it('should run hooks if a non-auto callback transaction is committed', async function () {
      const hook = sinon.spy();
      let transaction;

      await expect(
        (async () => {
          try {
            const t = await this.sequelize.startUnmanagedTransaction();
            transaction = t;
            transaction.afterCommit(hook);
            await t.commit();
            expect(hook).to.have.been.calledOnce;
            expect(hook).to.have.been.calledWith(t);
          } catch (error) {
            // Cleanup this transaction so other tests don't
            // fail due to an open transaction
            if (!transaction.finished) {
              await transaction.rollback();
              throw error;
            }

            throw error;
          }
        })(),
      ).to.eventually.be.fulfilled;
    });

    it('should not run hooks if a non-auto callback transaction is rolled back', async function () {
      const hook = sinon.spy();

      await expect(
        (async () => {
          const t = await this.sequelize.startUnmanagedTransaction();
          t.afterCommit(hook);
          await t.rollback();
          expect(hook).to.not.have.been.called;
        })(),
      ).to.eventually.be.fulfilled;
    });

    it('should not run hooks if a non-auto callback transaction is rolled back in database', async function () {
      const hook = sinon.spy();

      this.sinon.stub(this.sequelize.queryInterface, 'commitTransaction').rejects(new Error('Oh no, an error!'));

      await expect(
        (async function () {
          const t = await this.sequelize.startUnmanagedTransaction();
          t.afterCommit(hook);
          await t.commit();
        }()),
      ).to.eventually.be.rejected;

      expect(hook).to.not.have.been.called;
    });

    it('should throw an error if null is passed to afterCommit', async function () {
      const hook = null;
      let transaction;

      await expect(
        (async () => {
          try {
            const t = await this.sequelize.startUnmanagedTransaction();
            transaction = t;
            transaction.afterCommit(hook);

            return await t.commit();
          } catch (error) {
            // Cleanup this transaction so other tests don't
            // fail due to an open transaction
            if (!transaction.finished) {
              await transaction.rollback();
              throw error;
            }

            throw error;
          }
        })(),
      ).to.eventually.be.rejectedWith('"fn" must be a function');
    });

    it('should throw an error if undefined is passed to afterCommit', async function () {
      const hook = undefined;
      let transaction;

      await expect(
        (async () => {
          try {
            const t = await this.sequelize.startUnmanagedTransaction();
            transaction = t;
            transaction.afterCommit(hook);

            return await t.commit();
          } catch (error) {
            // Cleanup this transaction so other tests don't
            // fail due to an open transaction
            if (!transaction.finished) {
              await transaction.rollback();
              throw error;
            }

            throw error;
          }
        })(),
      ).to.eventually.be.rejectedWith('"fn" must be a function');
    });

    it('should throw an error if an object is passed to afterCommit', async function () {
      const hook = {};
      let transaction;

      await expect(
        (async () => {
          try {
            const t = await this.sequelize.startUnmanagedTransaction();
            transaction = t;
            transaction.afterCommit(hook);

            return await t.commit();
          } catch (error) {
            // Cleanup this transaction so other tests don't
            // fail due to an open transaction
            if (!transaction.finished) {
              await transaction.rollback();
              throw error;
            }

            throw error;
          }
        })(),
      ).to.eventually.be.rejectedWith('"fn" must be a function');
    });

    it('does not allow commits after rollback', async function () {
      await expect((async () => {
        const t = await this.sequelize.startUnmanagedTransaction();
        await t.rollback();

        return await t.commit();
      })()).to.be.rejectedWith('Transaction cannot be committed because it has been finished with state: rollback');
    });

    it('does not allow rollbacks after commit', async function () {
      await expect((async () => {
        const t = await this.sequelize.startUnmanagedTransaction();
        await t.commit();

        return await t.rollback();
      })()).to.be.rejectedWith('Transaction cannot be rolled back because it has been finished with state: commit');
    });

    it('does not allow rollbacks after rollback', async function () {
      await expect((async () => {
        const t = await this.sequelize.startUnmanagedTransaction();
        await t.rollback();

        return await t.rollback();
      })()).to.be.rejectedWith('Transaction cannot be rolled back because it has been finished with state: rollback');
    });

    it('works even if a transaction: null option is passed', async function () {
      this.sinon.spy(this.sequelize, 'queryRaw');

      const t = await this.sequelize.startUnmanagedTransaction({
        transaction: null,
      });

      await t.commit();
      expect(this.sequelize.queryRaw.callCount).to.be.greaterThan(0);

      for (let i = 0; i < this.sequelize.queryRaw.callCount; i++) {
        expect(this.sequelize.queryRaw.getCall(i).args[1].transaction).to.equal(t);
      }
    });

    it('works even if a transaction: undefined option is passed', async function () {
      this.sinon.spy(this.sequelize, 'queryRaw');

      const t = await this.sequelize.startUnmanagedTransaction({
        transaction: undefined,
      });

      await t.commit();
      expect(this.sequelize.queryRaw.callCount).to.be.greaterThan(0);

      for (let i = 0; i < this.sequelize.queryRaw.callCount; i++) {
        expect(this.sequelize.queryRaw.getCall(i).args[1].transaction).to.equal(t);
      }
    });

    if (['mysql', 'mariadb'].includes(dialect)) {
      describe('deadlock handling', () => {
        // Create the `Task` table and ensure it's initialized with 2 rows
        const getAndInitializeTaskModel = async sequelize => {
          const Task = sequelize.define('task', {
            id: {
              type: DataTypes.INTEGER,
              primaryKey: true,
            },
          });

          await sequelize.sync({ force: true });
          await Task.create({ id: 0 });
          await Task.create({ id: 1 });

          return Task;
        };

        // Lock the row with id of `from`, and then try to update the row
        // with id of `to`
        const update = async (sequelize, Task, from, to) => {
          await sequelize
            .transaction(async transaction => {
              try {
                try {
                  await Task.findAll({
                    where: { id: { [Sequelize.Op.eq]: from } },
                    lock: transaction.LOCK.UPDATE,
                    transaction,
                  });

                  await delay(10);

                  await Task.update(
                    { id: to },
                    {
                      where: { id: { [Sequelize.Op.ne]: to } },
                      lock: transaction.LOCK.UPDATE,
                      transaction,
                    },
                  );
                } catch (error) {
                  console.log(error.message);
                }

                await Task.create({ id: 2 }, { transaction });
              } catch (error) {
                console.log(error.message);
              }

              throw new Error('Rollback!');
            })
            .catch(() => {});
        };

        it('should treat deadlocked transaction as rollback', async function () {
          const Task = await getAndInitializeTaskModel(this.sequelize);

          // This gets called twice simultaneously, and we expect at least one of the calls to encounter a
          // deadlock (which effectively rolls back the active transaction).
          // We only expect createTask() to insert rows if a transaction is active.  If deadlocks are handled
          // properly, it should only execute a query if we're actually inside a real transaction.  If it does
          // execute a query, we expect the newly-created rows to be destroyed when we forcibly rollback by
          // throwing an error.
          // tl;dr; This test is designed to ensure that this function never inserts and commits a new row.
          await Promise.all([update(this.sequelize, Task, 1, 0), update(this.sequelize, Task, 0, 1)]);

          const count = await Task.count();
          // If we were actually inside a transaction when we called `Task.create({ id: 2 })`, no new rows should be added.
          expect(count).to.equal(2, 'transactions were fully rolled-back, and no new rows were added');
        });

        it('should release the connection for a deadlocked transaction (1/2)', async function () {
          const Task = await getAndInitializeTaskModel(this.sequelize);

          // 1 of 2 queries should deadlock and be rolled back by InnoDB
          this.sinon.spy(this.sequelize.connectionManager, 'releaseConnection');
          await Promise.all([update(this.sequelize, Task, 1, 0), update(this.sequelize, Task, 0, 1)]);

          // Verify that both of the connections were released
          expect(this.sequelize.connectionManager.releaseConnection.callCount).to.equal(2);

          // Verify that a follow-up READ_COMMITTED works as expected.
          // For unknown reasons, we need to explicitly rollback on MariaDB,
          // even though the transaction should've automatically been rolled
          // back.
          // Otherwise, this READ_COMMITTED doesn't work as expected.
          const User = this.sequelize.define('user', {
            username: DataTypes.STRING,
          });
          await this.sequelize.sync({ force: true });
          await this.sequelize.transaction(
            { isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED },
            async transaction => {
              const users0 = await User.findAll({ transaction });
              expect(users0).to.have.lengthOf(0);
              await User.create({ username: 'jan' }); // Create a User outside of the transaction
              const users = await User.findAll({ transaction });
              expect(users).to.have.lengthOf(1); // We SHOULD see the created user inside the transaction
            },
          );
        });

        // The following code is supposed to cause a deadlock in MariaDB & MySQL
        // but starting with MariaDB 10.5.15, this does not happen anymore.
        // See https://github.com/sequelize/sequelize/issues/14174
        it.skip('should release the connection for a deadlocked transaction (2/2)', async function () {
          const verifyDeadlock = async () => {
            const User = this.sequelize.define('user', {
              username: DataTypes.STRING,
              awesome: DataTypes.BOOLEAN,
            }, { timestamps: false });

            await this.sequelize.sync({ force: true });
            const { id } = await User.create({ username: 'jan' });

            // First, we start a transaction T1 and perform a SELECT with it using the `LOCK.SHARE` mode (setting a shared mode lock on the row).
            // This will cause other sessions to be able to read the row but not modify it.
            // So, if another transaction tries to update those same rows, it will wait until T1 commits (or rolls back).
            // https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html
            const t1 = await this.sequelize.startUnmanagedTransaction();
            const t1Jan = await User.findByPk(id, { lock: t1.LOCK.SHARE, transaction: t1 });

            // Then we start another transaction T2 and see that it can indeed read the same row.
            const t2 = await this.sequelize.startUnmanagedTransaction({ isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED });
            const t2Jan = await User.findByPk(id, { transaction: t2 });

            // Then, we want to see that an attempt to update that row from T2 will be queued until T1 commits.
            // However, before commiting T1 we will also perform an update via T1 on the same rows.
            // This should cause T2 to notice that it can't function anymore, so it detects a deadlock and automatically rolls itself back (and throws an error).
            // Meanwhile, T1 should still be ok.
            const executionOrder = [];
            const [t2AttemptData, t1AttemptData] = await pSettle([
              (async () => {
                try {
                  executionOrder.push('Begin attempt to update via T2');
                  await t2Jan.update({ awesome: false }, { transaction: t2 });
                  executionOrder.push('Done updating via T2'); // Shouldn't happen
                } catch (error) {
                  executionOrder.push('Failed to update via T2');
                  throw error;
                }

                await delay(30);

                try {
                  // We shouldn't reach this point, but if we do, let's at least commit the transaction
                  // to avoid forever occupying one connection of the pool with a pending transaction.
                  executionOrder.push('Attempting to commit T2');
                  await t2.commit();
                  executionOrder.push('Done committing T2');
                } catch {
                  executionOrder.push('Failed to commit T2');
                }
              })(),
              (async () => {
                await delay(100);

                try {
                  executionOrder.push('Begin attempt to update via T1');
                  await t1Jan.update({ awesome: true }, { transaction: t1 });
                  executionOrder.push('Done updating via T1');
                } catch (error) {
                  executionOrder.push('Failed to update via T1'); // Shouldn't happen
                  throw error;
                }

                await delay(150);

                try {
                  executionOrder.push('Attempting to commit T1');
                  await t1.commit();
                  executionOrder.push('Done committing T1');
                } catch {
                  executionOrder.push('Failed to commit T1'); // Shouldn't happen
                }
              })(),
            ]);

            expect(t1AttemptData.isFulfilled).to.eq(true, 'T1 is not fullfilled, but should have been');
            expect(t2AttemptData.isRejected).to.eq(true, 'T2 is not rejected, but should have been');
            expect(t2AttemptData.reason.message).to.include('Deadlock found when trying to get lock; try restarting transaction');
            expect(t1.finished).to.equal('commit');
            expect(t2.finished).to.equal('rollback');

            const expectedExecutionOrder = [
              'Begin attempt to update via T2',
              'Begin attempt to update via T1', // 100ms after
              'Done updating via T1', // right after
              'Failed to update via T2', // right after
              'Attempting to commit T1', // 150ms after
              'Done committing T1', // right after
            ];

            // The order things happen in the database must be the one shown above. However, sometimes it can happen that
            // the calls in the JavaScript event loop that are communicating with the database do not match exactly this order.
            // In particular, it is possible that the JS event loop logs `'Failed to update via T2'` before logging `'Done updating via T1'`,
            // even though the database updated T1 first (and then rushed to declare a deadlock for T2).

            const anotherAcceptableExecutionOrderFromJSPerspective = [
              'Begin attempt to update via T2',
              'Begin attempt to update via T1', // 100ms after
              'Failed to update via T2', // right after
              'Done updating via T1', // right after
              'Attempting to commit T1', // 150ms after
              'Done committing T1', // right after
            ];

            const executionOrderOk = Support.isDeepEqualToOneOf(
              executionOrder,
              [
                expectedExecutionOrder,
                anotherAcceptableExecutionOrderFromJSPerspective,
              ],
            );

            if (!executionOrderOk) {
              throw new Error(`Unexpected execution order: ${executionOrder.join(' > ')}`);
            }
          };

          for (let i = 0; i < 3 * Support.getPoolMax(); i++) {
            await verifyDeadlock();
            await delay(10);
          }
        });
      });
    }

    if (dialect === 'sqlite') {
      it('provides persistent transactions', async () => {
        const sequelize = new Sequelize('database', 'username', 'password', { dialect: 'sqlite' });
        const User = sequelize.define('user', {
          username: DataTypes.STRING,
          awesome: DataTypes.BOOLEAN,
        });

        const t1 = await sequelize.startUnmanagedTransaction();
        await sequelize.sync({ transaction: t1 });
        const t0 = t1;
        await User.create({}, { transaction: t0 });
        await t0.commit();
        const persistentTransaction = await sequelize.startUnmanagedTransaction();
        const users = await User.findAll({ transaction: persistentTransaction });
        expect(users.length).to.equal(1);

        await persistentTransaction.commit();
      });
    }

    if (current.dialect.supports.transactionOptions.type) {
      describe('transaction types', () => {
        it('should support default transaction type DEFERRED', async function () {
          const t = await this.sequelize.startUnmanagedTransaction({});

          await t.rollback();
          expect(t.options.type).to.equal('DEFERRED');
        });

        for (const key of Object.keys(Transaction.TYPES)) {
          it(`should allow specification of ${key} type`, async function () {
            const t = await this.sequelize.startUnmanagedTransaction({
              type: key,
            });

            await t.rollback();
            expect(t.options.type).to.equal(Transaction.TYPES[key]);
          });
        }

      });

    }

    if (dialect === 'sqlite') {
      it('automatically retries on SQLITE_BUSY failure', async function () {
        const sequelize = await Support.prepareTransactionTest(this.sequelize);
        const User = sequelize.define('User', { username: DataTypes.STRING });
        await User.sync({ force: true });
        const newTransactionFunc = async function () {
          const t = await sequelize.startUnmanagedTransaction({ type: Transaction.TYPES.EXCLUSIVE });
          await User.create({}, { transaction: t });

          return t.commit();
        };

        await Promise.all([newTransactionFunc(), newTransactionFunc()]);
        const users = await User.findAll();
        expect(users.length).to.equal(2);
      });

      it('fails with SQLITE_BUSY when retry.match is changed', async function () {
        const sequelize = await Support.prepareTransactionTest(this.sequelize);
        const User = sequelize.define('User', { id: { type: DataTypes.INTEGER, primaryKey: true }, username: DataTypes.STRING });
        await User.sync({ force: true });
        const newTransactionFunc = async function () {
          const t = await sequelize.startUnmanagedTransaction({ type: Transaction.TYPES.EXCLUSIVE, retry: { match: ['NO_MATCH'] } });
          // introduce delay to force the busy state race condition to fail
          await delay(1000);
          await User.create({ id: null, username: `test ${t.id}` }, { transaction: t });

          return t.commit();
        };

        await expect(Promise.all([newTransactionFunc(), newTransactionFunc()])).to.be.rejectedWith('SQLITE_BUSY: database is locked');
      });

    }

    describe('isolation levels', () => {
      it('should read the most recent committed rows when using the READ COMMITTED isolation level', async function () {
        const User = this.sequelize.define('user', {
          username: DataTypes.STRING,
        });

        await expect(
          this.sequelize.sync({ force: true }).then(() => {
            return this.sequelize.transaction(
              { isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED },
              async transaction => {
                const users0 = await User.findAll({ transaction });
                expect(users0).to.have.lengthOf(0);
                await User.create({ username: 'jan' }); // Create a User outside of the transaction
                const users = await User.findAll({ transaction });
                expect(users).to.have.lengthOf(1); // We SHOULD see the created user inside the transaction
              },
            );
          }),
        ).to.eventually.be.fulfilled;
      });

      // mssql is excluded because it implements REPREATABLE READ using locks rather than a snapshot, and will see the new row
      if (!['sqlite', 'mssql', 'db2'].includes(dialect)) {
        it('should not read newly committed rows when using the REPEATABLE READ isolation level', async function () {
          const User = this.sequelize.define('user', {
            username: DataTypes.STRING,
          });

          await expect(
            this.sequelize.sync({ force: true }).then(() => {
              return this.sequelize.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.REPEATABLE_READ }, async transaction => {
                const users0 = await User.findAll({ transaction });
                await expect(users0).to.have.lengthOf(0);
                await User.create({ username: 'jan' }); // Create a User outside of the transaction
                const users = await User.findAll({ transaction });

                return expect(users).to.have.lengthOf(0); // We SHOULD NOT see the created user inside the transaction
              });
            }),
          ).to.eventually.be.fulfilled;
        });
      }

      // PostgreSQL is excluded because it detects Serialization Failure on commit instead of acquiring locks on the read rows
      if (!['sqlite', 'postgres', 'postgres-native', 'db2'].includes(dialect)) {
        it('should block updates after reading a row using SERIALIZABLE', async function () {
          const User = this.sequelize.define('user', {
            username: DataTypes.STRING,
          });
          const transactionSpy = sinon.spy();

          await this.sequelize.sync({ force: true });
          await User.create({ username: 'jan' });
          const transaction = await this.sequelize.startUnmanagedTransaction({ isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE });
          await User.findAll({ transaction });

          await Promise.all([
            // Update should not succeed before transaction has committed
            User.update({ username: 'joe' }, {
              where: {
                username: 'jan',
              },
            }).then(() => {
              expect(transactionSpy).to.have.been.called;
              expect(transaction.finished).to.equal('commit');
            }),

            delay(4000)
              .then(transactionSpy)
              .then(() => transaction.commit()),
          ]);
        });
      }

    });

    if (current.dialect.supports.lock) {
      describe('row locking', () => {
        it('supports for update', async function () {
          const User = this.sequelize.define('user', {
            username: DataTypes.STRING,
            awesome: DataTypes.BOOLEAN,
          });
          const t1Spy = sinon.spy();
          const t2Spy = sinon.spy();

          await this.sequelize.sync({ force: true });
          await User.create({ username: 'jan' });
          const t1 = await this.sequelize.startUnmanagedTransaction();

          const t1Jan = await User.findOne({
            where: {
              username: 'jan',
            },
            lock: t1.LOCK.UPDATE,
            transaction: t1,
          });

          const t2 = await this.sequelize.startUnmanagedTransaction({
            isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED,
          });

          await Promise.all([(async () => {
            await User.findOne({
              where: {
                username: 'jan',
              },
              lock: t2.LOCK.UPDATE,
              transaction: t2,
            });

            t2Spy();
            await t2.commit();
            expect(t2Spy).to.have.been.calledAfter(t1Spy); // Find should not succeed before t1 has committed
          })(), (async () => {
            await t1Jan.update({
              awesome: true,
            }, {
              transaction: t1,
            });

            t1Spy();
            await delay(2000);

            return await t1.commit();
          })()]);
        });

        if (current.dialect.supports.skipLocked) {
          it('supports for update with skip locked', async function () {
            const User = this.sequelize.define('user', {
              username: DataTypes.STRING,
              awesome: DataTypes.BOOLEAN,
            });

            await this.sequelize.sync({ force: true });

            await Promise.all([
              User.create(
                { username: 'jan' },
              ),
              User.create(
                { username: 'joe' },
              ),
            ]);

            const t1 = await this.sequelize.startUnmanagedTransaction();

            const results = await User.findAll({
              limit: 1,
              lock: true,
              transaction: t1,
            });

            const firstUserId = results[0].id;
            const t2 = await this.sequelize.startUnmanagedTransaction();

            const secondResults = await User.findAll({
              limit: 1,
              lock: true,
              skipLocked: true,
              transaction: t2,
            });

            expect(secondResults[0].id).to.not.equal(firstUserId);

            await Promise.all([
              t1.commit(),
              t2.commit(),
            ]);
          });
        }

        it('fail locking with outer joins', async function () {
          const User = this.sequelize.define('User', { username: DataTypes.STRING });
          const Task = this.sequelize.define('Task', { title: DataTypes.STRING, active: DataTypes.BOOLEAN });

          User.belongsToMany(Task, { through: 'UserTasks' });
          Task.belongsToMany(User, { through: 'UserTasks' });

          await this.sequelize.sync({ force: true });

          const [john, task1] = await Promise.all([
            User.create({ username: 'John' }),
            Task.create({ title: 'Get rich', active: false }),
          ]);

          await john.setTasks([task1]);

          await this.sequelize.transaction(t1 => {

            if (current.dialect.supports.lockOuterJoinFailure) {

              return expect(User.findOne({
                where: {
                  username: 'John',
                },
                include: [Task],
                lock: t1.LOCK.UPDATE,
                transaction: t1,
              })).to.be.rejectedWith('FOR UPDATE cannot be applied to the nullable side of an outer join');
            }

            return User.findOne({
              where: {
                username: 'John',
              },
              include: [Task],
              lock: t1.LOCK.UPDATE,
              transaction: t1,
            });
          });
        });

        if (current.dialect.supports.lockOf) {
          it('supports for update of table', async function () {
            const User = this.sequelize.define('User', { username: DataTypes.STRING }, { tableName: 'Person' });
            const Task = this.sequelize.define('Task', { title: DataTypes.STRING, active: DataTypes.BOOLEAN });

            User.belongsToMany(Task, { through: 'UserTasks' });
            Task.belongsToMany(User, { through: 'UserTasks' });

            await this.sequelize.sync({ force: true });

            const [john, task1] = await Promise.all([
              User.create({ username: 'John' }),
              Task.create({ title: 'Get rich', active: false }),
              Task.create({ title: 'Die trying', active: false }),
            ]);

            await john.setTasks([task1]);

            await this.sequelize.transaction(async t1 => {
              const t1John = await User.findOne({
                where: {
                  username: 'John',
                },
                include: [Task],
                lock: {
                  level: t1.LOCK.UPDATE,
                  of: User,
                },
                transaction: t1,
              });

              // should not be blocked by the lock of the other transaction
              await this.sequelize.transaction(t2 => {
                return Task.update({
                  active: true,
                }, {
                  where: {
                    active: false,
                  },
                  transaction: t2,
                });
              });

              return t1John.save({
                transaction: t1,
              });
            });
          });
        }

        if (current.dialect.supports.lockKey) {
          it('supports for key share', async function () {
            const User = this.sequelize.define('user', {
              username: DataTypes.STRING,
              awesome: DataTypes.BOOLEAN,
            });
            const t1Spy = sinon.spy();
            const t2Spy = sinon.spy();

            await this.sequelize.sync({ force: true });
            await User.create({ username: 'jan' });
            const t1 = await this.sequelize.startUnmanagedTransaction();

            const t1Jan = await User.findOne({
              where: {
                username: 'jan',
              },
              lock: t1.LOCK.NO_KEY_UPDATE,
              transaction: t1,
            });

            const t2 = await this.sequelize.startUnmanagedTransaction();

            await Promise.all([(async () => {
              await User.findOne({
                where: {
                  username: 'jan',
                },
                lock: t2.LOCK.KEY_SHARE,
                transaction: t2,
              });

              t2Spy();

              return await t2.commit();
            })(), (async () => {
              await t1Jan.update({
                awesome: true,
              }, {
                transaction: t1,
              });

              await delay(2000);
              t1Spy();
              expect(t1Spy).to.have.been.calledAfter(t2Spy);

              return await t1.commit();
            })()]);
          });
        }

        it('supports for share (i.e. `SELECT ... LOCK IN SHARE MODE`)', async function () {
          const verifySelectLockInShareMode = async () => {
            const User = this.sequelize.define('user', {
              username: DataTypes.STRING,
              awesome: DataTypes.BOOLEAN,
            }, { timestamps: false });

            await this.sequelize.sync({ force: true });
            const { id } = await User.create({ username: 'jan' });

            // First, we start a transaction T1 and perform a SELECT with it using the `LOCK.SHARE` mode (setting a shared mode lock on the row).
            // This will cause other sessions to be able to read the row but not modify it.
            // So, if another transaction tries to update those same rows, it will wait until T1 commits (or rolls back).
            // https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html
            const t1 = await this.sequelize.startUnmanagedTransaction();
            await User.findByPk(id, { lock: t1.LOCK.SHARE, transaction: t1 });

            // Then we start another transaction T2 and see that it can indeed read the same row.
            const t2 = await this.sequelize.startUnmanagedTransaction({ isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED });
            const t2Jan = await User.findByPk(id, { transaction: t2 });

            // Then, we want to see that an attempt to update that row from T2 will be queued until T1 commits.
            const executionOrder = [];
            const [t2AttemptData, t1AttemptData] = await pSettle([
              (async () => {
                try {
                  executionOrder.push('Begin attempt to update via T2');
                  await t2Jan.update({ awesome: false }, { transaction: t2 });
                  executionOrder.push('Done updating via T2');
                } catch (error) {
                  executionOrder.push('Failed to update via T2'); // Shouldn't happen
                  throw error;
                }

                await delay(30);

                try {
                  executionOrder.push('Attempting to commit T2');
                  await t2.commit();
                  executionOrder.push('Done committing T2');
                } catch {
                  executionOrder.push('Failed to commit T2'); // Shouldn't happen
                }
              })(),
              (async () => {
                await delay(100);

                try {
                  executionOrder.push('Begin attempt to read via T1');
                  await User.findAll({ transaction: t1 });
                  executionOrder.push('Done reading via T1');
                } catch (error) {
                  executionOrder.push('Failed to read via T1'); // Shouldn't happen
                  throw error;
                }

                await delay(150);

                try {
                  executionOrder.push('Attempting to commit T1');
                  await t1.commit();
                  executionOrder.push('Done committing T1');
                } catch {
                  executionOrder.push('Failed to commit T1'); // Shouldn't happen
                }
              })(),
            ]);

            expect(t1AttemptData.isFulfilled).to.be.true;
            expect(t2AttemptData.isFulfilled).to.be.true;
            expect(t1.finished).to.equal('commit');
            expect(t2.finished).to.equal('commit');

            const expectedExecutionOrder = [
              'Begin attempt to update via T2',
              'Begin attempt to read via T1', // 100ms after
              'Done reading via T1', // right after
              'Attempting to commit T1', // 150ms after
              'Done committing T1', // right after
              'Done updating via T2', // right after
              'Attempting to commit T2', // 30ms after
              'Done committing T2', // right after
            ];

            // The order things happen in the database must be the one shown above. However, sometimes it can happen that
            // the calls in the JavaScript event loop that are communicating with the database do not match exactly this order.
            // In particular, it is possible that the JS event loop logs `'Done updating via T2'` before logging `'Done committing T1'`,
            // even though the database committed T1 first (and then rushed to complete the pending update query from T2).

            const anotherAcceptableExecutionOrderFromJSPerspective = [
              'Begin attempt to update via T2',
              'Begin attempt to read via T1', // 100ms after
              'Done reading via T1', // right after
              'Attempting to commit T1', // 150ms after
              'Done updating via T2', // right after
              'Done committing T1', // right after
              'Attempting to commit T2', // 30ms after
              'Done committing T2', // right after
            ];

            const executionOrderOk = Support.isDeepEqualToOneOf(
              executionOrder,
              [
                expectedExecutionOrder,
                anotherAcceptableExecutionOrderFromJSPerspective,
              ],
            );

            if (!executionOrderOk) {
              throw new Error(`Unexpected execution order: ${executionOrder.join(' > ')}`);
            }
          };

          for (let i = 0; i < 3 * Support.getPoolMax(); i++) {
            await verifySelectLockInShareMode();
            await delay(10);
          }
        });
      });
    }
  });
}


const remainingString = sqlString.slice(i, sqlString.length);

const dollarStringStartMatch = remainingString.match(/^\$(?<name>[a-z_][0-9a-z_])?(\$)/i);

const tagName = dollarStringStartMatch?.groups?.name ?? '';

if (currentDollarStringTagName === tagName) {

keep keep keep keep keep replace keep keep keep keep

const dollarStringStartMatch = remainingString.match(/^\$(?<name>[a-z_][0-9a-z_]*)?(\$)/i);

#!/usr/bin/env bash
set -Eeuxo pipefail # https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/
cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" # https://stackoverflow.com/a/17744637

docker compose -p sequelize-mysql-oldest down --remove-orphans

echo "Local oldest supported MySQL instance stopped (if it was running)."


keep keep keep keep add keep keep keep keep keep

i += dollarStringStartMatch[0].length - 1;

import isPlainObject from 'lodash/isPlainObject';
import type { AbstractDialect, BindCollector } from '../dialects/abstract/index.js';
import type { BindOrReplacements } from '../sequelize.js';
import { escape as escapeSqlValue } from '../sql-string';

type OnBind = (oldName: string) => string;

type MapSqlOptions = {
  onPositionalReplacement?(): void,
};

/**
 * Internal function used by {@link mapBindParameters} and {@link injectReplacements}.
 * Parse bind parameters & replacements in places where they would be valid SQL values.
 *
 * @param sqlString The SQL that contains the bind parameters & replacements
 * @param dialect The dialect of the SQL
 * @param replacements if provided, this method will replace ':named' replacements & positional replacements (?)
 * @param onBind if provided, sequelize will call this method each time a $bind parameter is found, and replace it with its output.
 * @param options Options
 *
 * @returns The SQL with bind parameters & replacements rewritten in their dialect-specific syntax.
 */
function mapBindParametersAndReplacements(
  sqlString: string,
  dialect: AbstractDialect,
  replacements?: BindOrReplacements,
  onBind?: OnBind,
  options?: MapSqlOptions,
): string {
  const isNamedReplacements = isPlainObject(replacements);
  const isPositionalReplacements = Array.isArray(replacements);
  let lastConsumedPositionalReplacementIndex = -1;

  let output: string = '';

  let currentDollarStringTagName = null;
  let isString = false;
  let isColumn = false;
  let previousSliceEnd = 0;
  let isSingleLineComment = false;
  let isCommentBlock = false;
  let stringIsBackslashEscapable = false;

  for (let i = 0; i < sqlString.length; i++) {
    const char = sqlString[i];

    if (isColumn) {
      if (char === dialect.TICK_CHAR_RIGHT) {
        isColumn = false;
      }

      continue;
    }

    if (isString) {
      if (char === `'` && (!stringIsBackslashEscapable || !isBackslashEscaped(sqlString, i - 1))) {
        isString = false;
        stringIsBackslashEscapable = false;
      }

      continue;
    }

    if (currentDollarStringTagName !== null) {
      if (char !== '$') {
        continue;
      }

      const remainingString = sqlString.slice(i, sqlString.length);

      const dollarStringStartMatch = remainingString.match(/^\$(?<name>[a-z_][0-9a-z_])?(\$)/i);
      const tagName = dollarStringStartMatch?.groups?.name ?? '';

      if (currentDollarStringTagName === tagName) {
        currentDollarStringTagName = null;
      }

      continue;
    }

    if (isSingleLineComment) {
      if (char === '\n') {
        isSingleLineComment = false;
      }

      continue;
    }

    if (isCommentBlock) {
      if (char === '*' && sqlString[i + 1] === '/') {
        isCommentBlock = false;
      }

      continue;
    }

    if (char === dialect.TICK_CHAR_LEFT) {
      isColumn = true;
      continue;
    }

    if (char === `'`) {
      isString = true;

      // The following query is supported in almost all dialects,
      //  SELECT E'test';
      // but postgres interprets it as an E-prefixed string, while other dialects interpret it as
      //  SELECT E 'test';
      // which selects the type E and aliases it to 'test'.

      stringIsBackslashEscapable
        // all ''-style strings in this dialect can be backslash escaped
        = dialect.canBackslashEscape()
        // checking if this is a postgres-style E-prefixed string, which also supports backslash escaping
        || (
          dialect.supports.escapeStringConstants
          // is this a E-prefixed string, such as `E'abc'`, `e'abc'` ?
          && (sqlString[i - 1] === 'E' || sqlString[i - 1] === 'e')
          // reject things such as `AE'abc'` (the prefix must be exactly E)
          && canPrecedeNewToken(sqlString[i - 2])
        );

      continue;
    }

    if (char === '-' && sqlString.slice(i, i + 3) === '-- ') {
      isSingleLineComment = true;
      continue;
    }

    if (char === '/' && sqlString.slice(i, i + 2) === '/*') {
      isCommentBlock = true;
      continue;
    }

    // either the start of a $bind parameter, or the start of a $tag$string$tag$
    if (char === '$') {
      const previousChar = sqlString[i - 1];

      // we are part of an identifier
      if (/[0-9a-z_]/i.test(previousChar)) {
        continue;
      }

      const remainingString = sqlString.slice(i, sqlString.length);

      const dollarStringStartMatch = remainingString.match(/^\$(?<name>[a-z_][0-9a-z_]*)?\$/i);
      if (dollarStringStartMatch) {
        currentDollarStringTagName = dollarStringStartMatch.groups?.name ?? '';

        continue;
      }

      if (onBind) {
        // we want to be conservative with what we consider to be a bind parameter to avoid risk of conflict with potential operators
        // users need to add a space before the bind parameter (except after '(', ',', and '=')
        if (!canPrecedeNewToken(previousChar)) {
          continue;
        }

        // detect the bind param if it's a valid identifier and it's followed either by '::' (=cast), ')', whitespace of it's the end of the query.
        const match = remainingString.match(/^\$(?<name>([a-z_][0-9a-z_]*|[1-9][0-9]*))(?:\)|,|$|\s|::|;)/i);
        const bindParamName = match?.groups?.name;
        if (!bindParamName) {
          continue;
        }

        // we found a bind parameter
        const newName: string = onBind(bindParamName);

        // add everything before the bind parameter name
        output += sqlString.slice(previousSliceEnd, i);
        // continue after the bind parameter name
        previousSliceEnd = i + bindParamName.length + 1;

        output += newName;
      }

      continue;
    }

    if (isNamedReplacements && char === ':') {
      const previousChar = sqlString[i - 1];
      // we want to be conservative with what we consider to be a replacement to avoid risk of conflict with potential operators
      // users need to add a space before the bind parameter (except after '(', ',', '=', and '[' (for arrays))
      if (!canPrecedeNewToken(previousChar) && previousChar !== '[') {
        continue;
      }

      const remainingString = sqlString.slice(i, sqlString.length);

      const match = remainingString.match(/^:(?<name>[a-z_][0-9a-z_]*)(?:\)|,|$|\s|::|;|])/i);
      const replacementName = match?.groups?.name;
      if (!replacementName) {
        continue;
      }

      // @ts-expect-error -- isPlainObject does not tell typescript that replacements is a plain object, not an array
      const replacementValue = replacements[replacementName];
      if (!Object.prototype.hasOwnProperty.call(replacements, replacementName) || replacementValue === undefined) {
        throw new Error(`Named replacement ":${replacementName}" has no entry in the replacement map.`);
      }

      const escapedReplacement = escapeSqlValue(replacementValue, undefined, dialect, true);

      // add everything before the bind parameter name
      output += sqlString.slice(previousSliceEnd, i);
      // continue after the bind parameter name
      previousSliceEnd = i + replacementName.length + 1;

      output += escapedReplacement;

      continue;
    }

    if (isPositionalReplacements && char === '?') {
      const previousChar = sqlString[i - 1];

      // we want to be conservative with what we consider to be a replacement to avoid risk of conflict with potential operators
      // users need to add a space before the bind parameter (except after '(', ',', '=', and '[' (for arrays))
      // -> [ is temporarily added to allow 'ARRAY[:name]' to be replaced
      // https://github.com/sequelize/sequelize/issues/14410 will make this obsolete.
      if (!canPrecedeNewToken(previousChar) && previousChar !== '[') {
        continue;
      }

      // don't parse ?| and ?& operators as replacements
      const nextChar = sqlString[i + 1];
      if (nextChar === '|' || nextChar === '&') {
        continue;
      }

      // this is a positional replacement
      if (options?.onPositionalReplacement) {
        options.onPositionalReplacement();
      }

      const replacementIndex = ++lastConsumedPositionalReplacementIndex;
      const replacementValue = replacements[lastConsumedPositionalReplacementIndex];

      if (replacementValue === undefined) {
        throw new Error(`Positional replacement (?) ${replacementIndex} has no entry in the replacement map (replacements[${replacementIndex}] is undefined).`);
      }

      const escapedReplacement = escapeSqlValue(replacementValue, undefined, dialect, true);

      // add everything before the bind parameter name
      output += sqlString.slice(previousSliceEnd, i);
      // continue after the bind parameter name
      previousSliceEnd = i + 1;

      output += escapedReplacement;
    }
  }

  if (isString) {
    throw new Error(`The following SQL query includes an unterminated string literal:\n${sqlString}`);
  }

  output += sqlString.slice(previousSliceEnd, sqlString.length);

  return output;
}

function canPrecedeNewToken(char: string | undefined): boolean {
  return char === undefined || /[\s(>,=]/.test(char);
}

/**
 * Maps bind parameters from Sequelize's format ($1 or $name) to the dialect's format.
 *
 * @param sqlString
 * @param dialect
 */
export function mapBindParameters(sqlString: string, dialect: AbstractDialect): {
  sql: string,
  bindOrder: string[] | null,
  parameterSet: Set<string>,
} {
  const parameterCollector = dialect.createBindCollector();
  const parameterSet = new Set<string>();

  const newSql = mapBindParametersAndReplacements(sqlString, dialect, undefined, foundBindParamName => {
    parameterSet.add(foundBindParamName);

    return parameterCollector.collect(foundBindParamName);
  });

  return { sql: newSql, bindOrder: parameterCollector.getBindParameterOrder(), parameterSet };
}

export function injectReplacements(
  sqlString: string,
  dialect: AbstractDialect,
  replacements: BindOrReplacements,
  opts?: MapSqlOptions,
): string {
  if (replacements == null) {
    return sqlString;
  }

  if (!Array.isArray(replacements) && !isPlainObject(replacements)) {
    throw new TypeError(`"replacements" must be an array or a plain object, but received ${JSON.stringify(replacements)} instead.`);
  }

  return mapBindParametersAndReplacements(sqlString, dialect, replacements, undefined, opts);
}

function isBackslashEscaped(string: string, pos: number): boolean {
  let escaped = false;
  for (let i = pos; i >= 0; i--) {
    const char = string[i];
    if (char !== '\\') {
      break;
    }

    escaped = !escaped;
  }

  return escaped;
}

/**
 * Collector for dialects that only support ordered parameters, and whose order matter in the SQL query. (e.g. dialects that use the "?" token for parameters)
 *
 * @param token The token to use as the bind parameter (e.g. '?' in mysql).
 */
export function createUnspecifiedOrderedBindCollector(token = '?'): BindCollector {
  const parameterOrder: string[] = [];

  return {
    collect(bindParameterName) {
      parameterOrder.push(bindParameterName);

      return token;
    },
    getBindParameterOrder() {
      return parameterOrder;
    },
  };
}

/**
 * Collector for dialects that only support ordered parameters, but whose order does not matter in the SQL query (e.g. dialect that support parameters names like '$1')
 *
 * Parameter index starts at 1!
 *
 * @param prefix The prefix to place before the name of the bind parameter (e.g. @ for mssql, $ for sqlite/postgres)
 */
export function createSpecifiedOrderedBindCollector(prefix = '$'): BindCollector {
  const parameterOrder: string[] = [];

  return {
    collect(bindParameterName) {
      const cachedPosition = parameterOrder.indexOf(bindParameterName);
      if (cachedPosition === -1) {
        parameterOrder.push(bindParameterName);

        return `${prefix}${parameterOrder.length}`;
      }

      return `${prefix}${cachedPosition + 1}`;
    },
    getBindParameterOrder() {
      return parameterOrder;
    },
  };
}

/**
 * Collector for dialects that support named bind parameters (e.g. @name, $name, etc)
 *
 * @param parameterPrefix The prefix to place before the name of the bind parameter (e.g. @ for mssql, $ for sqlite/postgres)
 */
export function createNamedParamBindCollector(parameterPrefix: string): BindCollector {
  return {
    collect(bindParameterName: string) {
      return parameterPrefix + bindParameterName;
    },
    getBindParameterOrder() {
      return null;
    },
  };
}

export function assertNoReservedBind(bind: BindOrReplacements): void {
  if (Array.isArray(bind)) {
    return;
  }

  for (const key of Object.keys(bind)) {
    if (key.startsWith('sequelize_')) {
      throw new Error('Bind parameters cannot start with "sequelize_", these bind parameters are reserved by Sequelize.');
    }
  }
}

export function combineBinds(bindA: BindOrReplacements, bindB: { [key: string]: unknown }) {
  if (Array.isArray(bindA)) {
    bindA = arrayBindToNamedBind(bindA);
  }

  return {
    ...bindA,
    ...bindB,
  };
}

function arrayBindToNamedBind(bind: unknown[]): { [key: string]: unknown } {
  const out = Object.create(null);

  // eslint-disable-next-line unicorn/no-for-loop -- too slow.
  for (let i = 0; i < bind.length; i++) {
    out[i + 1] = bind[i];
  }

  return out;
}


import type { ReferentialAction } from '@sequelize/core';
import { DataTypes, Deferrable } from '@sequelize/core';
import { sequelize } from '../support';

const dialect = sequelize.getDialect();

describe('Sequelize#drop', () => {
  it('supports dropping cyclic associations', async () => {
    const A = sequelize.define('A', {
      BId: {
        type: DataTypes.INTEGER,
        references: {
          deferrable: Deferrable.INITIALLY_IMMEDIATE,
        },
      },
    });

    const B = sequelize.define('B', {
      AId: {
        type: DataTypes.INTEGER,
        references: {
          deferrable: Deferrable.INITIALLY_IMMEDIATE,
        },
      },
    });

    // mssql refuses cyclic references unless ON DELETE and ON UPDATE is set to NO ACTION
    const mssqlConstraints = dialect === 'mssql' ? { onDelete: 'NO ACTION' as ReferentialAction, onUpdate: 'NO ACTION' as ReferentialAction } : null;

    // These models both have a foreign key that references the other model.
    // Sequelize should be able to create them.
    A.belongsTo(B, { foreignKey: { allowNull: false, ...mssqlConstraints } });
    B.belongsTo(A, { foreignKey: { allowNull: false, ...mssqlConstraints } });

    await sequelize.sync();

    // drop both tables
    await sequelize.drop();
  });

  it('supports dropping cyclic associations with { cascade: true } in supported dialects', async () => {
    if (!sequelize.dialect.supports.dropTable.cascade) {
      return;
    }

    const A = sequelize.define('A', {
      BId: {
        type: DataTypes.INTEGER,
      },
    });

    const B = sequelize.define('B', {
      AId: {
        type: DataTypes.INTEGER,
      },
    });

    // These models both have a foreign key that references the other model.
    // Sequelize should be able to create them.
    A.belongsTo(B, { foreignKey: { allowNull: false } });
    B.belongsTo(A, { foreignKey: { allowNull: false } });

    await sequelize.sync();

    // drop both tables
    await sequelize.drop({ cascade: true });
  });
});


'use strict';

import NodeUtil from 'node:util';

const { AbstractQuery } = require('../abstract/query');
const sequelizeErrors = require('../../errors');
const _ = require('lodash');
const DataTypes = require('../../data-types');
const { logger } = require('../../utils/logger');

const ER_DUP_ENTRY = 1062;
const ER_DEADLOCK = 1213;
const ER_ROW_IS_REFERENCED = 1451;
const ER_NO_REFERENCED_ROW = 1452;

const debug = logger.debugContext('sql:mariadb');

export class MariaDbQuery extends AbstractQuery {
  constructor(connection, sequelize, options) {
    super(connection, sequelize, { showWarnings: false, ...options });
  }

  async run(sql, parameters) {
    this.sql = sql;
    const { connection, options } = this;

    const showWarnings = this.sequelize.options.showWarnings || options.showWarnings;

    const complete = this._logQuery(sql, debug, parameters);

    if (parameters) {
      debug('parameters(%j)', parameters);
    }

    let results;

    try {
      results = await connection.query(this.sql, parameters);
    } catch (error) {
      if (options.transaction && error.errno === ER_DEADLOCK) {
        // MariaDB automatically rolls-back transactions in the event of a deadlock.
        // However, we still initiate a manual rollback to ensure the connection gets released - see #13102.
        try {
          await options.transaction.rollback();
        } catch {
          // Ignore errors - since MariaDB automatically rolled back, we're
          // not that worried about this redundant rollback failing.
        }

        options.transaction.finished = 'rollback';
      }

      error.sql = sql;
      error.parameters = parameters;
      throw this.formatError(error);
    } finally {
      complete();
    }

    if (showWarnings && results && results.warningStatus > 0) {
      await this.logWarnings(results);
    }

    return this.formatResults(results);
  }

  /**
   * High level function that handles the results of a query execution.
   *
   *
   * Example:
   *  query.formatResults([
   *    {
   *      id: 1,              // this is from the main table
   *      attr2: 'snafu',     // this is from the main table
   *      Tasks.id: 1,        // this is from the associated table
   *      Tasks.title: 'task' // this is from the associated table
   *    }
   *  ])
   *
   * @param {Array} data - The result of the query execution.
   * @private
   */
  formatResults(data) {
    let result = this.instance;

    if (this.isBulkUpdateQuery() || this.isBulkDeleteQuery()) {
      return data.affectedRows;
    }

    if (this.isUpsertQuery()) {
      return [result, data.affectedRows === 1];
    }

    if (this.isInsertQuery(data)) {
      this.handleInsertQuery(data);

      if (!this.instance) {
        const modelDefinition = this.model?.modelDefinition;

        // handle bulkCreate AI primary key
        if (
          modelDefinition?.autoIncrementAttributeName
          && modelDefinition?.autoIncrementAttributeName === this.model.primaryKeyAttribute
        ) {
          // ONLY TRUE IF @auto_increment_increment is set to 1 !!
          // Doesn't work with GALERA => each node will reserve increment (x for first server, x+1 for next node...)
          const startId = data[this.getInsertIdField()];
          result = new Array(data.affectedRows);
          const pkColumnName = modelDefinition.attributes.get(this.model.primaryKeyAttribute).columnName;
          for (let i = 0; i < data.affectedRows; i++) {
            result[i] = { [pkColumnName]: startId + i };
          }

          return [result, data.affectedRows];
        }

        return [data[this.getInsertIdField()], data.affectedRows];
      }
    }

    if (this.isSelectQuery()) {
      this.handleJsonSelectQuery(data);

      return this.handleSelectQuery(data);
    }

    if (this.isInsertQuery() || this.isUpdateQuery()) {
      return [result, data.affectedRows];
    }

    if (this.isCallQuery()) {
      return data[0];
    }

    if (this.isRawQuery()) {
      const meta = data.meta;
      delete data.meta;

      return [data, meta];
    }

    if (this.isShowIndexesQuery()) {
      return this.handleShowIndexesQuery(data);
    }

    if (this.isForeignKeysQuery() || this.isShowConstraintsQuery()) {
      return data;
    }

    if (this.isShowTablesQuery()) {
      return this.handleShowTablesQuery(data);
    }

    if (this.isDescribeQuery()) {
      result = {};

      for (const _result of data) {
        result[_result.Field] = {
          type: _result.Type.toLowerCase().startsWith('enum') ? _result.Type.replace(/^enum/i,
            'ENUM') : _result.Type.toUpperCase(),
          allowNull: _result.Null === 'YES',
          defaultValue: _result.Default,
          primaryKey: _result.Key === 'PRI',
          autoIncrement: Object.prototype.hasOwnProperty.call(_result, 'Extra')
            && _result.Extra.toLowerCase() === 'auto_increment',
          comment: _result.Comment ? _result.Comment : null,
        };
      }

      return result;
    }

    if (this.isVersionQuery()) {
      return data[0].version;
    }

    return result;
  }

  handleJsonSelectQuery(rows) {
    if (!this.model || !this.model.fieldRawAttributesMap) {
      return;
    }

    for (const _field of Object.keys(this.model.fieldRawAttributesMap)) {
      const modelField = this.model.fieldRawAttributesMap[_field];
      if (modelField.type instanceof DataTypes.JSON) {
        // Value is returned as String, not JSON
        rows = rows.map(row => {
          // JSON fields for MariaDB server 10.5.2+ already results in JSON format, skip JSON.parse
          // this is due to this https://jira.mariadb.org/browse/MDEV-17832 and how mysql2 connector interacts with MariaDB and JSON fields
          if (row[modelField.fieldName] && typeof row[modelField.fieldName] === 'string' && !this.connection.info.hasMinVersion(10, 5, 2)) {
            row[modelField.fieldName] = JSON.parse(row[modelField.fieldName]);
          }

          if (DataTypes.JSON.parse) {
            return DataTypes.JSON.parse(modelField, this.sequelize.options,
              row[modelField.fieldName]);
          }

          return row;
        });
      }
    }
  }

  formatError(err) {
    switch (err.errno) {
      case ER_DUP_ENTRY: {
        const match = err.message.match(
          /Duplicate entry '([\S\s]*)' for key '?((.|\s)*?)'?\s.*$/,
        );

        let fields = {};
        let message = 'Validation error';
        const values = match ? match[1].split('-') : undefined;
        const fieldKey = match ? match[2] : undefined;
        const fieldVal = match ? match[1] : undefined;
        const uniqueKey = this.model && this.model.getIndexes().find(index => index.unique && index.name === fieldKey);

        if (uniqueKey) {
          if (uniqueKey.msg) {
            message = uniqueKey.msg;
          }

          fields = _.zipObject(uniqueKey.fields, values);
        } else {
          fields[fieldKey] = fieldVal;
        }

        const errors = [];
        _.forOwn(fields, (value, field) => {
          errors.push(new sequelizeErrors.ValidationErrorItem(
            this.getUniqueConstraintErrorMessage(field),
            'unique violation', // sequelizeErrors.ValidationErrorItem.Origins.DB,
            field,
            value,
            this.instance,
            'not_unique',
          ));
        });

        return new sequelizeErrors.UniqueConstraintError({ message, errors, cause: err, fields });
      }

      case ER_ROW_IS_REFERENCED:
      case ER_NO_REFERENCED_ROW: {
        // e.g. CONSTRAINT `example_constraint_name` FOREIGN KEY (`example_id`) REFERENCES `examples` (`id`)
        const match = err.message.match(
          /CONSTRAINT (["`])(.*)\1 FOREIGN KEY \(\1(.*)\1\) REFERENCES \1(.*)\1 \(\1(.*)\1\)/,
        );
        const quoteChar = match ? match[1] : '`';
        const fields = match ? match[3].split(new RegExp(`${quoteChar}, *${quoteChar}`)) : undefined;

        return new sequelizeErrors.ForeignKeyConstraintError({
          reltype: err.errno === ER_ROW_IS_REFERENCED ? 'parent' : 'child',
          table: match ? match[4] : undefined,
          fields,
          value: fields && fields.length && this.instance && this.instance[fields[0]] || undefined,
          index: match ? match[2] : undefined,
          cause: err,
        });
      }

      default:
        return new sequelizeErrors.DatabaseError(err);
    }
  }

  handleShowTablesQuery(results) {
    return results.map(resultSet => ({
      tableName: resultSet.TABLE_NAME,
      schema: resultSet.TABLE_SCHEMA,
    }));
  }

  handleShowIndexesQuery(data) {

    let currItem;
    const result = [];

    for (const item of data) {
      if (!currItem || currItem.name !== item.Key_name) {
        currItem = {
          primary: item.Key_name === 'PRIMARY',
          fields: [],
          name: item.Key_name,
          tableName: item.Table,
          unique: item.Non_unique !== '1',
          type: item.Index_type,
        };
        result.push(currItem);
      }

      currItem.fields[item.Seq_in_index - 1] = {
        attribute: item.Column_name,
        length: item.Sub_part || undefined,
        order: item.Collation === 'A' ? 'ASC'
          : item.Collation === 'D' ? 'DESC'
          // Not sorted
          : item.Collation === null ? null
          : (() => {
            throw new Error(`Unknown index collation ${NodeUtil.inspect(item.Collation)}`);
          })(),
      };
    }

    return result;
  }
}


import type { CommonErrorProperties } from './base-error';
import { BaseError } from './base-error';

export interface DatabaseErrorParent extends Error, Pick<CommonErrorProperties, 'sql'> {
  /** The parameters for the sql that triggered the error */
  readonly parameters?: object;
}

export interface DatabaseErrorSubclassOptions {
  cause?: DatabaseErrorParent;

  /**
   * @deprecated use {@link DatabaseErrorSubclassOptions.cause}
   */
  parent?: DatabaseErrorParent;
  message?: string;
}

/**
 * A base class for all database related errors.
 */
export class DatabaseError
  extends BaseError
  implements DatabaseErrorParent, CommonErrorProperties {
  sql: string;
  parameters: object;

  declare cause: DatabaseErrorParent;

  /**
   * @param parent The database specific error which triggered this one
   */
  constructor(parent: DatabaseErrorParent) {
    super(parent.message, { cause: parent });
    this.name = 'SequelizeDatabaseError';

    this.sql = parent.sql;
    this.parameters = parent.parameters ?? {};
  }
}


it('does consider the token to be a bind parameter if it is located after a $ quoted string', () => {

const sql = injectReplacements(`SELECT $$ abc $$ AS string FROM users WHERE id = ?`, dialect, [1]);

default: `SELECT $$ abc $$ AS string FROM users WHERE id = 1`,

keep keep keep replace keep keep keep keep

it('does not consider the token to be a replacement if it is in an unnamed $ quoted string', () => {

const sql = injectReplacements(`SELECT $$ ? $$`, dialect, [1]);

it('does consider the token to be a replacement if it is located after a $ quoted string', () => {


import type { ErrorOptions } from './base-error';
import { BaseError } from './base-error';

interface OptimisticLockErrorOptions {
  message?: string;

  /** The name of the model on which the update was attempted */
  modelName?: string;

  /** The values of the attempted update */
  values?: Record<string, unknown>;
  where?: Record<string, unknown>;
}

/**
 * Thrown when attempting to update a stale model instance
 */
export class OptimisticLockError extends BaseError {
  modelName: string | undefined;
  values: Record<string, unknown> | undefined;
  where: Record<string, unknown> | undefined;

  constructor(options?: OptimisticLockErrorOptions, errorOptions?: ErrorOptions) {
    const message = options?.message
      || `Attempting to update a stale model instance: ${options?.modelName}`;

    super(message, errorOptions);
    this.name = 'SequelizeOptimisticLockError';
    this.modelName = options?.modelName;
    this.values = options?.values;
    this.where = options?.where;
  }
}


'use strict';

const chai = require('chai');

const expect = chai.expect;
const Support = require('../../support');

const dialect = Support.getTestDialect();
const { DataTypes } = require('@sequelize/core');

if (dialect === 'mariadb') {
  describe('[MariaDB Specific] Associations', () => {
    describe('many-to-many', () => {
      describe('where tables have the same prefix', () => {
        it('should create a table wp_table1wp_table2s', async function () {
          const Table2 = this.sequelize.define('wp_table2', { foo: DataTypes.STRING });
          const Table1 = this.sequelize.define('wp_table1',
            { foo: DataTypes.STRING });

          Table1.belongsToMany(Table2, { through: 'wp_table1swp_table2s' });
          Table2.belongsToMany(Table1, { through: 'wp_table1swp_table2s' });
          await Table1.sync({ force: true });
          await Table2.sync({ force: true });
          expect(this.sequelize.modelManager.getModel(
            'wp_table1swp_table2s',
          )).to.exist;
        });
      });

      describe('when join table name is specified', () => {
        beforeEach(async function () {
          const Table2 = this.sequelize.define('ms_table1', { foo: DataTypes.STRING });
          const Table1 = this.sequelize.define('ms_table2',
            { foo: DataTypes.STRING });

          Table1.belongsToMany(Table2, { through: 'table1_to_table2' });
          Table2.belongsToMany(Table1, { through: 'table1_to_table2' });
          await Table1.sync({ force: true });
          await Table2.sync({ force: true });
        });

        it('should not use only a specified name', function () {
          expect(this.sequelize.modelManager.getModel(
            'ms_table1sms_table2s',
          )).not.to.exist;
          expect(
            this.sequelize.modelManager.getModel('table1_to_table2'),
          ).to.exist;
        });
      });
    });

    describe('HasMany', () => {
      beforeEach(async function () {
        // prevent periods from occurring in the table name since they are used to delimit (table.column)
        this.User = this.sequelize.define(`User${Math.ceil(Math.random() * 10_000_000)}`, { name: DataTypes.STRING });
        this.Task = this.sequelize.define(`Task${Math.ceil(Math.random() * 10_000_000)}`, { name: DataTypes.STRING });
        this.users = null;
        this.tasks = null;

        this.User.belongsToMany(this.Task, { as: 'Tasks', through: 'UserTasks' });
        this.Task.belongsToMany(this.User, { as: 'Users', through: 'UserTasks' });

        const users = [];
        const tasks = [];

        for (let i = 0; i < 5; ++i) {
          users[i] = { name: `User${Math.random()}` };
          tasks[i] = { name: `Task${Math.random()}` };
        }

        await this.sequelize.sync({ force: true });
        await this.User.bulkCreate(users);
        await this.Task.bulkCreate(tasks);
      });
    });
  });
}


import { expectTypeOf } from 'expect-type';
import type { Attributes, FindByPkOptions } from '@sequelize/core';
import { User } from './models/user';

(async () => {
  expectTypeOf(await User.findByPk(Buffer.from('asdf'))).toEqualTypeOf<User | null>();

  // rejectOnEmpty

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    rejectOnEmpty: undefined,
  })).toEqualTypeOf<User | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    rejectOnEmpty: false,
  })).toEqualTypeOf<User | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    rejectOnEmpty: true,
  })).toEqualTypeOf<User>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    rejectOnEmpty: new Error('error'),
  })).toEqualTypeOf<User>();

  // raw

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: true,
  })).toEqualTypeOf<Attributes<User> | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: false,
  })).toEqualTypeOf<User | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: undefined,
  })).toEqualTypeOf<User | null>();

  // combination

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: false,
    rejectOnEmpty: false,
  })).toEqualTypeOf<User | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: false,
    rejectOnEmpty: true,
  })).toEqualTypeOf<User>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: false,
    rejectOnEmpty: undefined,
  })).toEqualTypeOf<User | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: true,
    rejectOnEmpty: false,
  })).toEqualTypeOf<Attributes<User> | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: true,
    rejectOnEmpty: true,
  })).toEqualTypeOf<Attributes<User>>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: true,
    rejectOnEmpty: undefined,
  })).toEqualTypeOf<Attributes<User> | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: undefined,
    rejectOnEmpty: false,
  })).toEqualTypeOf<User | null>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: undefined,
    rejectOnEmpty: true,
  })).toEqualTypeOf<User>();

  expectTypeOf(await User.findByPk(Buffer.from('asdf'), {
    raw: undefined,
    rejectOnEmpty: undefined,
  })).toEqualTypeOf<User | null>();

  // custom parameter

  interface CustomUser {
    foo: 'bar';
  }

  expectTypeOf(await User.findByPk<User, CustomUser>(Buffer.from('asdf'), {
    raw: true,
  })).toEqualTypeOf<CustomUser | null>();

  expectTypeOf(await User.findByPk<User, CustomUser>(Buffer.from('asdf'), {
    attributes: [['bar', 'foo']],
    rejectOnEmpty: false,
    raw: true,
  })).toEqualTypeOf<CustomUser | null>();

  expectTypeOf(await User.findByPk<User, CustomUser>(Buffer.from('asdf'), {
    attributes: [['bar', 'foo']],
    rejectOnEmpty: true,
    raw: true,
  })).toEqualTypeOf<CustomUser>();

  async function passDown(params: FindByPkOptions<User>) {
    // Unknown ahead of time
    // We can't what 'rejectOnEmpty' and 'raw' are set to, so we default to these types:
    expectTypeOf(await User.findByPk(Buffer.from('asdf'), params))
      .toEqualTypeOf<User | null>();
  }
})();