import util from 'node:util'; import { expect } from 'chai'; import { expectTypeOf } from 'expect-type'; import attempt from 'lodash/attempt'; import type { AttributeNames, Attributes, Cast, Col, Fn, InferAttributes, Literal, Range, WhereOperators, WhereOptions, } from '@sequelize/core'; import { DataTypes, Model, Op, and, json, or, sql } from '@sequelize/core'; import type { FormatWhereOptions, } from '@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/abstract/query-generator-typescript.js'; import { createTester, expectsql, getTestDialectTeaser, sequelize } from '../../support'; const { literal, col, where, fn, cast, attribute } = sql; const queryGen = sequelize.dialect.queryGenerator; // Notice: [] will be replaced by dialect specific tick/quote character // when there is no dialect specific expectation but only a default expectation // TODO: fix and resolve any .skip test type Expectations = { [dialectName: string]: string | Error, }; const dialectSupportsArray = () => sequelize.dialect.supports.dataTypes.ARRAY; const dialectSupportsRange = () => sequelize.dialect.supports.dataTypes.RANGE; const dialectSupportsJsonB = () => sequelize.dialect.supports.dataTypes.JSONB; const dialectSupportsJson = () => sequelize.dialect.supports.dataTypes.JSON; const dialectSupportsJsonOperations = () => sequelize.dialect.supports.jsonOperations; class TestModel extends Model> { declare intAttr1: number; declare intAttr2: number; declare nullableIntAttr: number | null; declare intArrayAttr: number[]; declare intRangeAttr: Range; declare dateRangeAttr: Range; declare stringAttr: string; declare binaryAttr: Buffer; declare dateAttr: Date; declare booleanAttr: boolean; declare bigIntAttr: bigint; declare jsonAttr: object; declare jsonbAttr: object; declare aliasedInt: number; declare aliasedJsonAttr: object; declare aliasedJsonbAttr: object; declare uuidAttr: string; } type TestModelWhere = WhereOptions>; // @ts-expect-error -- we only init a subset of datatypes based on feature support TestModel.init({ intAttr1: DataTypes.INTEGER, intAttr2: DataTypes.INTEGER, nullableIntAttr: DataTypes.INTEGER, ...(dialectSupportsArray() && { intArrayAttr: DataTypes.ARRAY(DataTypes.INTEGER), intRangeAttr: DataTypes.RANGE(DataTypes.INTEGER), dateRangeAttr: DataTypes.RANGE(DataTypes.DATE(3)), }), stringAttr: DataTypes.STRING, binaryAttr: DataTypes.BLOB, dateAttr: DataTypes.DATE(3), booleanAttr: DataTypes.BOOLEAN, bigIntAttr: DataTypes.BIGINT, aliasedInt: { type: DataTypes.INTEGER, field: 'aliased_int' }, ...(dialectSupportsJson() && { jsonAttr: { type: DataTypes.JSON }, aliasedJsonAttr: { type: DataTypes.JSON, field: 'aliased_json' }, }), ...(dialectSupportsJsonB() && { jsonbAttr: { type: DataTypes.JSONB }, aliasedJsonbAttr: { type: DataTypes.JSONB, field: 'aliased_jsonb' }, }), uuidAttr: DataTypes.UUID, }, { sequelize }); describe(getTestDialectTeaser('SQL'), () => { describe('whereQuery', () => { it('prefixes its output with WHERE when it is not empty', () => { expectsql( queryGen.whereQuery({ firstName: 'abc' }), { default: `WHERE [firstName] = 'abc'`, mssql: `WHERE [firstName] = N'abc'`, }, ); }); it('returns an empty string if the input results in an empty query', () => { expectsql( queryGen.whereQuery({ firstName: { [Op.notIn]: [] } }), { default: '', }, ); }); }); describe('whereItemsQuery', () => { type IncludesType = Needle extends any ? Extract extends never ? false : true : never; /** * 'OperatorsSupportingSequelizeValueMethods' lists all operators * that accept values: `col()`, `literal()`, `fn()`, `cast()`, and { [Op.col] } */ type OperatorsSupportingSequelizeValueMethods = keyof { [Key in keyof WhereOperators as IncludesType< WhereOperators[Key], Col | Literal | Fn | Cast | { [Op.col]: string } > extends true ? Key : never ]: WhereOperators[Key] }; /** * Tests whether an operator is compatible with the 5 sequelize methods that can be used as values: * - col() * - literal() * - fn() * - cast() * - legacy Op.col * * If there is a typescript error on the operator passed to this function, then * the typings in {@link WhereOperators} for the provided operator are incorrect. * * @param operator * @param sqlOperator */ function testSequelizeValueMethods( operator: OperatorsSupportingSequelizeValueMethods, sqlOperator: string, ): void { testSql({ intAttr1: { [operator]: { [Op.col]: 'intAttr2' } } }, { default: `[intAttr1] ${sqlOperator} [intAttr2]`, }); testSql({ intAttr1: { [operator]: col('intAttr2') } }, { default: `[intAttr1] ${sqlOperator} [intAttr2]`, }); testSql({ intAttr1: { [operator]: literal('literal') } }, { default: `[intAttr1] ${sqlOperator} literal`, }); testSql({ intAttr1: { [operator]: fn('NOW') } }, { default: `[intAttr1] ${sqlOperator} NOW()`, }); testSql({ intAttr1: { [operator]: fn('SUM', { [Op.col]: 'intAttr2' }) } }, { default: `[intAttr1] ${sqlOperator} SUM([intAttr2])`, }); testSql({ intAttr1: { [operator]: cast(col('intAttr2'), 'string') } }, { default: `[intAttr1] ${sqlOperator} CAST([intAttr2] AS STRING)`, }); testSql({ intAttr1: { [operator]: cast({ [Op.col]: 'intAttr2' }, 'string') } }, { default: `[intAttr1] ${sqlOperator} CAST([intAttr2] AS STRING)`, }); testSql({ intAttr1: { [operator]: cast(12, 'string') } }, { default: `[intAttr1] ${sqlOperator} CAST(12 AS STRING)`, }); } /** * 'OperatorsSupportingSequelizeValueMethods' lists all operators * that accept values: `col()`, `literal()`, `fn()`, `cast()`, and { [Op.col] } */ type OperatorsSupportingAnyAll = keyof { [Key in keyof WhereOperators as IncludesType< WhereOperators[Key], | { [Op.all]: any[] | Literal | { [Op.values]: any[] } } | { [Op.any]: any[] | Literal | { [Op.values]: any[] } } > extends true ? Key : never ]: WhereOperators[Key] }; /** * Tests whether an operator is compatible with: * - Op.any (+ Op.values) * - Op.all (+ Op.values) * * If there is a typescript error on the operator passed to this function, then * the typings in {@link WhereOperators} for the provided operator are incorrect. * * @param operator * @param sqlOperator * @param testWithValues * @param attributeName */ function testSupportsAnyAll( operator: OperatorsSupportingAnyAll, sqlOperator: string, testWithValues: TestWithValue[], attributeName: AttributeNames = 'intAttr1', ) { if (!dialectSupportsArray()) { return; } const arrayOperators: Array<[jsOp: symbol, sqlOp: string]> = [ [Op.any, 'ANY'], [Op.all, 'ALL'], ]; for (const [arrayOperator, arraySqlOperator] of arrayOperators) { testSql({ [attributeName]: { [operator]: { [arrayOperator]: testWithValues } } }, { default: `[${attributeName}] ${sqlOperator} ${arraySqlOperator} (ARRAY[${testWithValues.map(v => util.inspect(v)).join(',')}])`, }); testSql({ [attributeName]: { [operator]: { [arrayOperator]: literal('literal') } } }, { default: `[${attributeName}] ${sqlOperator} ${arraySqlOperator} (literal)`, }); // e.g. "col" LIKE ANY (VALUES ("col2")) testSql({ [attributeName]: { [operator]: { [arrayOperator]: { [Op.values]: [ literal('literal'), fn('UPPER', col('col2')), col('col3'), cast(col('col'), 'string'), testWithValues[0], ], }, }, }, }, { default: `[${attributeName}] ${sqlOperator} ${arraySqlOperator} (VALUES (literal), (UPPER("col2")), ("col3"), (CAST("col" AS STRING)), (${util.inspect(testWithValues[0])}))`, }); } } const testSql = createTester( (it, whereObj: TestModelWhere, expectations: Expectations, options?: FormatWhereOptions) => { it(util.inspect(whereObj, { depth: 10 }) + (options ? `, ${util.inspect(options)}` : ''), () => { const sqlOrError = attempt(() => queryGen.whereItemsQuery(whereObj, { ...options, model: TestModel, })); return expectsql(sqlOrError, expectations); }); }, ); // "where" is typically optional. If the user sets it to undefined, we treat is as if the option was not set. testSql(undefined, { default: '', }); testSql({}, { default: '', }); testSql([], { default: '', }); // @ts-expect-error -- not supported, testing that it throws testSql(null, { default: new Error(`Invalid value received for the "where" option. Refer to the sequelize documentation to learn which values the "where" option accepts. Value: null Caused by: Invalid Query: expected a plain object, an array or a sequelize SQL method but got null`), }); // @ts-expect-error -- not supported, testing that it throws testSql(10, { default: new Error(`Invalid value received for the "where" option. Refer to the sequelize documentation to learn which values the "where" option accepts. Value: 10 Caused by: Invalid Query: expected a plain object, an array or a sequelize SQL method but got 10`), }); testSql({ intAttr1: undefined }, { default: new Error(`Invalid value received for the "where" option. Refer to the sequelize documentation to learn which values the "where" option accepts. Value: { intAttr1: undefined } Caused by: "undefined" cannot be escaped`), }); // @ts-expect-error -- user does not exist testSql({ intAttr1: 1, user: undefined }, { default: new Error('"undefined" cannot be escaped') }); testSql({ intAttr1: 1 }, { default: '[User].[intAttr1] = 1', }, { mainAlias: 'User' }); testSql({ dateAttr: { $gte: '2022-11-06' } }, { default: new Error(`{ '$gte': '2022-11-06' } is not a valid date`) }); testSql(literal('raw sql'), { default: 'raw sql', }); describe('value serialization', () => { // string testSql({ stringAttr: '1' }, { default: `[stringAttr] = '1'`, mssql: `[stringAttr] = N'1'`, }); testSql({ stringAttr: 'here is a null char: \0', }, { default: '[stringAttr] = \'here is a null char: \\0\'', snowflake: '"stringAttr" = \'here is a null char: \0\'', mssql: '[stringAttr] = N\'here is a null char: \0\'', db2: '"stringAttr" = \'here is a null char: \0\'', ibmi: '"stringAttr" = \'here is a null char: \0\'', sqlite: '`stringAttr` = \'here is a null char: \0\'', }); testSql({ dateAttr: 1_356_998_400_000, }, { default: `[dateAttr] = '2013-01-01 00:00:00.000 +00:00'`, 'mariadb mysql': `\`dateAttr\` = '2013-01-01 00:00:00.000'`, mssql: `[dateAttr] = N'2013-01-01 00:00:00.000 +00:00'`, 'db2 snowflake ibmi': `"dateAttr" = '2013-01-01 00:00:00.000'`, }); describe('Buffer', () => { testSql({ binaryAttr: Buffer.from('Sequelize') }, { ibmi: `"binaryAttr" = BLOB(X'53657175656c697a65')`, postgres: `"binaryAttr" = '\\x53657175656c697a65'`, 'sqlite mariadb mysql': '`binaryAttr` = X\'53657175656c697a65\'', db2: `"binaryAttr" = BLOB('Sequelize')`, snowflake: `"binaryAttr" = X'53657175656c697a65'`, mssql: '[binaryAttr] = 0x53657175656c697a65', }); // Including a quote (') to ensure dialects that don't convert to hex are safe from SQL injection. testSql({ binaryAttr: [Buffer.from(`Seque'lize1`), Buffer.from('Sequelize2')] }, { ibmi: `"binaryAttr" IN (BLOB(X'5365717565276c697a6531'), BLOB(X'53657175656c697a6532'))`, postgres: `"binaryAttr" IN ('\\x5365717565276c697a6531', '\\x53657175656c697a6532')`, 'sqlite mariadb mysql': '`binaryAttr` IN (X\'5365717565276c697a6531\', X\'53657175656c697a6532\')', db2: `"binaryAttr" IN (BLOB('Seque''lize1'), BLOB('Sequelize2'))`, snowflake: `"binaryAttr" IN (X'5365717565276c697a6531', X'53657175656c697a6532')`, mssql: '[binaryAttr] IN (0x5365717565276c697a6531, 0x53657175656c697a6532)', }); }); }); describe('implicit operator', () => { testSql({ intAttr1: 1 }, { default: '[intAttr1] = 1', }); testSql({ stringAttr: '1' }, { default: `[stringAttr] = '1'`, mssql: `[stringAttr] = N'1'`, }); testSql({ intAttr1: [1, 2] }, { default: '[intAttr1] IN (1, 2)', }); testSql({ stringAttr: ['1', '2'] }, { default: `[stringAttr] IN ('1', '2')`, mssql: `[stringAttr] IN (N'1', N'2')`, }); testSql({ intAttr1: ['not-an-int'] }, { default: new Error(`'not-an-int' is not a valid integer`) }); testSql({ 'stringAttr::integer': 1 }, { default: 'CAST([stringAttr] AS INTEGER) = 1', }); testSql({ $intAttr1$: 1 }, { default: '[intAttr1] = 1', }); testSql({ '$stringAttr$::integer': 1 }, { default: 'CAST([stringAttr] AS INTEGER) = 1', }); testSql({ '$association.attribute$': 1 }, { default: '[association].[attribute] = 1', }); testSql({ '$association.attribute$::integer': 1 }, { default: 'CAST([association].[attribute] AS INTEGER) = 1', }); testSql({ booleanAttr: true }, { default: `[booleanAttr] = true`, mssql: '[booleanAttr] = 1', sqlite: '`booleanAttr` = 1', ibmi: '"booleanAttr" = 1', }); testSql({ stringAttr: 'a project', intAttr1: { [Op.or]: [ [1, 2, 3], { [Op.gt]: 10 }, ], }, }, { default: '[stringAttr] = \'a project\' AND ([intAttr1] IN (1, 2, 3) OR [intAttr1] > 10)', mssql: '[stringAttr] = N\'a project\' AND ([intAttr1] IN (1, 2, 3) OR [intAttr1] > 10)', }); testSql({ nullableIntAttr: null }, { default: '[nullableIntAttr] IS NULL', }); testSql({ dateAttr: new Date('2021-01-01T00:00:00Z') }, { default: `[dateAttr] = '2021-01-01 00:00:00.000 +00:00'`, mssql: `[dateAttr] = N'2021-01-01 00:00:00.000 +00:00'`, 'mariadb mysql': `\`dateAttr\` = '2021-01-01 00:00:00.000'`, 'db2 ibmi snowflake': `"dateAttr" = '2021-01-01 00:00:00.000'`, }); testSql({ intAttr1: { [Op.col]: 'intAttr2' } }, { default: '[intAttr1] = [intAttr2]', }); testSql({ intAttr1: col('intAttr2') }, { default: '[intAttr1] = [intAttr2]', }); testSql({ intAttr1: literal('literal') }, { default: '[intAttr1] = literal', }); testSql({ stringAttr: fn('UPPER', col('stringAttr')) }, { default: '[stringAttr] = UPPER([stringAttr])', }); testSql({ stringAttr: fn('UPPER', { [Op.col]: 'stringAttr' }) }, { default: '[stringAttr] = UPPER([stringAttr])', }); testSql({ stringAttr: cast(col('intAttr1'), 'string') }, { default: '[stringAttr] = CAST([intAttr1] AS STRING)', }); testSql({ stringAttr: cast({ [Op.col]: 'intAttr1' }, 'string') }, { default: '[stringAttr] = CAST([intAttr1] AS STRING)', }); testSql({ stringAttr: cast('abc', 'string') }, { default: `[stringAttr] = CAST('abc' AS STRING)`, mssql: `[stringAttr] = CAST(N'abc' AS STRING)`, }); if (dialectSupportsArray()) { testSql({ intArrayAttr: [1, 2] }, { default: `[intArrayAttr] = ARRAY[1,2]`, }); testSql({ intArrayAttr: [] }, { default: `[intArrayAttr] = ARRAY[]::INTEGER[]`, }); // when using arrays, Op.in is never included // @ts-expect-error -- Omitting the operator with an array attribute is always Op.eq, never Op.in testSql({ intArrayAttr: [[1, 2]] }, { default: new Error('[ 1, 2 ] is not a valid integer') }); testSql({ intAttr1: { [Op.any]: [2, 3, 4] } }, { default: '[intAttr1] = ANY (ARRAY[2,3,4])', }); testSql({ intAttr1: { [Op.any]: literal('literal') } }, { default: '[intAttr1] = ANY (literal)', }); testSql({ intAttr1: { [Op.any]: { [Op.values]: [col('col')] } } }, { default: '[intAttr1] = ANY (VALUES ([col]))', }); testSql({ intAttr1: { [Op.all]: [2, 3, 4] } }, { default: '[intAttr1] = ALL (ARRAY[2,3,4])', }); testSql({ intAttr1: { [Op.all]: literal('literal') } }, { default: '[intAttr1] = ALL (literal)', }); testSql({ intAttr1: { [Op.all]: { [Op.values]: [col('col')] } } }, { default: '[intAttr1] = ALL (VALUES ([col]))', }); // e.g. "col" LIKE ANY (VALUES ("col2")) testSql({ intAttr1: { [Op.any]: { [Op.values]: [ literal('literal'), fn('UPPER', col('col2')), col('col3'), cast(col('col'), 'string'), 1, ], }, }, }, { default: `[intAttr1] = ANY (VALUES (literal), (UPPER([col2])), ([col3]), (CAST([col] AS STRING)), (1))`, }); } }); describe('Op.eq', () => { testSql({ intAttr1: { [Op.eq]: 1 } }, { default: '[intAttr1] = 1', }); testSql({ 'intAttr1::integer': { [Op.eq]: 1 } }, { default: 'CAST([intAttr1] AS INTEGER) = 1', }); testSql({ $intAttr1$: { [Op.eq]: 1 } }, { default: '[intAttr1] = 1', }); testSql({ '$intAttr1$::integer': { [Op.eq]: 1 } }, { default: 'CAST([intAttr1] AS INTEGER) = 1', }); testSql({ '$association.attribute$': { [Op.eq]: 1 } }, { default: '[association].[attribute] = 1', }); testSql({ '$association.attribute$::integer': { [Op.eq]: 1 } }, { default: `CAST([association].[attribute] AS INTEGER) = 1`, }); if (dialectSupportsArray()) { // @ts-expect-error -- intArrayAttr is not an array const ignore: TestModelWhere = { intAttr1: { [Op.eq]: [1, 2] } }; testSql({ intArrayAttr: { [Op.eq]: [1, 2] } }, { default: '[intArrayAttr] = ARRAY[1,2]', }); } { // @ts-expect-error -- intAttr1 is not nullable const ignore: TestModelWhere = { intAttr1: { [Op.eq]: null } }; // this one is testSql({ nullableIntAttr: { [Op.eq]: null } }, { default: '[nullableIntAttr] IS NULL', }); } testSql({ booleanAttr: { [Op.eq]: true } }, { default: '[booleanAttr] = true', 'mssql sqlite ibmi': '[booleanAttr] = 1', }); testSequelizeValueMethods(Op.eq, '='); testSupportsAnyAll(Op.eq, '=', [2, 3, 4]); }); describe('Op.ne', () => { testSql({ intAttr1: { [Op.ne]: 1 } }, { default: '[intAttr1] != 1', }); if (dialectSupportsArray()) { testSql({ intArrayAttr: { [Op.ne]: [1, 2] } }, { default: '[intArrayAttr] != ARRAY[1,2]', }); } testSql({ nullableIntAttr: { [Op.ne]: null } }, { default: '[nullableIntAttr] IS NOT NULL', }); testSql({ booleanAttr: { [Op.ne]: true } }, { default: '[booleanAttr] != true', 'mssql ibmi sqlite': '[booleanAttr] != 1', }); testSequelizeValueMethods(Op.ne, '!='); testSupportsAnyAll(Op.ne, '!=', [2, 3, 4]); }); describe('Op.is', () => { { // @ts-expect-error -- intAttr is not nullable const ignore: TestModelWhere = { intAttr: { [Op.is]: null } }; } { // @ts-expect-error -- stringAttr is not a boolean const ignore: TestModelWhere = { stringAttr: { [Op.is]: true } }; } testSql({ nullableIntAttr: { [Op.is]: null } }, { default: '[nullableIntAttr] IS NULL', }); testSql({ booleanAttr: { [Op.is]: false } }, { default: '[booleanAttr] IS false', 'mssql ibmi sqlite': '[booleanAttr] IS 0', }); testSql({ booleanAttr: { [Op.is]: true } }, { default: '[booleanAttr] IS true', 'mssql ibmi sqlite': '[booleanAttr] IS 1', }); // @ts-expect-error -- not supported, testing that it throws testSql({ intAttr1: { [Op.is]: 1 } }, { default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); // @ts-expect-error -- not supported, testing that it throws testSql({ intAttr1: { [Op.is]: { [Op.col]: 'intAttr2' } } }, { default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); // @ts-expect-error -- not supported, testing that it throws testSql({ intAttr1: { [Op.is]: col('intAttr2') } }, { default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); testSql({ intAttr1: { [Op.is]: literal('UNKNOWN') } }, { default: '[intAttr1] IS UNKNOWN', }); // @ts-expect-error -- not supported, testing that it throws testSql({ intAttr1: { [Op.is]: fn('UPPER', col('intAttr2')) } }, { default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); // @ts-expect-error -- not supported, testing that it throws testSql({ intAttr1: { [Op.is]: cast(col('intAttr2'), 'boolean') } }, { default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); if (dialectSupportsArray()) { // @ts-expect-error -- not supported, testing that it throws testSql({ intAttr1: { [Op.is]: { [Op.any]: [2, 3] } } }, { default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); // @ts-expect-error -- not supported, testing that it throws testSql({ intAttr1: { [Op.is]: { [Op.all]: [2, 3, 4] } } }, { default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); } }); describe('Op.isNot', () => { testSql({ nullableIntAttr: { [Op.isNot]: null } }, { default: '[nullableIntAttr] IS NOT NULL', }); testSql({ booleanAttr: { [Op.isNot]: false } }, { default: '[booleanAttr] IS NOT false', 'mssql ibmi sqlite': '[booleanAttr] IS NOT 0', }); testSql({ booleanAttr: { [Op.isNot]: true } }, { default: '[booleanAttr] IS NOT true', 'mssql ibmi sqlite': '[booleanAttr] IS NOT 1', }); }); describe('Op.not', () => { testSql({ [Op.not]: {} }, { default: '', }); testSql({ [Op.not]: { [Op.not]: {}, }, }, { default: '', }); testSql({ [Op.not]: [] }, { default: '', }); testSql({ nullableIntAttr: { [Op.not]: {} } }, { default: '', }); testSql({ nullableIntAttr: { [Op.not]: null } }, { default: 'NOT ([nullableIntAttr] IS NULL)', }); testSql({ booleanAttr: { [Op.not]: false } }, { default: 'NOT ([booleanAttr] = false)', mssql: 'NOT ([booleanAttr] = 0)', ibmi: 'NOT ("booleanAttr" = 0)', sqlite: 'NOT (`booleanAttr` = 0)', }); testSql({ booleanAttr: { [Op.not]: true } }, { default: 'NOT ([booleanAttr] = true)', mssql: 'NOT ([booleanAttr] = 1)', ibmi: 'NOT ("booleanAttr" = 1)', sqlite: 'NOT (`booleanAttr` = 1)', }); testSql({ intAttr1: { [Op.not]: 1 } }, { default: 'NOT ([intAttr1] = 1)', }); testSql({ intAttr1: { [Op.not]: [1, 2] } }, { default: 'NOT ([intAttr1] IN (1, 2))', }); { // @ts-expect-error -- not a valid query: attribute does not exist. const ignore: TestModelWhere = { [Op.not]: { doesNotExist: 5 } }; } testSql({ [Op.not]: { intAttr1: 5 } }, { default: 'NOT ([intAttr1] = 5)', }); testSql({ [Op.not]: { intAttr1: { [Op.gt]: 5 } } }, { default: 'NOT ([intAttr1] > 5)', }); testSql({ [Op.not]: where(col('intAttr1'), Op.eq, '5') }, { default: `NOT ([intAttr1] = '5')`, mssql: `NOT ([intAttr1] = N'5')`, }); if (dialectSupportsJsonOperations()) { testSql({ [Op.not]: json('data.key', 10) }, { postgres: `NOT ("data"->'key' = '10')`, sqlite: `NOT (json_extract(\`data\`,'$.key') = '10')`, mariadb: `NOT (json_compact(json_extract(\`data\`,'$.key')) = '10')`, mysql: `NOT (json_extract(\`data\`,'$.key') = CAST('10' AS JSON))`, }); } testSql({ intAttr1: { [Op.not]: { [Op.gt]: 5 } } }, { default: 'NOT ([intAttr1] > 5)', }); }); function describeComparisonSuite( operator: typeof Op.gt | typeof Op.gte | typeof Op.lt | typeof Op.lte, sqlOperator: string, ) { // ensure gte, gt, lte, lt support the same typings, so we only have to test their typings once. // unfortunately, at time of writing (TS 4.5.5), TypeScript // does not detect an error in `{ [operator]: null }` // but it does detect an error in { [Op.gt]: null }` expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); describe(`Op.${operator.description}`, () => { { const ignore: TestModelWhere = { intAttr1: { [Op.gt]: 1 } }; testSql({ intAttr1: { [operator]: 1 } }, { default: `[intAttr1] ${sqlOperator} 1`, }); } { const ignore: TestModelWhere = { stringAttr: { [Op.gt]: 'abc' } }; testSql({ stringAttr: { [operator]: 'abc' } }, { default: `[stringAttr] ${sqlOperator} 'abc'`, mssql: `[stringAttr] ${sqlOperator} N'abc'`, }); } if (dialectSupportsArray()) { const ignore: TestModelWhere = { intArrayAttr: { [Op.gt]: [1, 2] } }; testSql({ intArrayAttr: { [operator]: [1, 2] } }, { default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2]`, }); } expectTypeOf({ intAttr1: { [Op.gt]: null } }).not.toMatchTypeOf(); testSql({ intAttr1: { [operator]: null } }, { default: `[intAttr1] ${sqlOperator} NULL`, }); testSequelizeValueMethods(operator, sqlOperator); testSupportsAnyAll(operator, sqlOperator, [2, 3, 4]); }); } describeComparisonSuite(Op.gt, '>'); describeComparisonSuite(Op.gte, '>='); describeComparisonSuite(Op.lt, '<'); describeComparisonSuite(Op.lte, '<='); function describeBetweenSuite( operator: typeof Op.between | typeof Op.notBetween, sqlOperator: string, ) { // ensure between and notBetween support the same typings, so we only have to test their typings once. // unfortunately, at time of writing (TS 4.5.5), TypeScript // does not detect an error in `{ [operator]: null }` // but it does detect an error in { [Op.gt]: null }` expectTypeOf().toEqualTypeOf(); describe(`Op.${operator.description}`, () => { expectTypeOf({ id: { [Op.between]: [1, 2] } }).toMatchTypeOf(); expectTypeOf({ id: { [Op.between]: [new Date(), new Date()] } }).toMatchTypeOf(); expectTypeOf({ id: { [Op.between]: ['a', 'b'] } }).toMatchTypeOf(); // expectTypeOf doesn't work with this one: { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: [1, 2] }, }; testSql({ intAttr1: { [operator]: [1, 2] } }, { default: `[intAttr1] ${sqlOperator} 1 AND 2`, }); // @ts-expect-error -- must pass exactly 2 items const ignoreWrong: TestModelWhere = { intAttr1: { [Op.between]: [1, 2, 3] } }; // @ts-expect-error -- must pass exactly 2 items const ignoreWrong2: TestModelWhere = { intAttr1: { [Op.between]: [1] } }; testSql({ intAttr1: { [operator]: [1] } }, { default: new Error('Operators Op.between and Op.notBetween must be used with an array of two values, or a literal.'), }); // @ts-expect-error -- must pass exactly 2 items const ignoreWrong3: TestModelWhere = { intAttr1: { [Op.between]: [] } }; } if (dialectSupportsArray()) { { const ignoreRight: TestModelWhere = { intArrayAttr: { [Op.between]: [[1, 2], [3, 4]] } }; testSql({ intArrayAttr: { [operator]: [[1, 2], [3, 4]] } }, { default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2] AND ARRAY[3,4]`, }); } { // @ts-expect-error -- this is not valid because intAttr1 is not an array and cannot be compared to arrays const ignore: TestModelWhere = { intAttr1: { [Op.between]: [[1, 2], [3, 4]] } }; } } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: [col('col1'), col('col2')] } }; testSql({ intAttr1: { [operator]: [col('col1'), col('col2')] } }, { default: `[intAttr1] ${sqlOperator} [col1] AND [col2]`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: [literal('literal1'), literal('literal2')] } }; testSql({ intAttr1: { [operator]: [literal('literal1'), literal('literal2')] } }, { default: `[intAttr1] ${sqlOperator} literal1 AND literal2`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: [fn('NOW'), fn('NOW')] } }; testSql({ intAttr1: { [operator]: [fn('NOW'), fn('NOW')] } }, { default: `[intAttr1] ${sqlOperator} NOW() AND NOW()`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }; testSql({ intAttr1: { [operator]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }, { default: `[intAttr1] ${sqlOperator} [col1] AND [col2]`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: [cast(col('col'), 'string'), cast(col('col'), 'string')] } }; testSql({ intAttr1: { [operator]: [cast(col('col'), 'string'), cast(col('col'), 'string')] } }, { default: `[intAttr1] ${sqlOperator} CAST([col] AS STRING) AND CAST([col] AS STRING)`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: literal('literal1 AND literal2') } }; testSql({ intAttr1: { [operator]: literal('literal1 AND literal2') } }, { default: `[intAttr1] ${sqlOperator} literal1 AND literal2`, }); } }); } describeBetweenSuite(Op.between, 'BETWEEN'); describeBetweenSuite(Op.notBetween, 'NOT BETWEEN'); function describeInSuite( operator: typeof Op.in | typeof Op.notIn, sqlOperator: string, extraTests: () => void, ): void { // ensure between and notBetween support the same typings, so we only have to test their typings once. // unfortunately, at time of writing (TS 4.5.5), TypeScript // does not detect an error in `{ [operator]: null }` // but it does detect an error in { [Op.gt]: null }` expectTypeOf().toEqualTypeOf(); describe(`Op.${operator.description}`, () => { { const ignoreRight: TestModelWhere = { intAttr1: { [Op.in]: [1, 2, 3] } }; testSql({ intAttr1: { [operator]: [1, 2, 3] } }, { default: `[intAttr1] ${sqlOperator} (1, 2, 3)`, }); } if (dialectSupportsArray()) { { // valid const ignore: TestModelWhere = { intArrayAttr: { [Op.in]: [[1, 2], [3, 4]] } }; testSql({ intArrayAttr: { [operator]: [[1, 2], [3, 4]] } }, { default: `[intArrayAttr] ${sqlOperator} (ARRAY[1,2], ARRAY[3,4])`, }); } { // @ts-expect-error -- intAttr1 is not an array const ignore: TestModelWhere = { intAttr1: { [Op.in]: [[1, 2], [3, 4]] } }; testSql({ intArrayAttr: { [operator]: [[1, 2], [3, 4]] } }, { default: `[intArrayAttr] ${sqlOperator} (ARRAY[1,2], ARRAY[3,4])`, }); } } { // @ts-expect-error -- this is invalid because intAttr1 is not an array and cannot be compared to arrays. const ignore: TestModelWhere = { intAttr1: { [Op.in]: [[1, 2], [3, 4]] } }; } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intAttr1: { [Op.in]: 1 } }; testSql({ intAttr1: { [operator]: 1 } }, { default: new Error('Operators Op.in and Op.notIn must be called with an array of values, or a literal'), }); } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intAttr1: { [Op.in]: col('col2') } }; testSql({ intAttr1: { [operator]: col('col1') } }, { default: new Error('Operators Op.in and Op.notIn must be called with an array of values, or a literal'), }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.in]: [col('col1'), col('col2')] } }; testSql({ intAttr1: { [operator]: [col('col1'), col('col2')] } }, { default: `[intAttr1] ${sqlOperator} ([col1], [col2])`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.in]: [literal('literal1'), literal('literal2')] } }; testSql({ intAttr1: { [operator]: [literal('literal1'), literal('literal2')] } }, { default: `[intAttr1] ${sqlOperator} (literal1, literal2)`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.in]: [fn('NOW'), fn('NOW')] } }; testSql({ intAttr1: { [operator]: [fn('NOW'), fn('NOW')] } }, { default: `[intAttr1] ${sqlOperator} (NOW(), NOW())`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.in]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }; testSql({ intAttr1: { [operator]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }, { default: `[intAttr1] ${sqlOperator} ([col1], [col2])`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.in]: [cast(col('col'), 'string'), cast(col('col'), 'string')] } }; testSql({ intAttr1: { [operator]: [cast(col('col'), 'string'), cast(col('col'), 'string')] } }, { default: `[intAttr1] ${sqlOperator} (CAST([col] AS STRING), CAST([col] AS STRING))`, }); } { const ignoreRight: TestModelWhere = { intAttr1: { [Op.in]: literal('literal') } }; testSql({ intAttr1: { [operator]: literal('literal') } }, { default: `[intAttr1] ${sqlOperator} literal`, }); } { // @ts-expect-error -- Op.all is not compatible with Op.in const ignoreWrong: TestModelWhere = { intAttr1: { [Op.in]: { [Op.all]: [] } } }; } extraTests(); }); } describeInSuite(Op.in, 'IN', () => { testSql({ intAttr1: { [Op.in]: [] } }, { default: '[intAttr1] IN (NULL)', }); }); describeInSuite(Op.notIn, 'NOT IN', () => { testSql({ intAttr1: { [Op.notIn]: [] } }, { default: '', }); }); function describeLikeSuite( operator: typeof Op.like | typeof Op.notLike | typeof Op.iLike | typeof Op.notILike, sqlOperator: string, ) { // ensure like ops support the same typings, so we only have to test their typings once. // unfortunately, at time of writing (TS 4.5.5), TypeScript // does not detect an error in `{ [operator]: null }` // but it does detect an error in { [Op.iLike]: null }` expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); describe(`Op.${operator.description}`, () => { expectTypeOf({ stringAttr: { [Op.like]: '%id' } }).toMatchTypeOf(); testSql({ stringAttr: { [operator]: '%id' } }, { default: `[stringAttr] ${sqlOperator} '%id'`, mssql: `[stringAttr] ${sqlOperator} N'%id'`, }); // This test checks that the right data type is used to stringify the right operand testSql({ 'intAttr1::text': { [operator]: '%id' } }, { default: `CAST([intAttr1] AS TEXT) ${sqlOperator} '%id'`, mssql: `CAST([intAttr1] AS TEXT) ${sqlOperator} N'%id'`, }); testSequelizeValueMethods(operator, sqlOperator); testSupportsAnyAll(operator, sqlOperator, ['a', 'b', 'c'], 'stringAttr'); }); } describeLikeSuite(Op.like, 'LIKE'); describeLikeSuite(Op.notLike, 'NOT LIKE'); describeLikeSuite(Op.iLike, 'ILIKE'); describeLikeSuite(Op.notILike, 'NOT ILIKE'); function describeOverlapSuite( operator: typeof Op.overlap | typeof Op.contains | typeof Op.contained, sqlOperator: string, ) { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); if (dialectSupportsArray()) { describe(`Op.${operator.description} on ARRAY`, () => { { const ignoreRight: TestModelWhere = { intArrayAttr: { [Op.overlap]: [1, 2, 3] } }; testSql({ intArrayAttr: { [operator]: [1, 2, 3] } }, { default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2,3]`, }); } testSequelizeValueMethods(operator, sqlOperator); // ARRAY Overlap ARRAY doesn't support ANY or ALL, except with VALUES // testSupportsAnyAll(operator, sqlOperator, [[1, 2], [1, 2]]); { // @ts-expect-error -- cannot compare an array with a range! const ignore: TestModelWhere = { intArrayAttr: { [Op.overlap]: [1, { value: 2, inclusive: true }] } }; testSql({ intArrayAttr: { [operator]: [1, { value: 2, inclusive: true }] } }, { default: new Error('{ value: 2, inclusive: true } is not a valid integer'), }); } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [col('col')] } }; testSql({ intArrayAttr: { [operator]: [col('col')] } }, { default: new Error(`Col { identifiers: [ 'col' ] } is not a valid integer`), }); } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [{ [Op.col]: 'col' }] } }; testSql({ intArrayAttr: { [operator]: [{ [Op.col]: 'col' }] } }, { default: new Error(`{ [Symbol(col)]: 'col' } is not a valid integer`), }); } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [literal('literal')] } }; testSql({ intArrayAttr: { [operator]: [literal('literal')] } }, { default: new Error(`Literal { val: [ 'literal' ] } is not a valid integer`), }); } }); } if (dialectSupportsRange()) { describe(`Op.${operator.description} on RANGE`, () => { { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [1, 2] } }; testSql({ intRangeAttr: { [operator]: [1, 2] } }, { default: `[intRangeAttr] ${sqlOperator} '[1,2)'::int4range`, }); } { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [1, { value: 2, inclusive: true }] } }; testSql({ intRangeAttr: { [operator]: [1, { value: 2, inclusive: true }] } }, { // used 'postgres' because otherwise range is transformed to "1,2" postgres: `"intRangeAttr" ${sqlOperator} '[1,2]'::int4range`, }); } { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [{ value: 1, inclusive: false }, 2] } }; testSql({ intRangeAttr: { [operator]: [{ value: 1, inclusive: false }, 2] } }, { default: `[intRangeAttr] ${sqlOperator} '(1,2)'::int4range`, }); } { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [{ value: 1, inclusive: false }, { value: 2, inclusive: false }] }, }; testSql({ intRangeAttr: { [operator]: [{ value: 1, inclusive: false }, { value: 2, inclusive: false }] } }, { default: `[intRangeAttr] ${sqlOperator} '(1,2)'::int4range`, }); } { // unbounded range (right) const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [10, null] } }; testSql({ intRangeAttr: { [operator]: [10, null] }, }, { postgres: `"intRangeAttr" ${sqlOperator} '[10,)'::int4range`, }); } { // unbounded range (left) const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [null, 10] } }; testSql({ intRangeAttr: { [operator]: [null, 10] }, }, { postgres: `"intRangeAttr" ${sqlOperator} '[,10)'::int4range`, }); } { // unbounded range (left) const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [null, null] } }; testSql({ intRangeAttr: { [operator]: [null, null] }, }, { postgres: `"intRangeAttr" ${sqlOperator} '[,)'::int4range`, }); } { const ignoreRight: TestModelWhere = { dateRangeAttr: { [Op.overlap]: [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY] }, }; testSql({ dateRangeAttr: { [operator]: [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY], }, }, { postgres: `"dateRangeAttr" ${sqlOperator} '[-infinity,infinity)'::tstzrange`, }); } { // empty range const ignoreRight: TestModelWhere = { dateRangeAttr: { [Op.overlap]: [] } }; testSql({ dateRangeAttr: { [operator]: [] }, }, { postgres: `"dateRangeAttr" ${sqlOperator} 'empty'::tstzrange`, }); } { // @ts-expect-error -- 'intRangeAttr' is a range, but right-hand side is a regular Array const ignore: TestModelWhere = { intRangeAttr: { [Op.overlap]: [1, 2, 3] } }; testSql({ intRangeAttr: { [operator]: [1, 2, 3] } }, { default: new Error('A range must either be an array with two elements, or an empty array for the empty range. Got [ 1, 2, 3 ].'), }); } testSequelizeValueMethods(operator, sqlOperator); testSupportsAnyAll(operator, sqlOperator, [1, 2]); }); } } describeOverlapSuite(Op.overlap, '&&'); describeOverlapSuite(Op.contains, '@>'); if (dialectSupportsRange()) { describe('RANGE Op.contains ELEMENT', () => { testSql({ intRangeAttr: { [Op.contains]: 1 }, }, { postgres: `"intRangeAttr" @> 1`, }); // @ts-expect-error -- `ARRAY Op.contains ELEMENT` is not a valid query testSql({ intArrayAttr: { [Op.contains]: 1 } }, { default: new Error('1 is not a valid array'), }); }); } describeOverlapSuite(Op.contained, '<@'); describe('ELEMENT Op.contained RANGE', () => { if (!dialectSupportsRange()) { return; } testSql({ intAttr1: { [Op.contained]: [1, 2] }, }, { postgres: `"intAttr1" <@ '[1,2)'::int4range`, }); testSql({ bigIntAttr: { [Op.contained]: [1, 2] }, }, { postgres: `"bigIntAttr" <@ '[1,2)'::int8range`, }); testSql({ dateAttr: { [Op.contained]: [new Date('2020-01-01T00:00:00Z'), new Date('2021-01-01T00:00:00Z')] }, }, { postgres: `"dateAttr" <@ '[2020-01-01 00:00:00.000 +00:00,2021-01-01 00:00:00.000 +00:00)'::tstzrange`, }); /* TODO: numrange — Range of numeric tsrange — Range of timestamp without time zone daterange — Range of date */ }); describe('Op.startsWith', () => { // TODO: use implementation not based on "LIKE" // mysql, mariadb: locate() // postgres:, ^@ // snowflake, ibmi, db2: position() // mssql: CHARINDEX() // sqlite: INSTR() testSql({ stringAttr: { [Op.startsWith]: 'swagger', }, }, { default: `[stringAttr] LIKE 'swagger%'`, mssql: `[stringAttr] LIKE N'swagger%'`, }); testSql({ stringAttr: { [Op.startsWith]: 'sql\'injection', }, }, { default: `[stringAttr] LIKE 'sql''injection%'`, mysql: `\`stringAttr\` LIKE 'sql\\'injection%'`, mariadb: `\`stringAttr\` LIKE 'sql\\'injection%'`, mssql: `[stringAttr] LIKE N'sql''injection%'`, }); // startsWith should escape anything that has special meaning in LIKE testSql.skip({ stringAttr: { [Op.startsWith]: 'like%injection', }, }, { default: String.raw`[stringAttr] LIKE 'sql\%injection%' ESCAPE '\'`, mssql: String.raw`[stringAttr] LIKE N'sql\%injection%' ESCAPE '\'`, }); testSql({ stringAttr: { [Op.startsWith]: literal('$bind'), }, }, { default: `[stringAttr] LIKE CONCAT($bind, '%')`, mssql: `[stringAttr] LIKE CONCAT($bind, N'%')`, }); testSql({ stringAttr: { [Op.startsWith]: col('username'), }, }, { default: `[stringAttr] LIKE CONCAT([username], '%')`, mssql: `[stringAttr] LIKE CONCAT([username], N'%')`, }); testSql({ stringAttr: { [Op.startsWith]: { [Op.col]: 'username' }, }, }, { default: `[stringAttr] LIKE CONCAT([username], '%')`, mssql: `[stringAttr] LIKE CONCAT([username], N'%')`, }); testSql({ stringAttr: { [Op.startsWith]: fn('NOW'), }, }, { default: `[stringAttr] LIKE CONCAT(NOW(), '%')`, mssql: `[stringAttr] LIKE CONCAT(NOW(), N'%')`, }); testSql({ stringAttr: { [Op.startsWith]: cast(fn('NOW'), 'string'), }, }, { default: `[stringAttr] LIKE CONCAT(CAST(NOW() AS STRING), '%')`, mssql: `[stringAttr] LIKE CONCAT(CAST(NOW() AS STRING), N'%')`, }); // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPe is '\') // @ts-expect-error -- startsWith is not compatible with Op.any testSql({ stringAttr: { [Op.startsWith]: { [Op.any]: ['test'] } } }, { default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- startsWith is not compatible with Op.all testSql({ stringAttr: { [Op.startsWith]: { [Op.all]: ['test'] } } }, { default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); describe('Op.endsWith', () => { testSql({ stringAttr: { [Op.endsWith]: 'swagger', }, }, { default: `[stringAttr] LIKE '%swagger'`, mssql: `[stringAttr] LIKE N'%swagger'`, }); testSql({ stringAttr: { [Op.endsWith]: 'sql\'injection', }, }, { default: `[stringAttr] LIKE '%sql''injection'`, mysql: `\`stringAttr\` LIKE '%sql\\'injection'`, mariadb: `\`stringAttr\` LIKE '%sql\\'injection'`, mssql: `[stringAttr] LIKE N'%sql''injection'`, }); // endsWith should escape anything that has special meaning in LIKE testSql.skip({ stringAttr: { [Op.endsWith]: 'like%injection', }, }, { default: String.raw`[stringAttr] LIKE '%sql\%injection' ESCAPE '\'`, mssql: String.raw`[stringAttr] LIKE N'%sql\%injection' ESCAPE '\'`, }); testSql({ stringAttr: { [Op.endsWith]: literal('$bind'), }, }, { default: `[stringAttr] LIKE CONCAT('%', $bind)`, mssql: `[stringAttr] LIKE CONCAT(N'%', $bind)`, }); testSql({ stringAttr: { [Op.endsWith]: col('username'), }, }, { default: `[stringAttr] LIKE CONCAT('%', [username])`, mssql: `[stringAttr] LIKE CONCAT(N'%', [username])`, }); testSql({ stringAttr: { [Op.endsWith]: { [Op.col]: 'username' }, }, }, { default: `[stringAttr] LIKE CONCAT('%', [username])`, mssql: `[stringAttr] LIKE CONCAT(N'%', [username])`, }); testSql({ stringAttr: { [Op.endsWith]: fn('NOW'), }, }, { default: `[stringAttr] LIKE CONCAT('%', NOW())`, mssql: `[stringAttr] LIKE CONCAT(N'%', NOW())`, }); testSql({ stringAttr: { [Op.endsWith]: cast(fn('NOW'), 'string'), }, }, { default: `[stringAttr] LIKE CONCAT('%', CAST(NOW() AS STRING))`, mssql: `[stringAttr] LIKE CONCAT(N'%', CAST(NOW() AS STRING))`, }); // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPE is '\') // @ts-expect-error -- startsWith is not compatible with Op.any testSql({ stringAttr: { [Op.endsWith]: { [Op.any]: ['test'] } } }, { default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- startsWith is not compatible with Op.all testSql({ stringAttr: { [Op.endsWith]: { [Op.all]: ['test'] } } }, { default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); describe('Op.substring', () => { // TODO: use implementation not based on "LIKE" // mysql, mariadb: locate() // postgres:, position() // snowflake, ibmi, db2: position() // mssql: CHARINDEX() // sqlite: INSTR() testSql({ stringAttr: { [Op.substring]: 'swagger', }, }, { default: `[stringAttr] LIKE '%swagger%'`, mssql: `[stringAttr] LIKE N'%swagger%'`, }); testSql({ stringAttr: { [Op.substring]: 'sql\'injection', }, }, { default: `[stringAttr] LIKE '%sql''injection%'`, mysql: `\`stringAttr\` LIKE '%sql\\'injection%'`, mariadb: `\`stringAttr\` LIKE '%sql\\'injection%'`, mssql: `[stringAttr] LIKE N'%sql''injection%'`, }); // substring should escape anything that has special meaning in LIKE testSql.skip({ stringAttr: { [Op.substring]: 'like%injection', }, }, { default: String.raw`[stringAttr] LIKE '%sql\%injection%' ESCAPE '\'`, mssql: String.raw`[stringAttr] LIKE N'%sql\%injection%' ESCAPE '\'`, }); testSql({ stringAttr: { [Op.substring]: literal('$bind'), }, }, { default: `[stringAttr] LIKE CONCAT('%', $bind, '%')`, mssql: `[stringAttr] LIKE CONCAT(N'%', $bind, N'%')`, }); testSql({ stringAttr: { [Op.substring]: col('username'), }, }, { default: `[stringAttr] LIKE CONCAT('%', [username], '%')`, mssql: `[stringAttr] LIKE CONCAT(N'%', [username], N'%')`, }); testSql({ stringAttr: { [Op.substring]: { [Op.col]: 'username' }, }, }, { default: `[stringAttr] LIKE CONCAT('%', [username], '%')`, mssql: `[stringAttr] LIKE CONCAT(N'%', [username], N'%')`, }); testSql({ stringAttr: { [Op.substring]: fn('NOW'), }, }, { default: `[stringAttr] LIKE CONCAT('%', NOW(), '%')`, mssql: `[stringAttr] LIKE CONCAT(N'%', NOW(), N'%')`, }); testSql({ stringAttr: { [Op.substring]: cast(fn('NOW'), 'string'), }, }, { default: `[stringAttr] LIKE CONCAT('%', CAST(NOW() AS STRING), '%')`, mssql: `[stringAttr] LIKE CONCAT(N'%', CAST(NOW() AS STRING), N'%')`, }); // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPE is '\') // @ts-expect-error -- startsWith is not compatible with Op.any testSql({ stringAttr: { [Op.substring]: { [Op.any]: ['test'] } } }, { default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- startsWith is not compatible with Op.all testSql({ stringAttr: { [Op.substring]: { [Op.all]: ['test'] } } }, { default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); describe('Op.notStartsWith', () => { testSql({ stringAttr: { [Op.notStartsWith]: 'swagger', }, }, { default: `[stringAttr] NOT LIKE 'swagger%'`, mssql: `[stringAttr] NOT LIKE N'swagger%'`, }); testSql({ stringAttr: { [Op.notStartsWith]: 'sql\'injection', }, }, { default: `[stringAttr] NOT LIKE 'sql''injection%'`, mysql: `\`stringAttr\` NOT LIKE 'sql\\'injection%'`, mariadb: `\`stringAttr\` NOT LIKE 'sql\\'injection%'`, mssql: `[stringAttr] NOT LIKE N'sql''injection%'`, }); // startsWith should escape anything that has special meaning in LIKE testSql.skip({ stringAttr: { [Op.notStartsWith]: 'like%injection', }, }, { default: String.raw`[stringAttr] NOT LIKE 'sql\%injection%' ESCAPE '\'`, mssql: String.raw`[stringAttr] NOT LIKE N'sql\%injection%' ESCAPE '\'`, }); testSql({ stringAttr: { [Op.notStartsWith]: literal('$bind'), }, }, { default: `[stringAttr] NOT LIKE CONCAT($bind, '%')`, mssql: `[stringAttr] NOT LIKE CONCAT($bind, N'%')`, }); testSql({ stringAttr: { [Op.notStartsWith]: col('username'), }, }, { default: `[stringAttr] NOT LIKE CONCAT([username], '%')`, mssql: `[stringAttr] NOT LIKE CONCAT([username], N'%')`, }); testSql({ stringAttr: { [Op.notStartsWith]: { [Op.col]: 'username' }, }, }, { default: `[stringAttr] NOT LIKE CONCAT([username], '%')`, mssql: `[stringAttr] NOT LIKE CONCAT([username], N'%')`, }); testSql({ stringAttr: { [Op.notStartsWith]: fn('NOW'), }, }, { default: `[stringAttr] NOT LIKE CONCAT(NOW(), '%')`, mssql: `[stringAttr] NOT LIKE CONCAT(NOW(), N'%')`, }); testSql({ stringAttr: { [Op.notStartsWith]: cast(fn('NOW'), 'string'), }, }, { default: `[stringAttr] NOT LIKE CONCAT(CAST(NOW() AS STRING), '%')`, mssql: `[stringAttr] NOT LIKE CONCAT(CAST(NOW() AS STRING), N'%')`, }); // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPe is '\') // @ts-expect-error -- notStartsWith is not compatible with Op.any testSql({ stringAttr: { [Op.notStartsWith]: { [Op.any]: ['test'] } } }, { default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- notStartsWith is not compatible with Op.all testSql({ stringAttr: { [Op.notStartsWith]: { [Op.all]: ['test'] } } }, { default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); describe('Op.notEndsWith', () => { testSql({ stringAttr: { [Op.notEndsWith]: 'swagger', }, }, { default: `[stringAttr] NOT LIKE '%swagger'`, mssql: `[stringAttr] NOT LIKE N'%swagger'`, }); testSql({ stringAttr: { [Op.notEndsWith]: 'sql\'injection', }, }, { default: `[stringAttr] NOT LIKE '%sql''injection'`, mysql: `\`stringAttr\` NOT LIKE '%sql\\'injection'`, mariadb: `\`stringAttr\` NOT LIKE '%sql\\'injection'`, mssql: `[stringAttr] NOT LIKE N'%sql''injection'`, }); // notEndsWith should escape anything that has special meaning in LIKE testSql.skip({ stringAttr: { [Op.notEndsWith]: 'like%injection', }, }, { default: String.raw`[stringAttr] NOT LIKE '%sql\%injection' ESCAPE '\'`, mssql: String.raw`[stringAttr] NOT LIKE N'%sql\%injection' ESCAPE '\'`, }); testSql({ stringAttr: { [Op.notEndsWith]: literal('$bind'), }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', $bind)`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', $bind)`, }); testSql({ stringAttr: { [Op.notEndsWith]: col('username'), }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', [username])`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', [username])`, }); testSql({ stringAttr: { [Op.notEndsWith]: { [Op.col]: 'username' }, }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', [username])`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', [username])`, }); testSql({ stringAttr: { [Op.notEndsWith]: fn('NOW'), }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', NOW())`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', NOW())`, }); testSql({ stringAttr: { [Op.notEndsWith]: cast(fn('NOW'), 'string'), }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', CAST(NOW() AS STRING))`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', CAST(NOW() AS STRING))`, }); // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPE is '\') // @ts-expect-error -- notEndsWith is not compatible with Op.any testSql({ stringAttr: { [Op.notEndsWith]: { [Op.any]: ['test'] } } }, { default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- notEndsWith is not compatible with Op.all testSql({ stringAttr: { [Op.notEndsWith]: { [Op.all]: ['test'] } } }, { default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); describe('Op.notSubstring', () => { testSql({ stringAttr: { [Op.notSubstring]: 'swagger', }, }, { default: `[stringAttr] NOT LIKE '%swagger%'`, mssql: `[stringAttr] NOT LIKE N'%swagger%'`, }); testSql({ stringAttr: { [Op.notSubstring]: 'sql\'injection', }, }, { default: `[stringAttr] NOT LIKE '%sql''injection%'`, mysql: `\`stringAttr\` NOT LIKE '%sql\\'injection%'`, mariadb: `\`stringAttr\` NOT LIKE '%sql\\'injection%'`, mssql: `[stringAttr] NOT LIKE N'%sql''injection%'`, }); // notSubstring should escape anything that has special meaning in LIKE testSql.skip({ stringAttr: { [Op.notSubstring]: 'like%injection', }, }, { default: String.raw`[stringAttr] NOT LIKE '%sql\%injection%' ESCAPE '\'`, mssql: String.raw`[stringAttr] NOT LIKE N'%sql\%injection%' ESCAPE '\'`, }); testSql({ stringAttr: { [Op.notSubstring]: literal('$bind'), }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', $bind, '%')`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', $bind, N'%')`, }); testSql({ stringAttr: { [Op.notSubstring]: col('username'), }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', [username], '%')`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', [username], N'%')`, }); testSql({ stringAttr: { [Op.notSubstring]: { [Op.col]: 'username' }, }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', [username], '%')`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', [username], N'%')`, }); testSql({ stringAttr: { [Op.notSubstring]: fn('NOW'), }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', NOW(), '%')`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', NOW(), N'%')`, }); testSql({ stringAttr: { [Op.notSubstring]: cast(fn('NOW'), 'string'), }, }, { default: `[stringAttr] NOT LIKE CONCAT('%', CAST(NOW() AS STRING), '%')`, mssql: `[stringAttr] NOT LIKE CONCAT(N'%', CAST(NOW() AS STRING), N'%')`, }); // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPE is '\') // @ts-expect-error -- notSubstring is not compatible with Op.any testSql({ stringAttr: { [Op.notSubstring]: { [Op.any]: ['test'] } } }, { default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- notSubstring is not compatible with Op.all testSql({ stringAttr: { [Op.notSubstring]: { [Op.all]: ['test'] } } }, { default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); function describeRegexpSuite( operator: typeof Op.regexp | typeof Op.iRegexp | typeof Op.notRegexp | typeof Op.notIRegexp, sqlOperator: string, ) { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); describe(`Op.${operator.description}`, () => { { const ignore: TestModelWhere = { stringAttr: { [Op.regexp]: '^sw.*r$' } }; } testSql({ stringAttr: { [operator]: '^sw.*r$' } }, { default: `[stringAttr] ${sqlOperator} '^sw.*r$'`, }); testSql({ stringAttr: { [operator]: '^new\nline$' } }, { default: `[stringAttr] ${sqlOperator} '^new\nline$'`, mariadb: `\`stringAttr\` ${sqlOperator} '^new\\nline$'`, mysql: `\`stringAttr\` ${sqlOperator} '^new\\nline$'`, }); testSequelizeValueMethods(operator, sqlOperator); testSupportsAnyAll(operator, sqlOperator, ['^a$', '^b$'], 'stringAttr'); }); } if (sequelize.dialect.supports.REGEXP) { describeRegexpSuite(Op.regexp, sequelize.dialect.name === 'postgres' ? '~' : 'REGEXP'); describeRegexpSuite(Op.notRegexp, sequelize.dialect.name === 'postgres' ? '!~' : 'NOT REGEXP'); } if (sequelize.dialect.supports.IREGEXP) { describeRegexpSuite(Op.iRegexp, '~*'); describeRegexpSuite(Op.notIRegexp, '!~*'); } if (sequelize.dialect.supports.dataTypes.TSVECTOR) { describe('Op.match', () => { testSql({ stringAttr: { [Op.match]: fn('to_tsvector', 'swagger') } }, { default: `[stringAttr] @@ to_tsvector('swagger')`, }); testSequelizeValueMethods(Op.match, '@@'); // TODO // testSupportsAnyAll(Op.match, '@@', [fn('to_tsvector', 'a'), fn('to_tsvector', 'b')]); }); } function describeAdjacentRangeSuite( operator: typeof Op.adjacent | typeof Op.strictLeft | typeof Op.strictRight | typeof Op.noExtendLeft | typeof Op.noExtendRight, sqlOperator: string, ) { if (!dialectSupportsRange()) { return; } expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); describe(`RANGE Op.${operator.description} RANGE`, () => { { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [1, 2] } }; testSql({ intRangeAttr: { [operator]: [1, 2] } }, { default: `[intRangeAttr] ${sqlOperator} '[1,2)'::int4range`, }); } { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [1, { value: 2, inclusive: true }] } }; testSql({ intRangeAttr: { [operator]: [1, { value: 2, inclusive: true }] } }, { // used 'postgres' because otherwise range is transformed to "1,2" postgres: `"intRangeAttr" ${sqlOperator} '[1,2]'::int4range`, }); } { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [{ value: 1, inclusive: false }, 2] } }; testSql({ intRangeAttr: { [operator]: [{ value: 1, inclusive: false }, 2] } }, { default: `[intRangeAttr] ${sqlOperator} '(1,2)'::int4range`, }); } { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [{ value: 1, inclusive: false }, { value: 2, inclusive: false }] }, }; testSql({ intRangeAttr: { [operator]: [{ value: 1, inclusive: false }, { value: 2, inclusive: false }] } }, { default: `[intRangeAttr] ${sqlOperator} '(1,2)'::int4range`, }); } { // unbounded range (right) const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [10, null] } }; testSql({ intRangeAttr: { [operator]: [10, null] }, }, { postgres: `"intRangeAttr" ${sqlOperator} '[10,)'::int4range`, }); } { // unbounded range (left) const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [null, 10] } }; testSql({ intRangeAttr: { [operator]: [null, 10] }, }, { postgres: `"intRangeAttr" ${sqlOperator} '[,10)'::int4range`, }); } { // unbounded range (left) const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [null, null] } }; testSql({ intRangeAttr: { [operator]: [null, null] }, }, { postgres: `"intRangeAttr" ${sqlOperator} '[,)'::int4range`, }); } { const ignoreRight: TestModelWhere = { dateRangeAttr: { [Op.adjacent]: [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY] }, }; testSql({ dateRangeAttr: { [operator]: [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY], }, }, { postgres: `"dateRangeAttr" ${sqlOperator} '[-infinity,infinity)'::tstzrange`, }); } { // empty range const ignoreRight: TestModelWhere = { dateRangeAttr: { [Op.adjacent]: [] } }; testSql({ dateRangeAttr: { [operator]: [] }, }, { postgres: `"dateRangeAttr" ${sqlOperator} 'empty'::tstzrange`, }); } { // @ts-expect-error -- 'intRangeAttr' is a range, but right-hand side is a regular Array const ignore: TestModelWhere = { intRangeAttr: { [Op.overlap]: [1, 2, 3] } }; testSql({ intRangeAttr: { [operator]: [1, 2, 3] } }, { default: new Error('A range must either be an array with two elements, or an empty array for the empty range. Got [ 1, 2, 3 ].'), }); } }); } describeAdjacentRangeSuite(Op.adjacent, '-|-'); describeAdjacentRangeSuite(Op.strictLeft, '<<'); describeAdjacentRangeSuite(Op.strictRight, '>>'); describeAdjacentRangeSuite(Op.noExtendLeft, '&>'); describeAdjacentRangeSuite(Op.noExtendRight, '&<'); if (sequelize.dialect.supports.jsonOperations) { describe('JSON Operations', () => { { // @ts-expect-error -- attribute 'doesNotExist' does not exist. const ignore: TestModelWhere = { 'doesNotExist.nested': 'value' }; } { // @ts-expect-error -- attribute 'doesNotExist' does not exist. const ignore: TestModelWhere = { '$doesNotExist$.nested': 'value' }; } testSql({ jsonAttr: 'value' }, { default: `[jsonAttr] = '"value"'`, mysql: `\`jsonAttr\` = CAST('"value"' AS JSON)`, }); testSql({ 'jsonAttr.nested': 'value' }, { postgres: `"jsonAttr"->'nested' = '"value"'`, sqlite: `json_extract(\`jsonAttr\`,'$.nested') = '"value"'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested')) = '"value"'`, mysql: `json_extract(\`jsonAttr\`,'$.nested') = CAST('"value"' AS JSON)`, }); testSql(where('value', Op.eq, attribute('jsonAttr.nested')), { postgres: `'"value"' = "jsonAttr"->'nested'`, sqlite: `'"value"' = json_extract(\`jsonAttr\`,'$.nested')`, mariadb: `'"value"' = json_compact(json_extract(\`jsonAttr\`,'$.nested'))`, mysql: `CAST('"value"' AS JSON) = json_extract(\`jsonAttr\`,'$.nested')`, }); testSql({ 'jsonAttr.nested.twice': 'value' }, { postgres: `"jsonAttr"#>ARRAY['nested','twice'] = '"value"'`, sqlite: `json_extract(\`jsonAttr\`,'$.nested.twice') = '"value"'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested.twice')) = '"value"'`, mysql: `json_extract(\`jsonAttr\`,'$.nested.twice') = CAST('"value"' AS JSON)`, }); testSql({ jsonAttr: { nested: 'value' }, }, { postgres: `"jsonAttr"->'nested' = '"value"'`, sqlite: `json_extract(\`jsonAttr\`,'$.nested') = '"value"'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested')) = '"value"'`, mysql: `json_extract(\`jsonAttr\`,'$.nested') = CAST('"value"' AS JSON)`, }); testSql({ 'jsonAttr.nested': { twice: 'value' }, }, { postgres: `"jsonAttr"#>ARRAY['nested','twice'] = '"value"'`, sqlite: `json_extract(\`jsonAttr\`,'$.nested.twice') = '"value"'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested.twice')) = '"value"'`, mysql: `json_extract(\`jsonAttr\`,'$.nested.twice') = CAST('"value"' AS JSON)`, }); testSql({ jsonAttr: { [Op.eq]: { key: 'value' } }, }, { default: `[jsonAttr] = '{"key":"value"}'`, mysql: `\`jsonAttr\` = CAST('{"key":"value"}' AS JSON)`, }); testSql({ 'jsonAttr.nested': { [Op.ne]: 'value' }, }, { postgres: `"jsonAttr"->'nested' != '"value"'`, sqlite: `json_extract(\`jsonAttr\`,'$.nested') != '"value"'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested')) != '"value"'`, mysql: `json_extract(\`jsonAttr\`,'$.nested') != CAST('"value"' AS JSON)`, }); testSql({ '$jsonAttr$.nested': 'value', }, { postgres: `"jsonAttr"->'nested' = '"value"'`, sqlite: `json_extract(\`jsonAttr\`,'$.nested') = '"value"'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested')) = '"value"'`, mysql: `json_extract(\`jsonAttr\`,'$.nested') = CAST('"value"' AS JSON)`, }); testSql({ '$association.jsonAttr$.nested': 'value', }, { postgres: `"association"."jsonAttr"->'nested' = '"value"'`, sqlite: `json_extract(\`association\`.\`jsonAttr\`,'$.nested') = '"value"'`, mariadb: `json_compact(json_extract(\`association\`.\`jsonAttr\`,'$.nested')) = '"value"'`, mysql: `json_extract(\`association\`.\`jsonAttr\`,'$.nested') = CAST('"value"' AS JSON)`, }); testSql({ 'jsonAttr.nested::STRING': 'value', }, { // with the left value cast to a string, we serialize the right value as a string, not as a JSON value postgres: `CAST("jsonAttr"->'nested' AS STRING) = 'value'`, mariadb: `CAST(json_compact(json_extract(\`jsonAttr\`,'$.nested')) AS STRING) = 'value'`, 'sqlite mysql': `CAST(json_extract(\`jsonAttr\`,'$.nested') AS STRING) = 'value'`, }); testSql({ '$association.jsonAttr$.nested::STRING': { attribute: 'value', }, }, { default: new Error(`Could not guess type of value { attribute: 'value' }`) }); testSql({ '$association.jsonAttr$.nested.deep::STRING': 'value', }, { postgres: `CAST("association"."jsonAttr"#>ARRAY['nested','deep'] AS STRING) = 'value'`, mariadb: `CAST(json_compact(json_extract(\`association\`.\`jsonAttr\`,'$.nested.deep')) AS STRING) = 'value'`, 'sqlite mysql': `CAST(json_extract(\`association\`.\`jsonAttr\`,'$.nested.deep') AS STRING) = 'value'`, }); testSql({ $jsonAttr$: { 'nested::string': 'value' }, }, { postgres: `CAST("jsonAttr"->'nested' AS STRING) = 'value'`, mariadb: `CAST(json_compact(json_extract(\`jsonAttr\`,'$.nested')) AS STRING) = 'value'`, 'sqlite mysql': `CAST(json_extract(\`jsonAttr\`,'$.nested') AS STRING) = 'value'`, }); testSql({ 'jsonAttr.nested.attribute': 4 }, { postgres: `"jsonAttr"#>ARRAY['nested','attribute'] = '4'`, sqlite: `json_extract(\`jsonAttr\`,'$.nested.attribute') = '4'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested.attribute')) = '4'`, mysql: `json_extract(\`jsonAttr\`,'$.nested.attribute') = CAST('4' AS JSON)`, }); // 0 is treated as a string key here, not an array index testSql({ 'jsonAttr.0': 4 }, { postgres: `"jsonAttr"->'0' = '4'`, sqlite: `json_extract(\`jsonAttr\`,'$."0"') = '4'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$."0"')) = '4'`, mysql: `json_extract(\`jsonAttr\`,'$."0"') = CAST('4' AS JSON)`, }); // 0 is treated as an index here, not a string key testSql({ 'jsonAttr[0]': 4 }, { postgres: `"jsonAttr"->0 = '4'`, // these tests cannot be deduplicated because [0] will be replaced by `0` by expectsql sqlite: `json_extract(\`jsonAttr\`,'$[0]') = '4'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$[0]')) = '4'`, mysql: `json_extract(\`jsonAttr\`,'$[0]') = CAST('4' AS JSON)`, }); testSql({ 'jsonAttr.0.attribute': 4 }, { postgres: `"jsonAttr"#>ARRAY['0','attribute'] = '4'`, sqlite: `json_extract(\`jsonAttr\`,'$."0".attribute') = '4'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$."0".attribute')) = '4'`, mysql: `json_extract(\`jsonAttr\`,'$."0".attribute') = CAST('4' AS JSON)`, }); // Regression test: https://github.com/sequelize/sequelize/issues/8718 testSql({ jsonAttr: { 'hyphenated-key': 4 } }, { postgres: `"jsonAttr"->'hyphenated-key' = '4'`, sqlite: `json_extract(\`jsonAttr\`,'$."hyphenated-key"') = '4'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$."hyphenated-key"')) = '4'`, mysql: `json_extract(\`jsonAttr\`,'$."hyphenated-key"') = CAST('4' AS JSON)`, }); // SQL injection test testSql({ jsonAttr: { '"a\')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- "': 1 } }, { postgres: `"jsonAttr"->'a'')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- ' = '1'`, mysql: `json_extract(\`jsonAttr\`,'$."a\\')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- "') = CAST('1' AS JSON)`, sqlite: `json_extract(\`jsonAttr\`,'$."a'')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- "') = '1'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$."a\\')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- "')) = '1'`, }); testSql({ 'jsonAttr[0].nested.attribute': 4 }, { postgres: `"jsonAttr"#>ARRAY['0','nested','attribute'] = '4'`, // these tests cannot be deduplicated because [0] will be replaced by `0` by expectsql sqlite: `json_extract(\`jsonAttr\`,'$[0].nested.attribute') = '4'`, mariadb: `json_compact(json_extract(\`jsonAttr\`,'$[0].nested.attribute')) = '4'`, mysql: `json_extract(\`jsonAttr\`,'$[0].nested.attribute') = CAST('4' AS JSON)`, }); // aliases attribute -> column correctly testSql({ 'aliasedJsonAttr.nested.attribute': 4 }, { postgres: `"aliased_json"#>ARRAY['nested','attribute'] = '4'`, sqlite: `json_extract(\`aliased_json\`,'$.nested.attribute') = '4'`, mariadb: `json_compact(json_extract(\`aliased_json\`,'$.nested.attribute')) = '4'`, mysql: `json_extract(\`aliased_json\`,'$.nested.attribute') = CAST('4' AS JSON)`, }); testSql({ 'jsonAttr:unquote': 0 }, { postgres: `"jsonAttr"#>>ARRAY[]::TEXT[] = 0`, 'sqlite mysql mariadb': `json_unquote([jsonAttr]) = 0`, }); testSql({ 'jsonAttr.key:unquote': 0 }, { postgres: `"jsonAttr"->>'key' = 0`, 'sqlite mysql mariadb': `json_unquote(json_extract([jsonAttr],'$.key')) = 0`, }); testSql({ 'jsonAttr.nested.key:unquote': 0 }, { postgres: `"jsonAttr"#>>ARRAY['nested','key'] = 0`, 'sqlite mysql mariadb': `json_unquote(json_extract([jsonAttr],'$.nested.key')) = 0`, }); testSql({ 'jsonAttr[0]:unquote': 0 }, { postgres: `"jsonAttr"->>0 = 0`, // must be separate because [0] will be replaced by `0` by expectsql sqlite: `json_unquote(json_extract(\`jsonAttr\`,'$[0]')) = 0`, mysql: `json_unquote(json_extract(\`jsonAttr\`,'$[0]')) = 0`, mariadb: `json_unquote(json_extract(\`jsonAttr\`,'$[0]')) = 0`, }); }); } if (dialectSupportsJsonB()) { describe('JSONB', () => { testSql({ jsonbAttr: { [Op.anyKeyExists]: ['a', 'b'], }, }, { default: `[jsonbAttr] ?| ARRAY['a','b']`, }); testSql({ jsonbAttr: { [Op.allKeysExist]: ['a', 'b'], }, }, { default: `[jsonbAttr] ?& ARRAY['a','b']`, }); testSql({ jsonbAttr: { [Op.anyKeyExists]: literal(`ARRAY(SELECT jsonb_array_elements_text('ARRAY["a","b"]'))`), }, }, { default: `[jsonbAttr] ?| ARRAY(SELECT jsonb_array_elements_text('ARRAY["a","b"]'))`, }); testSql({ jsonbAttr: { [Op.allKeysExist]: literal(`ARRAY(SELECT jsonb_array_elements_text('ARRAY["a","b"]'))`), }, }, { default: `[jsonbAttr] ?& ARRAY(SELECT jsonb_array_elements_text('ARRAY["a","b"]'))`, }); testSql({ jsonbAttr: { [Op.anyKeyExists]: col('label'), }, }, { default: `[jsonbAttr] ?| "label"`, }); testSql({ jsonbAttr: { [Op.allKeysExist]: col('labels'), }, }, { default: `[jsonbAttr] ?& "labels"`, }); testSql({ jsonbAttr: { [Op.anyKeyExists]: cast(col('labels'), 'STRING[]'), }, }, { default: `[jsonbAttr] ?| CAST("labels" AS STRING[])`, }); testSql({ jsonbAttr: { [Op.allKeysExist]: cast(col('labels'), 'STRING[]'), }, }, { default: `[jsonbAttr] ?& CAST("labels" AS STRING[])`, }); testSql({ jsonbAttr: { [Op.anyKeyExists]: [], }, }, { default: `[jsonbAttr] ?| ARRAY[]::TEXT[]`, }); testSql({ jsonbAttr: { [Op.allKeysExist]: [], }, }, { default: `[jsonbAttr] ?& ARRAY[]::TEXT[]`, }); testSql({ jsonbAttr: { [Op.anyKeyExists]: fn('get_label'), }, }, { default: `[jsonbAttr] ?| get_label()`, }); testSql({ jsonbAttr: { [Op.allKeysExist]: fn('get_labels'), }, }, { default: `[jsonbAttr] ?& get_labels()`, }); // @ts-expect-error -- typings for `json` are broken, but `json()` is deprecated testSql({ id: { [Op.eq]: json('profile.id') } }, { default: `"id" = "profile"->'id'`, }); // @ts-expect-error -- typings for `json` are broken, but `json()` is deprecated testSql(json('profile.id', cast('12346-78912', 'text')), { postgres: `"User"."profile"->'id' = CAST('12346-78912' AS TEXT)`, }, { mainAlias: 'User', }); testSql(json({ profile: { id: '12346-78912', name: 'test' } }), { postgres: `"User"."profile"->'id' = '"12346-78912"' AND "User"."profile"->'name' = '"test"'`, }, { mainAlias: 'User', }); testSql({ jsonbAttr: { nested: { attribute: 'value', }, }, }, { postgres: `"User"."jsonbAttr"#>ARRAY['nested','attribute'] = '"value"'`, }, { mainAlias: 'User', }); testSql({ jsonbAttr: { nested: { [Op.in]: [1, 2], }, }, }, { postgres: `"jsonbAttr"->'nested' IN ('1', '2')`, }); testSql({ 'jsonbAttr.nested.attribute': { [Op.in]: [3, 7], }, }, { postgres: `"jsonbAttr"#>ARRAY['nested','attribute'] IN ('3', '7')`, }); testSql({ jsonbAttr: { nested: { [Op.between]: [1, 2], }, }, }, { postgres: `"jsonbAttr"->'nested' BETWEEN '1' AND '2'`, }); testSql({ jsonbAttr: { price: 5, name: 'Product', }, }, { postgres: `"jsonbAttr"->'price' = '5' AND "jsonbAttr"->'name' = '"Product"'`, }); testSql({ jsonbAttr: { name: { last: 'Simpson', }, employment: { [Op.ne]: 'None', }, }, }, { postgres: `"User"."jsonbAttr"#>ARRAY['name','last'] = '"Simpson"' AND "User"."jsonbAttr"->'employment' != '"None"'`, }, { mainAlias: 'User', }); const dt = new Date(); const jsonDt = JSON.stringify(dt); testSql({ jsonbAttr: { nested: { attribute: { [Op.gt]: dt, }, }, }, }, { postgres: `"jsonbAttr"#>ARRAY['nested','attribute'] > ${queryGen.escape(jsonDt)}`, }); testSql({ jsonbAttr: { nested: { attribute: true, }, }, }, { postgres: `"jsonbAttr"#>ARRAY['nested','attribute'] = 'true'`, }); testSql({ jsonbAttr: { [Op.contains]: { company: 'Magnafone' }, }, }, { default: `[jsonbAttr] @> '{"company":"Magnafone"}'`, }); // aliases correctly testSql({ aliasedJsonbAttr: { key: 'value' } }, { postgres: `"aliased_jsonb"->'key' = '"value"'`, }); }); } testSql({ stringAttr: 'a project', [Op.or]: [ { intAttr1: [1, 2, 3] }, { intAttr1: { [Op.gt]: 10 } }, ], }, { default: '([intAttr1] IN (1, 2, 3) OR [intAttr1] > 10) AND [stringAttr] = \'a project\'', mssql: '([intAttr1] IN (1, 2, 3) OR [intAttr1] > 10) AND [stringAttr] = N\'a project\'', }); describe('Op.and', () => { it('and() is the same as Op.and', () => { expect(util.inspect(and('a', 'b'))).to.deep.equal(util.inspect({ [Op.and]: ['a', 'b'] })); }); testSql(and([]), { default: '', }); testSql(and({}), { default: '', }); // by default: it already is Op.and testSql({ intAttr1: 1, intAttr2: 2 }, { default: `[intAttr1] = 1 AND [intAttr2] = 2`, }); // top-level array is Op.and testSql([{ intAttr1: 1 }, { intAttr1: 2 }], { default: `[intAttr1] = 1 AND [intAttr1] = 2`, }); // $intAttr1$ doesn't override intAttr1 testSql({ intAttr1: 1, $intAttr1$: 2 }, { default: `[intAttr1] = 1 AND [intAttr1] = 2`, }); // can pass a simple object testSql({ [Op.and]: { intAttr1: 1, intAttr2: 2 } }, { default: `[intAttr1] = 1 AND [intAttr2] = 2`, }); // can pass an array testSql({ [Op.and]: [{ intAttr1: 1, intAttr2: 2 }, { stringAttr: '' }] }, { default: `([intAttr1] = 1 AND [intAttr2] = 2) AND [stringAttr] = ''`, mssql: `([intAttr1] = 1 AND [intAttr2] = 2) AND [stringAttr] = N''`, }); // can be used on attribute testSql({ intAttr1: { [Op.and]: [1, { [Op.gt]: 1 }] } }, { default: `[intAttr1] = 1 AND [intAttr1] > 1`, }); // @ts-expect-error -- cannot be used after operator testSql({ intAttr1: { [Op.gt]: { [Op.and]: [1, 2] } } }, { default: new Error(`{ [Symbol(and)]: [ 1, 2 ] } is not a valid integer`), }); }); describe('Op.or', () => { it('or() is the same as Op.or', () => { expect(util.inspect(or('a', 'b'))).to.deep.equal(util.inspect({ [Op.or]: ['a', 'b'] })); }); testSql(or([]), { default: '', }); testSql(or({}), { default: '', }); // can pass a simple object testSql({ [Op.or]: { intAttr1: 1, intAttr2: 2 } }, { default: `[intAttr1] = 1 OR [intAttr2] = 2`, }); // can pass an array testSql({ [Op.or]: [{ intAttr1: 1, intAttr2: 2 }, { stringAttr: '' }] }, { default: `([intAttr1] = 1 AND [intAttr2] = 2) OR [stringAttr] = ''`, mssql: `([intAttr1] = 1 AND [intAttr2] = 2) OR [stringAttr] = N''`, }); // can be used on attribute testSql({ intAttr1: { [Op.or]: [1, { [Op.gt]: 1 }] } }, { default: `[intAttr1] = 1 OR [intAttr1] > 1`, }); // @ts-expect-error -- cannot be used after operator testSql({ intAttr1: { [Op.gt]: { [Op.or]: [1, 2] } } }, { default: new Error(`{ [Symbol(or)]: [ 1, 2 ] } is not a valid integer`), }); testSql({ [Op.or]: { intAttr1: [1, 3], intAttr2: { [Op.in]: [2, 4], }, }, }, { default: '[intAttr1] IN (1, 3) OR [intAttr2] IN (2, 4)', }); }); describe('Op.{and,or,not} combinations', () => { // both can be used in the same object testSql({ [Op.and]: { intAttr1: 1, intAttr2: 2 }, [Op.or]: { intAttr1: 1, intAttr2: 2 }, }, { default: `([intAttr1] = 1 AND [intAttr2] = 2) AND ([intAttr1] = 1 OR [intAttr2] = 2)`, }); // Op.or only applies to its direct Array, the nested array is still Op.and testSql({ [Op.or]: [ [{ intAttr1: 1 }, { intAttr1: 2 }], { intAttr1: 3 }, ], }, { default: '([intAttr1] = 1 AND [intAttr1] = 2) OR [intAttr1] = 3', }); // can be nested *after* attribute testSql({ intAttr1: { [Op.and]: [ 1, 2, { [Op.or]: [3, 4] }, { [Op.not]: 5 }, [6, 7], ], }, }, { default: '[intAttr1] = 1 AND [intAttr1] = 2 AND ([intAttr1] = 3 OR [intAttr1] = 4) AND NOT ([intAttr1] = 5) AND [intAttr1] IN (6, 7)', }); // can be nested testSql({ [Op.not]: { [Op.and]: { [Op.or]: { [Op.and]: { intAttr1: 1, intAttr2: 2, }, }, }, }, }, { default: 'NOT ([intAttr1] = 1 AND [intAttr2] = 2)', }); // Op.not, Op.and, Op.or can reside on the same object as attributes testSql({ intAttr1: 1, [Op.not]: { intAttr1: { [Op.eq]: 2 }, [Op.and]: { intAttr1: 3, [Op.or]: { intAttr1: 4, [Op.and]: { intAttr1: 5, intAttr2: 6, }, }, }, }, }, { default: '(NOT (((([intAttr1] = 5 AND [intAttr2] = 6) OR [intAttr1] = 4) AND [intAttr1] = 3) AND [intAttr1] = 2)) AND [intAttr1] = 1', }); }); describe('where()', () => { { // @ts-expect-error -- 'intAttr1' is not a boolean and cannot be compared to the output of 'where' const ignore: TestModelWhere = { intAttr1: where(fn('lower', col('name')), null) }; } testSql({ booleanAttr: where(fn('lower', col('name')), null) }, { default: `[booleanAttr] = (lower([name]) IS NULL)`, }); testSql({ booleanAttr: where(fn('lower', col('name')), null) }, { default: `[booleanAttr] = (lower([name]) IS NULL)`, }); describe('where(leftOperand, operator, rightOperand)', () => { testSql(where(col('name'), Op.eq, fn('NOW')), { default: '[name] = NOW()', }); // some dialects support having a filter inside aggregate functions: // https://github.com/sequelize/sequelize/issues/6666 testSql(where(fn('sum', { id: 1 }), Op.eq, 1), { default: 'sum([id] = 1) = 1', }); // some dialects support having a filter inside aggregate functions, but require casting: // https://github.com/sequelize/sequelize/issues/6666 testSql(where(fn('sum', cast({ id: 1 }, 'int')), Op.eq, 1), { default: 'sum(CAST(([id] = 1) AS INT)) = 1', }); // comparing the output of `where` to `where` testSql( where( where(col('col'), Op.eq, '1'), Op.eq, where(col('col'), Op.eq, '2'), ), { default: `([col] = '1') = ([col] = '2')`, mssql: `([col] = N'1') = ([col] = N'2')`, }, ); testSql( where(1, Op.eq, 2), { default: '1 = 2', }, ); testSql(where(1, Op.eq, col('col')), { default: '1 = [col]', }); testSql(where('string', Op.eq, col('col')), { default: `'string' = [col]`, mssql: `N'string' = [col]`, }); testSql( where('a', Op.eq, 'b'), { default: `'a' = 'b'`, mssql: `N'a' = N'b'`, }, ); it('does not allow string operators', () => { // @ts-expect-error -- testing that this errors expect(() => where(fn('SUM', col('hours')), '>', 0)).to.throw('where(left, operator, right) does not accept a string as the operator'); }); testSql(where(fn('SUM', col('hours')), Op.gt, 0), { default: 'SUM([hours]) > 0', }); testSql(where(fn('lower', col('name')), Op.ne, null), { default: 'lower([name]) IS NOT NULL', }); // @ts-expect-error -- While these are supported for backwards compatibility, they are not documented. Users should use isNot testSql(where(fn('lower', col('name')), Op.not, null), { default: 'NOT (lower([name]) IS NULL)', }); testSql(where(fn('lower', col('name')), Op.isNot, null), { default: 'lower([name]) IS NOT NULL', }); testSql(where(col('hours'), Op.between, [0, 5]), { default: '[hours] BETWEEN 0 AND 5', }); testSql(where(col('hours'), Op.notBetween, [0, 5]), { default: '[hours] NOT BETWEEN 0 AND 5', }); testSql(where({ [Op.col]: 'hours' }, Op.notBetween, [0, 5]), { default: '[hours] NOT BETWEEN 0 AND 5', }); testSql(where(cast({ [Op.col]: 'hours' }, 'integer'), Op.notBetween, [0, 5]), { default: 'CAST([hours] AS INTEGER) NOT BETWEEN 0 AND 5', }); testSql(where(fn('SUM', { [Op.col]: 'hours' }), Op.notBetween, [0, 5]), { default: 'SUM([hours]) NOT BETWEEN 0 AND 5', }); testSql(where(literal(`'hours'`), Op.eq, 'hours'), { default: `'hours' = 'hours'`, mssql: `'hours' = N'hours'`, }); testSql(where(col('col'), Op.eq, { [Op.in]: [1, 2] }), { default: new Error('Could not guess type of value { [Symbol(in)]: [ 1, 2 ] }') }); }); describe('where(leftOperand, whereAttributeHashValue)', () => { testSql(where(fn('lower', col('name')), null), { default: 'lower([name]) IS NULL', }); testSql(where(cast(col('name'), 'int'), { [Op.eq]: 10 }), { default: 'CAST([name] AS INT) = 10', }); testSql(where(literal('abc'), { [Op.eq]: 10 }), { default: 'abc = 10', }); testSql( where(col('name'), { [Op.eq]: '123', [Op.not]: { [Op.eq]: '456' } }), { default: `[name] = '123' AND NOT ([name] = '456')`, mssql: `[name] = N'123' AND NOT ([name] = N'456')`, }, ); testSql( where(col('name'), or({ [Op.eq]: '123', [Op.not]: { [Op.eq]: '456' } })), { default: `[name] = '123' OR NOT ([name] = '456')`, mssql: `[name] = N'123' OR NOT ([name] = N'456')` }, ); testSql( // Note: using `col()`, the following is not treated as a json.path. // (yes, it's inconsistant with regular attribute notation. attr could be a good replacement) where(col('attribute.path'), 10), { default: '[attribute].[path] = 10', }, ); testSql( // Note: using `col()`, the following is not treated as a nested.attribute.path. // (yes, it's inconsistant with regular attribute notation. attr could be a good replacement) where(col('$attribute.path$'), 10), { default: '[$attribute].[path$] = 10', }, ); testSql(where(col('col'), { [Op.and]: [1, 2] }), { default: '[col] = 1 AND [col] = 2', }); if (dialectSupportsJsonOperations()) { testSql(where(col('col'), { jsonPath: 'value' }), { postgres: `"col"->'jsonPath' = '"value"'`, sqlite: `json_extract(\`col\`,'$.jsonPath') = '"value"'`, mariadb: `json_compact(json_extract(\`col\`,'$.jsonPath')) = '"value"'`, mysql: `json_extract(\`col\`,'$.jsonPath') = CAST('"value"' AS JSON)`, }); } }); }); }); });