diff --git a/docs.sh b/docs.sh index d6bc683e3070..d34b3dc61a7f 100644 --- a/docs.sh +++ b/docs.sh @@ -14,7 +14,7 @@ node docs/redirects/create-redirects.js rimraf esdoc/file esdoc/source.html set +e -GREP_RESULT=$(echo "$OUT" | grep -c 'could not parse the following code') +GREP_RESULT=$(echo "$OUT" | grep -c 'could not parse the following code\|SyntaxError') set -e if [ "$GREP_RESULT" -ge 1 ]; then diff --git a/package.json b/package.json index 08e3cb857c87..2d37e64aec2e 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@octokit/rest": "^18.12.0", "@octokit/types": "^6.34.0", "@types/chai": "^4.3.0", + "@types/lodash": "4.14.182", "@types/mocha": "^9.0.0", "@types/node": "^16.11.17", "@types/sinon": "^10.0.6", diff --git a/src/dialects/abstract/index.d.ts b/src/dialects/abstract/index.d.ts new file mode 100644 index 000000000000..018463fec091 --- /dev/null +++ b/src/dialects/abstract/index.d.ts @@ -0,0 +1,106 @@ +import type { Dialect } from '../../sequelize.js'; +import type { AbstractQuery } from './query.js'; + +export declare type DialectSupports = { + 'DEFAULT': boolean; + 'DEFAULT VALUES': boolean; + 'VALUES ()': boolean; + 'LIMIT ON UPDATE': boolean; + 'ON DUPLICATE KEY': boolean; + 'ORDER NULLS': boolean; + 'UNION': boolean; + 'UNION ALL': boolean; + 'RIGHT JOIN': boolean; + EXCEPTION: boolean; + forShare?: 'LOCK IN SHARE MODE' | 'FOR SHARE' | undefined; + lock: boolean; + lockOf: boolean; + lockKey: boolean; + lockOuterJoinFailure: boolean; + skipLocked: boolean; + finalTable: boolean; + returnValues: false | { + output: boolean; + returning: boolean; + }; + autoIncrement: { + identityInsert: boolean; + defaultValue: boolean; + update: boolean; + }; + bulkDefault: boolean; + schemas: boolean; + transactions: boolean; + settingIsolationLevelDuringTransaction: boolean; + transactionOptions: { + type: boolean; + }; + migrations: boolean; + upserts: boolean; + inserts: { + ignoreDuplicates: string; + updateOnDuplicate: boolean | string; + onConflictDoNothing: string; + conflictFields: boolean; + }; + constraints: { + restrict: boolean; + addConstraint: boolean; + dropConstraint: boolean; + unique: boolean; + default: boolean; + check: boolean; + foreignKey: boolean; + primaryKey: boolean; + onUpdate: boolean; + }; + index: { + collate: boolean; + length: boolean; + parser: boolean; + concurrently: boolean; + type: boolean; + using: boolean | number; + functionBased: boolean; + operator: boolean; + where: boolean; + }; + groupedLimit: boolean; + indexViaAlter: boolean; + JSON: boolean; + JSONB: boolean; + ARRAY: boolean; + RANGE: boolean; + NUMERIC: boolean; + GEOMETRY: boolean; + GEOGRAPHY: boolean; + REGEXP: boolean; + /** + * Case-insensitive regexp operator support ('~*' in postgres). + */ + IREGEXP: boolean; + HSTORE: boolean; + TSVECTOR: boolean; + deferrableConstraints: boolean; + tmpTableTrigger: boolean; + indexHints: boolean; + searchPath: boolean; +}; + +export declare abstract class AbstractDialect { + /** + * List of features this dialect supports. + * + * Important: Dialect implementations inherit these values. + * When changing a default, ensure the implementations still properly declare which feature they support. + */ + static readonly supports: DialectSupports; + readonly defaultVersion: string; + readonly Query: typeof AbstractQuery; + readonly name: Dialect; + readonly TICK_CHAR: string; + readonly TICK_CHAR_LEFT: string; + readonly TICK_CHAR_RIGHT: string; + readonly queryGenerator: unknown; + get supports(): DialectSupports; +} diff --git a/src/sequelize.js b/src/sequelize.js index 4e21665d0489..e424a9a2b2d2 100644 --- a/src/sequelize.js +++ b/src/sequelize.js @@ -26,6 +26,7 @@ const { BelongsTo } = require('./associations/belongs-to'); const HasOne = require('./associations/has-one'); const { BelongsToMany } = require('./associations/belongs-to-many'); const { HasMany } = require('./associations/has-many'); +const { injectReplacements } = require('./utils/sql'); /** * This is the main class, the entry point to sequelize. @@ -598,11 +599,7 @@ class Sequelize { } if (options.replacements) { - if (Array.isArray(options.replacements)) { - sql = Utils.format([sql].concat(options.replacements), this.options.dialect); - } else { - sql = Utils.formatNamedParameters(sql, options.replacements, this.options.dialect); - } + sql = injectReplacements(sql, this.dialect, options.replacements); } let bindParameters; @@ -629,7 +626,7 @@ class Sequelize { checkTransaction(); const connection = await (options.transaction ? options.transaction.connection : this.connectionManager.getConnection(options)); - + if (this.options.dialect === 'db2' && options.alter) { if (options.alter.drop === false) { connection.dropTable = false; diff --git a/src/sql-string.d.ts b/src/sql-string.d.ts index bf2d068acd98..310402495179 100644 --- a/src/sql-string.d.ts +++ b/src/sql-string.d.ts @@ -1,5 +1,5 @@ export type Escapable = undefined | null | boolean | number | string | Date; export function escapeId(val: string, forbidQualified?: boolean): string; -export function escape(val: Escapable | Escapable[], timeZone?: string, dialect?: string, format?: string): string; +export function escape(val: Escapable | Escapable[], timeZone?: string, dialect?: string, format?: boolean): string; export function format(sql: string, values: unknown[], timeZone?: string, dialect?: string): string; export function formatNamedParameters(sql: string, values: unknown[], timeZone?: string, dialect?: string): string; diff --git a/src/utils.js b/src/utils.js index 703fd7d0b628..3ac0bd421c5e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -114,6 +114,12 @@ function pluralize(str) { } exports.pluralize = pluralize; +/** + * @deprecated use {@link injectReplacements} instead. This method has been removed in v7. + * + * @param {unknown[]} arr - first item is the SQL, following items are the positional replacements. + * @param {AbstractDialect} dialect + */ function format(arr, dialect) { const timeZone = null; // Make a clone of the array beacuse format modifies the passed args @@ -121,6 +127,13 @@ function format(arr, dialect) { } exports.format = format; +/** + * @deprecated use {@link injectReplacements} instead. This method has been removed in v7. + * + * @param {string} sql + * @param {object} parameters + * @param {AbstractDialect} dialect + */ function formatNamedParameters(sql, parameters, dialect) { const timeZone = null; return SqlString.formatNamedParameters(sql, parameters, timeZone, dialect); diff --git a/src/utils/sql.ts b/src/utils/sql.ts new file mode 100644 index 000000000000..c252cd2704ea --- /dev/null +++ b/src/utils/sql.ts @@ -0,0 +1,221 @@ +import isPlainObject from 'lodash/isPlainObject'; +import type { AbstractDialect } from '../dialects/abstract/index.js'; +import { escape as escapeSqlValue } from '../sql-string'; + +type BindOrReplacements = { [key: string]: unknown } | unknown[]; + +/** + * Inlines replacements in places where they would be valid SQL values. + * + * @param sqlString The SQL that contains the replacements + * @param dialect The dialect of the SQL + * @param replacements if provided, this method will replace ':named' replacements & positional replacements (?) + * + * @returns The SQL with replacements rewritten in their dialect-specific syntax. + */ +export function injectReplacements( + sqlString: string, + dialect: AbstractDialect, + replacements: BindOrReplacements +): 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.`); + } + + const isNamedReplacements = isPlainObject(replacements); + const isPositionalReplacements = Array.isArray(replacements); + let lastConsumedPositionalReplacementIndex = -1; + + let output = ''; + + let currentDollarStringTagName = null; + let isString = false; + let isColumn = false; + let previousSliceEnd = 0; + let isSingleLineComment = false; + let isCommentBlock = 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 === '\'' && !isBackslashEscaped(sqlString, i - 1)) { + isString = false; + } + + continue; + } + + if (currentDollarStringTagName !== null) { + if (char !== '$') { + continue; + } + + const remainingString = sqlString.slice(i, sqlString.length); + + const dollarStringStartMatch = remainingString.match(/^\$(?[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; + 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(/^\$(?[a-z_][0-9a-z_]*)?\$/i); + if (dollarStringStartMatch) { + currentDollarStringTagName = dollarStringStartMatch.groups?.name ?? ''; + + continue; + } + + 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 '=') + if (previousChar !== undefined && !/[\s(,=]/.test(previousChar)) { + continue; + } + + const remainingString = sqlString.slice(i, sqlString.length); + + const match = remainingString.match(/^:(?[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.name, 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 '=') + if (previousChar !== undefined && !/[\s(,=]/.test(previousChar)) { + continue; + } + + // don't parse ?| and ?& operators as replacements + const nextChar = sqlString[i + 1]; + if (nextChar === '|' || nextChar === '&') { + continue; + } + + const replacementIndex = ++lastConsumedPositionalReplacementIndex; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore -- ts < 4.4 loses the information that 'replacements' is an array when using 'isPositionalReplacements' instead of 'Array.isArray' + // but performance matters here. + 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 as any, undefined, dialect.name, true); + + // add everything before the bind parameter name + output += sqlString.slice(previousSliceEnd, i); + // continue after the bind parameter name + previousSliceEnd = i + 1; + + output += escapedReplacement; + } + } + + output += sqlString.slice(previousSliceEnd, sqlString.length); + + return output; +} + +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; +} diff --git a/test/integration/sequelize/query.test.js b/test/integration/sequelize/query.test.js index 1cc9ad76af09..767940b5dbe9 100644 --- a/test/integration/sequelize/query.test.js +++ b/test/integration/sequelize/query.test.js @@ -457,27 +457,27 @@ describe(Support.getTestDialectTeaser('Sequelize'), () => { it('reject when key is missing in the passed object', async function() { await this.sequelize.query('select :one as foo, :two as bar, :three as baz', { raw: true, replacements: { one: 1, two: 2 } }) - .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g); + .should.be.rejectedWith(Error, 'Named replacement ":three" has no entry in the replacement map.'); }); it('reject with the passed number', async function() { await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: 2 }) - .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g); + .should.be.rejectedWith(Error, '"replacements" must be an array or a plain object, but received 2 instead.'); }); it('reject with the passed empty object', async function() { await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: {} }) - .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g); + .should.be.rejectedWith(Error, 'Named replacement ":one" has no entry in the replacement map.'); }); it('reject with the passed string', async function() { await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: 'foobar' }) - .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g); + .should.be.rejectedWith(Error, '"replacements" must be an array or a plain object, but received "foobar" instead.'); }); it('reject with the passed date', async function() { - await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: new Date() }) - .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g); + await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: new Buffer([1]) }) + .should.be.rejectedWith(Error, '"replacements" must be an array or a plain object, but received {"type":"Buffer","data":[1]} instead.'); }); it('reject when binds passed with object and numeric $1 is also present', async function() { diff --git a/test/integration/transaction.test.js b/test/integration/transaction.test.js index 8d6bb5bbaa14..9de28f693b8b 100644 --- a/test/integration/transaction.test.js +++ b/test/integration/transaction.test.js @@ -422,7 +422,11 @@ if (current.dialect.supports.transactions) { }); if (['mysql', 'mariadb'].includes(dialect)) { - describe('deadlock handling', () => { + // Both MariaDB and MySQL (probably innoDB) seem to have changed the way they handle this deadlock + // and the deadlock does not occur anymore. + // We have not managed to recreate this deadlock and, for now, are disabling this test. + // See https://github.com/sequelize/sequelize/issues/14174 + describe.skip('deadlock handling', () => { // Create the `Task` table and ensure it's initialized with 2 rows const getAndInitializeTaskModel = async sequelize => { const Task = sequelize.define('task', { diff --git a/test/support.js b/test/support.js index 34e6c9c7dfcd..fe621ec9a909 100644 --- a/test/support.js +++ b/test/support.js @@ -241,7 +241,7 @@ const Support = { if (query instanceof Error) { expect(query.message).to.equal(expectation.message); } else { - expect(query.query || query).to.equal(expectation); + expect(Support.minifySql(query.query || query)).to.equal(Support.minifySql(expectation)); } if (assertions.bind) { @@ -267,8 +267,12 @@ const Support = { minifySql(sql) { // replace all consecutive whitespaces with a single plain space character return sql.replace(/\s+/g, ' ') - // remove space before coma + // remove space before comma .replace(/ ,/g, ',') + // remove space before ) + .replace(/ \)/g, ')') + // replace space after ( + .replace(/\( /g, '(') // remove whitespace at start & end .trim(); } diff --git a/test/unit/dialects/abstract/query-interface.test.d.ts b/test/unit/dialects/abstract/query-interface.test.d.ts new file mode 100644 index 000000000000..cb0ff5c3b541 --- /dev/null +++ b/test/unit/dialects/abstract/query-interface.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/test/unit/logger.test.d.ts b/test/unit/logger.test.d.ts new file mode 100644 index 000000000000..cb0ff5c3b541 --- /dev/null +++ b/test/unit/logger.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/test/unit/query-interface/bulk-delete.test.js b/test/unit/query-interface/bulk-delete.test.js new file mode 100644 index 000000000000..7d2449bda3c6 --- /dev/null +++ b/test/unit/query-interface/bulk-delete.test.js @@ -0,0 +1,38 @@ +const { DataTypes } = require('sequelize'); +const sinon = require('sinon'); +const { expectsql, sequelize } = require('../../support'); +const { stubQueryRun } = require('./stub-query-run'); + +describe('QueryInterface#bulkDelete', () => { + const User = sequelize.define('User', { + firstName: DataTypes.STRING + }, { timestamps: false }); + + afterEach(() => { + sinon.restore(); + }); + + // you'll find more replacement tests in query-generator tests + it('does not parse replacements outside of raw sql', async () => { + const getSql = stubQueryRun(); + + await sequelize.getQueryInterface().bulkDelete( + User.tableName, + { id: ':id' }, + { + logging: console.log, + replacements: { + limit: 1, + id: '123' + } + }, + User + ); + + expectsql(getSql(), { + default: 'DELETE FROM [Users] WHERE [id] = \':id\'', + mssql: 'DELETE FROM [Users] WHERE [id] = N\':id\'; SELECT @@ROWCOUNT AS AFFECTEDROWS;', + snowflake: 'DELETE FROM "Users" WHERE "id" = \':id\';' + }); + }); +}); diff --git a/test/unit/query-interface/bulk-insert.test.js b/test/unit/query-interface/bulk-insert.test.js new file mode 100644 index 000000000000..6ab4aa7dc405 --- /dev/null +++ b/test/unit/query-interface/bulk-insert.test.js @@ -0,0 +1,32 @@ +const { DataTypes } = require('sequelize'); +const sinon = require('sinon'); +const { expectsql, sequelize } = require('../../support'); +const { stubQueryRun } = require('./stub-query-run'); + +describe('QueryInterface#bulkInsert', () => { + const User = sequelize.define('User', { + firstName: DataTypes.STRING + }, { timestamps: false }); + + afterEach(() => { + sinon.restore(); + }); + + // you'll find more replacement tests in query-generator tests + it('does not parse replacements outside of raw sql', async () => { + const getSql = stubQueryRun(); + + await sequelize.getQueryInterface().bulkInsert(User.tableName, [{ + firstName: ':injection' + }], { + replacements: { + injection: 'raw sql' + } + }); + + expectsql(getSql(), { + default: 'INSERT INTO [Users] ([firstName]) VALUES (\':injection\');', + mssql: 'INSERT INTO [Users] ([firstName]) VALUES (N\':injection\');' + }); + }); +}); diff --git a/test/unit/query-interface/decrement.test.js b/test/unit/query-interface/decrement.test.js new file mode 100644 index 000000000000..8a0cb1cba311 --- /dev/null +++ b/test/unit/query-interface/decrement.test.js @@ -0,0 +1,45 @@ +const { DataTypes } = require('sequelize'); +const sinon = require('sinon'); +const { expectsql, sequelize } = require('../../support'); +const { stubQueryRun } = require('./stub-query-run'); + +describe('QueryInterface#decrement', () => { + const User = sequelize.define('User', { + firstName: DataTypes.STRING + }, { timestamps: false }); + + afterEach(() => { + sinon.restore(); + }); + + // you'll find more replacement tests in query-generator tests + it('does not parse replacements outside of raw sql', async () => { + const getSql = stubQueryRun(); + + await sequelize.getQueryInterface().decrement( + User, + User.tableName, + // where + { id: ':id' }, + // incrementAmountsByField + { age: ':age' }, + // extraAttributesToBeUpdated + { name: ':name' }, + // options + { + returning: [':data'], + replacements: { + age: 1, + id: 2, + data: 3 + } + } + ); + + expectsql(getSql(), { + default: 'UPDATE [Users] SET [age]=[age]- \':age\',[name]=\':name\' WHERE [id] = \':id\'', + postgres: 'UPDATE "Users" SET "age"="age"- \':age\',"name"=\':name\' WHERE "id" = \':id\' RETURNING ":data"', + mssql: 'UPDATE [Users] SET [age]=[age]- N\':age\',[name]=N\':name\' OUTPUT INSERTED.[:data] WHERE [id] = N\':id\'' + }); + }); +}); diff --git a/test/unit/query-interface/delete.test.js b/test/unit/query-interface/delete.test.js new file mode 100644 index 000000000000..68469dee8dd4 --- /dev/null +++ b/test/unit/query-interface/delete.test.js @@ -0,0 +1,38 @@ +const { DataTypes } = require('sequelize'); +const sinon = require('sinon'); +const { expectsql, sequelize } = require('../../support'); +const { stubQueryRun } = require('./stub-query-run'); + +describe('QueryInterface#delete', () => { + const User = sequelize.define('User', { + firstName: DataTypes.STRING + }, { timestamps: false }); + + afterEach(() => { + sinon.restore(); + }); + + // you'll find more replacement tests in query-generator tests + it('does not parse replacements outside of raw sql', async () => { + const getSql = stubQueryRun(); + const instance = new User(); + + await sequelize.getQueryInterface().delete( + instance, + User.tableName, + { id: ':id' }, + { + replacements: { + limit: 1, + id: '123' + } + } + ); + + expectsql(getSql(), { + default: 'DELETE FROM [Users] WHERE [id] = \':id\'', + mssql: 'DELETE FROM [Users] WHERE [id] = N\':id\'; SELECT @@ROWCOUNT AS AFFECTEDROWS;', + snowflake: 'DELETE FROM "Users" WHERE "id" = \':id\';' + }); + }); +}); diff --git a/test/unit/query-interface/increment.test.js b/test/unit/query-interface/increment.test.js new file mode 100644 index 000000000000..05ab9bedb726 --- /dev/null +++ b/test/unit/query-interface/increment.test.js @@ -0,0 +1,45 @@ +const { DataTypes } = require('sequelize'); +const sinon = require('sinon'); +const { expectsql, sequelize } = require('../../support'); +const { stubQueryRun } = require('./stub-query-run'); + +describe('QueryInterface#increment', () => { + const User = sequelize.define('User', { + firstName: DataTypes.STRING + }, { timestamps: false }); + + afterEach(() => { + sinon.restore(); + }); + + // you'll find more replacement tests in query-generator tests + it('does not parse replacements outside of raw sql', async () => { + const getSql = stubQueryRun(); + + await sequelize.getQueryInterface().increment( + User, + User.tableName, + // where + { id: ':id' }, + // incrementAmountsByField + { age: ':age' }, + // extraAttributesToBeUpdated + { name: ':name' }, + // options + { + returning: [':data'], + replacements: { + age: 1, + id: 2, + data: 3 + } + } + ); + + expectsql(getSql(), { + default: 'UPDATE [Users] SET [age]=[age]+ \':age\',[name]=\':name\' WHERE [id] = \':id\'', + postgres: 'UPDATE "Users" SET "age"="age"+ \':age\',"name"=\':name\' WHERE "id" = \':id\' RETURNING ":data"', + mssql: 'UPDATE [Users] SET [age]=[age]+ N\':age\',[name]=N\':name\' OUTPUT INSERTED.[:data] WHERE [id] = N\':id\'' + }); + }); +}); diff --git a/test/unit/query-interface/raw-select.test.js b/test/unit/query-interface/raw-select.test.js new file mode 100644 index 000000000000..8404ef27bf85 --- /dev/null +++ b/test/unit/query-interface/raw-select.test.js @@ -0,0 +1,35 @@ +const { DataTypes } = require('sequelize'); +const sinon = require('sinon'); +const { expectsql, sequelize } = require('../../support'); +const { stubQueryRun } = require('./stub-query-run'); + +describe('QueryInterface#rawSelect', () => { + const User = sequelize.define('User', { + firstName: DataTypes.STRING + }, { timestamps: false }); + + afterEach(() => { + sinon.restore(); + }); + + // you'll find more replacement tests in query-generator tests + it('does not parse user-provided data as replacements', async () => { + const getSql = stubQueryRun(); + + await sequelize.getQueryInterface().rawSelect(User.tableName, { + // @ts-expect-error -- we'll fix the typings when we migrate query-generator to TypeScript + attributes: ['id'], + where: { + username: 'some :data' + }, + replacements: { + data: 'OR \' = ' + } + }, 'id', User); + + expectsql(getSql(), { + default: 'SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = \'some :data\';', + mssql: 'SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = N\'some :data\';' + }); + }); +}); diff --git a/test/unit/query-interface/select.test.js b/test/unit/query-interface/select.test.js new file mode 100644 index 000000000000..4e04109a3cf9 --- /dev/null +++ b/test/unit/query-interface/select.test.js @@ -0,0 +1,35 @@ +const { DataTypes } = require('sequelize'); +const sinon = require('sinon'); +const { expectsql, sequelize } = require('../../support'); +const { stubQueryRun } = require('./stub-query-run'); + +describe('QueryInterface#select', () => { + const User = sequelize.define('User', { + firstName: DataTypes.STRING + }, { timestamps: false }); + + afterEach(() => { + sinon.restore(); + }); + + // you'll find more replacement tests in query-generator tests + it('does not parse user-provided data as replacements', async () => { + const getSql = stubQueryRun(); + + await sequelize.getQueryInterface().select(User, User.tableName, { + // @ts-expect-error -- we'll fix the typings when we migrate query-generator to TypeScript + attributes: ['id'], + where: { + username: 'some :data' + }, + replacements: { + data: 'OR \' = ' + } + }); + + expectsql(getSql(), { + default: 'SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = \'some :data\';', + mssql: 'SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = N\'some :data\';' + }); + }); +}); diff --git a/test/unit/query-interface/stub-query-run.js b/test/unit/query-interface/stub-query-run.js new file mode 100644 index 000000000000..faa36779fbd9 --- /dev/null +++ b/test/unit/query-interface/stub-query-run.js @@ -0,0 +1,22 @@ +const sinon = require('sinon'); +const { sequelize } = require('../../support'); + +module.exports.stubQueryRun = function stubQueryRun() { + let lastExecutedSql; + + class FakeQuery { + run(sql) { + lastExecutedSql = sql; + + return []; + } + } + + sinon.stub(sequelize.dialect, 'Query').get(() => FakeQuery); + sinon.stub(sequelize.connectionManager, 'getConnection').returns({}); + sinon.stub(sequelize.connectionManager, 'releaseConnection'); + + return () => { + return lastExecutedSql; + }; +}; diff --git a/test/unit/sequelize.test.d.ts b/test/unit/sequelize.test.d.ts new file mode 100644 index 000000000000..cb0ff5c3b541 --- /dev/null +++ b/test/unit/sequelize.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/test/unit/utils/sql.test.js b/test/unit/utils/sql.test.js new file mode 100644 index 000000000000..eea503679cd6 --- /dev/null +++ b/test/unit/utils/sql.test.js @@ -0,0 +1,327 @@ +const { injectReplacements } = require('sequelize/lib/utils/sql'); +const { expect } = require('chai'); +const { expectsql, sequelize } = require('../../support'); + +const dialect = sequelize.dialect; + +describe('injectReplacements (named replacements)', () => { + it('parses named replacements', () => { + const sql = injectReplacements(`SELECT ${dialect.TICK_CHAR_LEFT}:id${dialect.TICK_CHAR_RIGHT} FROM users WHERE id = ':id' OR id = :id OR id = ''':id'''`, dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT [:id] FROM users WHERE id = \':id\' OR id = 1 OR id = \'\'\':id\'\'\'' + }); + }); + + it('throws if a named replacement is not provided as an own property', () => { + expect(() => { + injectReplacements('SELECT * FROM users WHERE id = :toString', dialect, { + id: 1 + }); + }).to.throw('Named replacement ":toString" has no entry in the replacement map.'); + + }); + + it('parses named replacements followed by cast syntax', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = :id::string', dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = 1::string' + }); + }); + + it('parses single letter named replacements', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = :a', dialect, { + a: 1 + }); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = 1' + }); + }); + + it('does not consider the token to be a replacement if it does not follow \'(\', \',\', \'=\' or whitespace', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = fn(:id) OR id = fn(\'a\',:id) OR id=:id OR id = :id', dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = fn(1) OR id = fn(\'a\',1) OR id=1 OR id = 1' + }); + }); + + it('does not consider the token to be a replacement if it is part of a $ quoted string', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = $tag$ :id $tag$ OR id = $$ :id $$', dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = $tag$ :id $tag$ OR id = $$ :id $$' + }); + }); + + it('does not consider the token to be a replacement if it is part of a nested $ quoted string', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = $tag1$ $tag2$ :id $tag2$ $tag1$', dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = $tag1$ $tag2$ :id $tag2$ $tag1$' + }); + }); + + it('does consider the token to be a replacement if it is in between two identifiers that look like $ quoted strings', () => { + const sql = injectReplacements('SELECT z$$ :id x$$ * FROM users', dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT z$$ 1 x$$ * FROM users' + }); + }); + + it('does not consider the token to be a replacement if it is part of a string with a backslash escaped quote', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\':id\' OR id = :id', dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = \'\\\':id\' OR id = 1' + }); + }); + + it('considers the token to be a replacement if it is outside a string ending with an escaped backslash', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\\\' OR id = :id', dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = \'\\\\\' OR id = 1' + }); + }); + + it('does not consider the token to be a replacement if it is part of a string with an escaped backslash followed by a backslash escaped quote', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\\\\\':id\' OR id = :id', dialect, { + id: 1 + }); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = \'\\\\\\\':id\' OR id = 1' + }); + }); + + it('does not consider the token to be a replacement if it is in a single line comment', () => { + const sql = injectReplacements(` + SELECT * FROM users -- WHERE id = :id + WHERE id = :id + `, dialect, { id: 1 }); + + expectsql(sql, { + default: ` + SELECT * FROM users -- WHERE id = :id + WHERE id = 1 + ` + }); + }); + + it('does not consider the token to be a replacement if it is in string but a previous comment included a string delimiter', () => { + const sql = injectReplacements(` + SELECT * FROM users -- ' + WHERE id = ' :id ' + `, dialect, { id: 1 }); + + expectsql(sql, { + default: ` + SELECT * FROM users -- ' + WHERE id = ' :id ' + ` + }); + }); + + it('does not consider the token to be a replacement if it is in a single line comment', () => { + const sql = injectReplacements(` + SELECT * FROM users /* + WHERE id = :id + */ + WHERE id = :id + `, dialect, { id: 1 }); + + expectsql(sql, { + default: ` + SELECT * FROM users /* + WHERE id = :id + */ + WHERE id = 1 + ` + }); + }); + + it('does not interpret ::x as a replacement, as it is a cast', () => { + expect(injectReplacements('(\'foo\')::string', dialect, [0])).to.equal('(\'foo\')::string'); + }); +}); + +describe('injectReplacements (positional replacements)', () => { + it('parses positional replacements', () => { + const sql = injectReplacements(`SELECT ${dialect.TICK_CHAR_LEFT}?${dialect.TICK_CHAR_RIGHT} FROM users WHERE id = '?' OR id = ? OR id = '''?''' OR id2 = ?`, dialect, [1, 2]); + + expectsql(sql, { + default: 'SELECT [?] FROM users WHERE id = \'?\' OR id = 1 OR id = \'\'\'?\'\'\' OR id2 = 2' + }); + }); + + it('parses positional replacements followed by cast syntax', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = ?::string', dialect, [1]); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = 1::string' + }); + }); + + it('does not consider the token to be a replacement if it does not follow \'(\', \',\', \'=\' or whitespace', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = fn(?) OR id = fn(\'a\',?) OR id=? OR id = ?', dialect, [2, 1, 3, 4]); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = fn(2) OR id = fn(\'a\',1) OR id=3 OR id = 4' + }); + }); + + it('does not consider the token to be a replacement if it is part of a $ quoted string', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = $tag$ ? $tag$ OR id = $$ ? $$', dialect, [1]); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = $tag$ ? $tag$ OR id = $$ ? $$' + }); + }); + + it('does not consider the token to be a replacement if it is part of a nested $ quoted string', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = $tag1$ $tag2$ ? $tag2$ $tag1$', dialect, [1]); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = $tag1$ $tag2$ ? $tag2$ $tag1$' + }); + }); + + it('does consider the token to be a replacement if it is in between two identifiers that look like $ quoted strings', () => { + const sql = injectReplacements('SELECT z$$ ? x$$ * FROM users', dialect, [1]); + + expectsql(sql, { + default: 'SELECT z$$ 1 x$$ * FROM users' + }); + }); + + it('does not consider the token to be a replacement if it is part of a string with a backslash escaped quote', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\'?\' OR id = ?', dialect, [1]); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = \'\\\'?\' OR id = 1' + }); + }); + + it('considers the token to be a replacement if it is outside a string ending with an escaped backslash', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\\\' OR id = ?', dialect, [1]); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = \'\\\\\' OR id = 1' + }); + }); + + it('does not consider the token to be a replacement if it is part of a string with an escaped backslash followed by a backslash escaped quote', () => { + const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\\\\\'?\' OR id = ?', dialect, [1]); + + expectsql(sql, { + default: 'SELECT * FROM users WHERE id = \'\\\\\\\'?\' OR id = 1' + }); + }); + + it('does not consider the token to be a replacement if it is in a single line comment', () => { + const sql = injectReplacements(` + SELECT * FROM users -- WHERE id = ? + WHERE id = ? + `, dialect, [1]); + + expectsql(sql, { + default: ` + SELECT * FROM users -- WHERE id = ? + WHERE id = 1 + ` + }); + }); + + it('does not consider the token to be a replacement if it is in string but a previous comment included a string delimiter', () => { + const sql = injectReplacements(` + SELECT * FROM users -- ' + WHERE id = ' ? ' + `, dialect, [1]); + + expectsql(sql, { + default: ` + SELECT * FROM users -- ' + WHERE id = ' ? ' + ` + }); + }); + + it('does not consider the token to be a replacement if it is in a single line comment', () => { + const sql = injectReplacements(` + SELECT * FROM users /* + WHERE id = ? + */ + WHERE id = ? + `, dialect, [1]); + + expectsql(sql, { + default: ` + SELECT * FROM users /* + WHERE id = ? + */ + WHERE id = 1 + ` + }); + }); + + // https://github.com/sequelize/sequelize/issues/14358 + it('does not parse ?& and ?| operators as replacements (#14358)', async () => { + const sql = injectReplacements('SELECT * FROM products WHERE tags ?& ARRAY[1] AND tags ?| ARRAY[1] AND id = ?;', dialect, [1]); + + expectsql(sql, { + default: 'SELECT * FROM products WHERE tags ?& ARRAY[1] AND tags ?| ARRAY[1] AND id = 1;', + // 'default' removes the trailing ; for ibmi, but we actually need to test it's there this time, to ensure '?;' is treated as a replacement + ';' + ibmi: 'SELECT * FROM products WHERE tags ?& ARRAY[1] AND tags ?| ARRAY[1] AND id = 1;' + }); + }); + + it('formats where clause correctly when the value is falsy', () => { + expect(injectReplacements('foo = ?', dialect, [0])).to.equal('foo = 0'); + }); + + it('formats arrays as an expression instead of an ARRAY data type', async () => { + const sql = injectReplacements('INSERT INTO users (username, email, created_at, updated_at) VALUES ?;', dialect, [[ + ['john', 'john@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'], + ['michael', 'michael@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'] + ]]); + + expectsql(sql, { + default: ` + INSERT INTO users (username, email, created_at, updated_at) + VALUES + ('john', 'john@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'), + ('michael', 'michael@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10');`, + // 'default' removes the trailing ; for ibmi, but we actually need to test it's there this time, to ensure '?;' is treated as a replacement + ';' + ibmi: ` + INSERT INTO users (username, email, created_at, updated_at) + VALUES + ('john', 'john@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'), + ('michael', 'michael@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10');`, + mssql: ` + INSERT INTO users (username, email, created_at, updated_at) + VALUES + (N'john', N'john@gmail.com', N'2012-01-01 10:10:10', N'2012-01-01 10:10:10'), + (N'michael', N'michael@gmail.com', N'2012-01-01 10:10:10', N'2012-01-01 10:10:10');` + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7ec00d439fcd..514efe1607bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -990,6 +990,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== +"@types/lodash@4.14.182": + version "4.14.182" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2" + integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q== + "@types/minimist@^1.2.0": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"