From 67ae0d854df7967c5e2520d70455317e36c4b95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sun, 6 Jul 2025 11:57:39 -0300 Subject: [PATCH 01/14] WIP: query builder --- CHANGELOG.md | 21 -- packages/core/CHANGELOG.md | 20 -- .../src/expression-builders/query-builder.ts | 127 ++++++++ packages/core/src/model.d.ts | 17 ++ packages/core/src/model.js | 19 ++ .../dialects/mariadb/query-builder.test.js | 281 +++++++++++++++++ .../dialects/mssql/query-builder.test.js | 287 ++++++++++++++++++ .../dialects/mysql/query-builder.test.js | 281 +++++++++++++++++ .../dialects/postgres/query-builder.test.js | 281 +++++++++++++++++ .../dialects/sqlite/query-builder.test.js | 287 ++++++++++++++++++ packages/utils/CHANGELOG.md | 16 - packages/validator-js/CHANGELOG.md | 8 - 12 files changed, 1580 insertions(+), 65 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 packages/core/CHANGELOG.md create mode 100644 packages/core/src/expression-builders/query-builder.ts create mode 100644 packages/core/test/integration/dialects/mariadb/query-builder.test.js create mode 100644 packages/core/test/integration/dialects/mssql/query-builder.test.js create mode 100644 packages/core/test/integration/dialects/mysql/query-builder.test.js create mode 100644 packages/core/test/integration/dialects/postgres/query-builder.test.js create mode 100644 packages/core/test/integration/dialects/sqlite/query-builder.test.js delete mode 100644 packages/utils/CHANGELOG.md delete mode 100644 packages/validator-js/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index b9f7b9b8f41f..000000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,21 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.0.0-alpha.46](https://github.com/sequelize/sequelize/compare/v7.0.0-alpha.45...v7.0.0-alpha.46) (2025-03-22) - -### Bug Fixes - -- **cli:** remove redundant types export in package.json ([#17781](https://github.com/sequelize/sequelize/issues/17781)) ([2391263](https://github.com/sequelize/sequelize/commit/2391263eaa09ae8c3fe1ce624b3f696ccfae8501)) -- **core:** fix issues with composite PK in `findByPk` ([#17747](https://github.com/sequelize/sequelize/issues/17747)) ([dd587cb](https://github.com/sequelize/sequelize/commit/dd587cb86a1b636cdc9cc490c9325e2f6e7640a8)) -- **core:** fix msg of error thrown when decorating a non-model ([#17745](https://github.com/sequelize/sequelize/issues/17745)) ([c43c270](https://github.com/sequelize/sequelize/commit/c43c2708d75535edd0fd78e990884a3e38f2fb0d)) -- **core:** proper check upsert support in query-interface ([#17358](https://github.com/sequelize/sequelize/issues/17358)) ([68d7d75](https://github.com/sequelize/sequelize/commit/68d7d758671e0f80bafd68c6980be9dc818683fd)) -- **postgres:** correct existing enum type matching ([#17576](https://github.com/sequelize/sequelize/issues/17576)) ([425d217](https://github.com/sequelize/sequelize/commit/425d21718af40f86015f6496ea6cf721cc61b981)) -- **postgres:** update to postgres 17 ([#17740](https://github.com/sequelize/sequelize/issues/17740)) ([b5c2b26](https://github.com/sequelize/sequelize/commit/b5c2b2667004b3b27e5634c677507f5593987938)) -- update typescript to v5.8.2 ([#17728](https://github.com/sequelize/sequelize/issues/17728)) ([6c5a82d](https://github.com/sequelize/sequelize/commit/6c5a82dbc82ec45bbe85112c51e1b496f3f7dbaa)) - -### Features - -- **core:** add `sql.join` & improve `sql.identifier` ([#17744](https://github.com/sequelize/sequelize/issues/17744)) ([e914861](https://github.com/sequelize/sequelize/commit/e914861c084ef0ed8f12ca7b59be4965326e9641)) -- **core:** count grouped rows ([#17751](https://github.com/sequelize/sequelize/issues/17751)) ([a396673](https://github.com/sequelize/sequelize/commit/a396673b4edad0d3d3379111a3b1cbf3695d22cc)) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md deleted file mode 100644 index dc829955d269..000000000000 --- a/packages/core/CHANGELOG.md +++ /dev/null @@ -1,20 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.0.0-alpha.46](https://github.com/sequelize/sequelize/compare/v7.0.0-alpha.45...v7.0.0-alpha.46) (2025-03-22) - -### Bug Fixes - -- **core:** fix issues with composite PK in `findByPk` ([#17747](https://github.com/sequelize/sequelize/issues/17747)) ([dd587cb](https://github.com/sequelize/sequelize/commit/dd587cb86a1b636cdc9cc490c9325e2f6e7640a8)) -- **core:** fix msg of error thrown when decorating a non-model ([#17745](https://github.com/sequelize/sequelize/issues/17745)) ([c43c270](https://github.com/sequelize/sequelize/commit/c43c2708d75535edd0fd78e990884a3e38f2fb0d)) -- **core:** proper check upsert support in query-interface ([#17358](https://github.com/sequelize/sequelize/issues/17358)) ([68d7d75](https://github.com/sequelize/sequelize/commit/68d7d758671e0f80bafd68c6980be9dc818683fd)) -- **postgres:** correct existing enum type matching ([#17576](https://github.com/sequelize/sequelize/issues/17576)) ([425d217](https://github.com/sequelize/sequelize/commit/425d21718af40f86015f6496ea6cf721cc61b981)) -- **postgres:** update to postgres 17 ([#17740](https://github.com/sequelize/sequelize/issues/17740)) ([b5c2b26](https://github.com/sequelize/sequelize/commit/b5c2b2667004b3b27e5634c677507f5593987938)) -- update typescript to v5.8.2 ([#17728](https://github.com/sequelize/sequelize/issues/17728)) ([6c5a82d](https://github.com/sequelize/sequelize/commit/6c5a82dbc82ec45bbe85112c51e1b496f3f7dbaa)) - -### Features - -- **core:** add `sql.join` & improve `sql.identifier` ([#17744](https://github.com/sequelize/sequelize/issues/17744)) ([e914861](https://github.com/sequelize/sequelize/commit/e914861c084ef0ed8f12ca7b59be4965326e9641)) -- **core:** count grouped rows ([#17751](https://github.com/sequelize/sequelize/issues/17751)) ([a396673](https://github.com/sequelize/sequelize/commit/a396673b4edad0d3d3379111a3b1cbf3695d22cc)) diff --git a/packages/core/src/expression-builders/query-builder.ts b/packages/core/src/expression-builders/query-builder.ts new file mode 100644 index 000000000000..aa2787998ffb --- /dev/null +++ b/packages/core/src/expression-builders/query-builder.ts @@ -0,0 +1,127 @@ +import type { WhereOptions } from '../abstract-dialect/where-sql-builder-types.js'; +import type { FindAttributeOptions, Model, ModelStatic } from '../model.d.ts'; +import { BaseSqlExpression, SQL_IDENTIFIER } from './base-sql-expression.js'; + +/** + * Do not use me directly. Use Model.select() instead. + */ +export class QueryBuilder extends BaseSqlExpression { + declare protected readonly [SQL_IDENTIFIER]: 'queryBuilder'; + + private readonly _model: ModelStatic; + private _attributes?: FindAttributeOptions; + private _where?: WhereOptions; + private _isSelect: boolean = false; + + constructor(model: ModelStatic) { + super(); + this._model = model; + } + + /** + * Initialize a SELECT query + * + * @returns The query builder instance for chaining + */ + select(): QueryBuilder { + const newBuilder = new QueryBuilder(this._model); + newBuilder._isSelect = true; + if (this._attributes !== undefined) { + newBuilder._attributes = this._attributes; + } + + if (this._where !== undefined) { + newBuilder._where = this._where; + } + + return newBuilder; + } + + /** + * Specify which attributes to select + * + * @param attributes - Array of attribute names or attribute options + * @returns The query builder instance for chaining + */ + attributes(attributes: FindAttributeOptions): QueryBuilder { + const newBuilder = new QueryBuilder(this._model); + newBuilder._isSelect = this._isSelect; + newBuilder._attributes = attributes; + newBuilder._where = this._where; + + return newBuilder; + } + + /** + * Add WHERE conditions to the query + * + * @param conditions - Where conditions object + * @returns The query builder instance for chaining + */ + where(conditions: WhereOptions): QueryBuilder { + const newBuilder = new QueryBuilder(this._model); + newBuilder._isSelect = this._isSelect; + if (this._attributes !== undefined) { + newBuilder._attributes = this._attributes; + } + + newBuilder._where = conditions; + + return newBuilder; + } + + /** + * Generate the SQL query string + * + * @returns The SQL query + */ + getQuery(): string { + if (!this._isSelect) { + throw new Error('Query builder requires select() to be called first'); + } + + const queryGenerator = this._model.queryGenerator; + const tableName = this._model.tableName; + + // Build the options object that matches Sequelize's FindOptions pattern + const options: any = { + attributes: this._attributes, + where: this._where, + raw: true, + plain: false, + }; + + // Generate the SQL using the existing query generator + const sql = queryGenerator.selectQuery(tableName, options, this._model); + + return sql; + } + + /** + * Get the table name for this query + * + * @returns The table name + */ + get tableName(): string { + return this._model.tableName; + } + + /** + * Get the model class + * + * @returns The model class + */ + get model(): ModelStatic { + return this._model; + } +} + +/** + * Creates a new QueryBuilder instance for the given model + * + * @param model - The model class + * @returns A new query builder instance + */ +export function createQueryBuilder(model: ModelStatic): QueryBuilder { + return new QueryBuilder(model); +} diff --git a/packages/core/src/model.d.ts b/packages/core/src/model.d.ts index 27aba242f217..4e9f388fb9ee 100644 --- a/packages/core/src/model.d.ts +++ b/packages/core/src/model.d.ts @@ -26,6 +26,7 @@ import type { Cast } from './expression-builders/cast.js'; import type { Col } from './expression-builders/col.js'; import type { Fn } from './expression-builders/fn.js'; import type { Literal } from './expression-builders/literal.js'; +import type { QueryBuilder } from './expression-builders/query-builder.js'; import type { Where } from './expression-builders/where.js'; import type { Lock, Op, TableHints, Transaction, WhereOptions } from './index'; import type { IndexHints } from './index-hints'; @@ -2395,6 +2396,22 @@ export abstract class Model< options?: AddScopeOptions, ): void; + /** + * Creates a new QueryBuilder instance for this model. + * This enables functional/chainable query building. + * + * @returns A new QueryBuilder instance for this model + * + * @example + * ```js + * const query = User.select() + * .attributes(['name', 'email']) + * .where({ active: true }) + * .getQuery(); + * ``` + */ + static select(this: ModelStatic): QueryBuilder; + /** * Search for multiple instances. * See {@link https://sequelize.org/docs/v7/core-concepts/model-querying-basics/} for more information about querying. diff --git a/packages/core/src/model.js b/packages/core/src/model.js index f279df34a402..188b6e105a17 100644 --- a/packages/core/src/model.js +++ b/packages/core/src/model.js @@ -40,6 +40,7 @@ import { AssociationSecret } from './associations/helpers'; import * as DataTypes from './data-types'; import * as SequelizeErrors from './errors'; import { BaseSqlExpression } from './expression-builders/base-sql-expression.js'; +import { QueryBuilder } from './expression-builders/query-builder.js'; import { InstanceValidator } from './instance-validator'; import { _validateIncludedElements, @@ -4626,6 +4627,24 @@ Instead of specifying a Model, either: static belongsTo(target, options) { return BelongsToAssociation.associate(AssociationSecret, this, target, options); } + + /** + * Creates a new QueryBuilder instance for this model. + * This enables functional/chainable query building. + * + * @returns {QueryBuilder} A new QueryBuilder instance for this model + * + * @example + * ```js + * const query = User.select() + * .attributes(['name', 'email']) + * .where({ active: true }) + * .getQuery(); + * ``` + */ + static select() { + return new QueryBuilder(this).select(); + } } /** diff --git a/packages/core/test/integration/dialects/mariadb/query-builder.test.js b/packages/core/test/integration/dialects/mariadb/query-builder.test.js new file mode 100644 index 000000000000..46132839ee9d --- /dev/null +++ b/packages/core/test/integration/dialects/mariadb/query-builder.test.js @@ -0,0 +1,281 @@ +'use strict'; + +const chai = require('chai'); + +const expect = chai.expect; +const Support = require('../../support'); +const { DataTypes, Op } = require('@sequelize/core'); + +const dialect = Support.getTestDialect(); + +if (dialect.startsWith('mariadb')) { + describe('[MARIADB] QueryBuilder', () => { + let sequelize; + let User; + let Post; + + beforeEach(async () => { + sequelize = Support.createSingleTestSequelizeInstance(); + + User = sequelize.define( + 'User', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + age: { + type: DataTypes.INTEGER, + }, + }, + { + tableName: 'users', + }, + ); + + Post = sequelize.define( + 'Post', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + content: { + type: DataTypes.TEXT, + }, + published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + }, + { + tableName: 'posts', + }, + ); + + User.hasMany(Post, { foreignKey: 'userId' }); + Post.belongsTo(User, { foreignKey: 'userId' }); + }); + + afterEach(() => { + return sequelize?.close(); + }); + + describe('Basic QueryBuilder functionality', () => { + it('should generate basic SELECT query', () => { + const query = User.select().getQuery(); + expect(query).to.equal('SELECT * FROM `users` AS `User`;'); + }); + + it('should generate SELECT query with specific attributes', () => { + const query = User.select().attributes(['name', 'email']).getQuery(); + expect(query).to.equal('SELECT `name`, `email` FROM `users` AS `User`;'); + }); + + it('should generate SELECT query with WHERE clause', () => { + const query = User.select().where({ active: true }).getQuery(); + expect(query).to.equal('SELECT * FROM `users` AS `User` WHERE `User`.`active` = true;'); + }); + + it('should generate SELECT query with multiple WHERE conditions', () => { + const query = User.select().where({ active: true, age: 25 }).getQuery(); + expect(query).to.equal( + 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = true AND `User`.`age` = 25;', + ); + }); + + it('should generate complete SELECT query with attributes and WHERE', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + expect(query).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = true;', + ); + }); + }); + + describe('Functional/Immutable behavior', () => { + it('should return new instances for each method call', () => { + const builder1 = User.select(); + const builder2 = builder1.attributes(['name']); + const builder3 = builder2.where({ active: true }); + + expect(builder1).to.not.equal(builder2); + expect(builder2).to.not.equal(builder3); + expect(builder1).to.not.equal(builder3); + }); + + it('should not mutate original builder when chaining', () => { + const baseBuilder = User.select(); + const builderWithAttributes = baseBuilder.attributes(['name']); + const builderWithWhere = baseBuilder.where({ active: true }); + + // Base builder should remain unchanged + const baseQuery = baseBuilder.getQuery(); + expect(baseQuery).to.equal('SELECT * FROM `users` AS `User`;'); + + // Other builders should have their modifications + const attributesQuery = builderWithAttributes.getQuery(); + expect(attributesQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); + + const whereQuery = builderWithWhere.getQuery(); + expect(whereQuery).to.equal( + 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = true;', + ); + }); + + it('should allow building different queries from same base', () => { + const baseBuilder = User.select().attributes(['name', 'email']); + + const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); + const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); + + expect(activeUsersQuery).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = true;', + ); + expect(youngUsersQuery).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`age` < 30;', + ); + }); + }); + + describe('MariaDB-specific features', () => { + it('should handle MariaDB operators correctly', () => { + const query = User.select() + .where({ + name: { [Op.like]: '%john%' }, + age: { [Op.between]: [18, 65] }, + }) + .getQuery(); + expect(query).to.include('LIKE'); + expect(query).to.include('BETWEEN'); + }); + + it('should handle array operations', () => { + const query = User.select() + .where({ + name: { [Op.in]: ['John', 'Jane', 'Bob'] }, + }) + .getQuery(); + expect(query).to.include('IN'); + expect(query).to.include("'John'"); + expect(query).to.include("'Jane'"); + expect(query).to.include("'Bob'"); + }); + + it('should quote identifiers properly for MariaDB', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + + // MariaDB uses backticks for identifiers (like MySQL) + expect(query).to.include('`name`'); + expect(query).to.include('`email`'); + expect(query).to.include('`users`'); + expect(query).to.include('`User`'); + expect(query).to.include('`User`.`active`'); + }); + }); + + describe('Error handling', () => { + it('should throw error when getQuery is called on non-select builder', () => { + expect(() => { + const builder = new (User.select().constructor)(User); + builder.getQuery(); + }).to.throw(); + }); + + it('should handle empty attributes array', () => { + expect(() => { + User.select().attributes([]).getQuery(); + }).to.throw( + "Attempted a SELECT query for model 'User' as `User` without selecting any columns", + ); + }); + + it('should handle null/undefined where conditions gracefully', () => { + // null where should throw an error as it's invalid + expect(() => { + User.select().where(null).getQuery(); + }).to.throw(); + + // undefined where should work (no where clause) + expect(() => { + User.select().where(undefined).getQuery(); + }).to.not.throw(); + }); + }); + + describe('Integration with different models', () => { + it('should work with different models', () => { + const userQuery = User.select().attributes(['name']).getQuery(); + const postQuery = Post.select().attributes(['title']).getQuery(); + + expect(userQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); + expect(postQuery).to.equal('SELECT `title` FROM `posts` AS `Post`;'); + }); + + it('should use correct table names and aliases', () => { + const query = User.select().getQuery(); + expect(query).to.include('`users`'); + expect(query).to.include('AS `User`'); + + const postQuery = Post.select().getQuery(); + expect(postQuery).to.include('`posts`'); + expect(postQuery).to.include('AS `Post`'); + }); + }); + + describe('Complex WHERE conditions', () => { + it('should handle complex nested conditions', () => { + const query = User.select() + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], + }, + ], + }) + .getQuery(); + + expect(query).to.include('OR'); + expect(query).to.include('AND'); + expect(query).to.be.a('string'); + }); + + it('should handle IS NULL conditions', () => { + const query = User.select().where({ age: null }).getQuery(); + expect(query).to.include('IS NULL'); + }); + + it('should handle NOT NULL conditions', () => { + const query = User.select() + .where({ age: { [Op.ne]: null } }) + .getQuery(); + expect(query).to.include('IS NOT NULL'); + }); + }); + }); +} diff --git a/packages/core/test/integration/dialects/mssql/query-builder.test.js b/packages/core/test/integration/dialects/mssql/query-builder.test.js new file mode 100644 index 000000000000..974abfe81e51 --- /dev/null +++ b/packages/core/test/integration/dialects/mssql/query-builder.test.js @@ -0,0 +1,287 @@ +'use strict'; + +const chai = require('chai'); + +const expect = chai.expect; +const Support = require('../../support'); +const { DataTypes, Op } = require('@sequelize/core'); + +const dialect = Support.getTestDialect(); + +if (dialect.startsWith('mssql')) { + describe('[MSSQL] QueryBuilder', () => { + let sequelize; + let User; + let Post; + + beforeEach(async () => { + sequelize = Support.createSingleTestSequelizeInstance(); + + User = sequelize.define( + 'User', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + age: { + type: DataTypes.INTEGER, + }, + }, + { + tableName: 'users', + }, + ); + + Post = sequelize.define( + 'Post', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + content: { + type: DataTypes.TEXT, + }, + published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + }, + { + tableName: 'posts', + }, + ); + + User.hasMany(Post, { foreignKey: 'userId' }); + Post.belongsTo(User, { foreignKey: 'userId' }); + }); + + afterEach(() => { + return sequelize?.close(); + }); + + describe('Basic QueryBuilder functionality', () => { + it('should generate basic SELECT query', () => { + const query = User.select().getQuery(); + expect(query).to.equal('SELECT * FROM [users] AS [User];'); + }); + + it('should generate SELECT query with specific attributes', () => { + const query = User.select().attributes(['name', 'email']).getQuery(); + expect(query).to.equal('SELECT [name], [email] FROM [users] AS [User];'); + }); + + it('should generate SELECT query with WHERE clause', () => { + const query = User.select().where({ active: true }).getQuery(); + expect(query).to.equal('SELECT * FROM [users] AS [User] WHERE [User].[active] = 1;'); + }); + + it('should generate SELECT query with multiple WHERE conditions', () => { + const query = User.select().where({ active: true, age: 25 }).getQuery(); + expect(query).to.equal( + 'SELECT * FROM [users] AS [User] WHERE [User].[active] = 1 AND [User].[age] = 25;', + ); + }); + + it('should generate complete SELECT query with attributes and WHERE', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + expect(query).to.equal( + 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;', + ); + }); + }); + + describe('Functional/Immutable behavior', () => { + it('should return new instances for each method call', () => { + const builder1 = User.select(); + const builder2 = builder1.attributes(['name']); + const builder3 = builder2.where({ active: true }); + + expect(builder1).to.not.equal(builder2); + expect(builder2).to.not.equal(builder3); + expect(builder1).to.not.equal(builder3); + }); + + it('should not mutate original builder when chaining', () => { + const baseBuilder = User.select(); + const builderWithAttributes = baseBuilder.attributes(['name']); + const builderWithWhere = baseBuilder.where({ active: true }); + + // Base builder should remain unchanged + const baseQuery = baseBuilder.getQuery(); + expect(baseQuery).to.equal('SELECT * FROM [users] AS [User];'); + + // Other builders should have their modifications + const attributesQuery = builderWithAttributes.getQuery(); + expect(attributesQuery).to.equal('SELECT [name] FROM [users] AS [User];'); + + const whereQuery = builderWithWhere.getQuery(); + expect(whereQuery).to.equal('SELECT * FROM [users] AS [User] WHERE [User].[active] = 1;'); + }); + + it('should allow building different queries from same base', () => { + const baseBuilder = User.select().attributes(['name', 'email']); + + const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); + const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); + + expect(activeUsersQuery).to.equal( + 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;', + ); + expect(youngUsersQuery).to.equal( + 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[age] < 30;', + ); + }); + }); + + describe('MSSQL-specific features', () => { + it('should handle MSSQL operators correctly', () => { + const query = User.select() + .where({ + name: { [Op.like]: '%john%' }, + age: { [Op.between]: [18, 65] }, + }) + .getQuery(); + expect(query).to.include('LIKE'); + expect(query).to.include('BETWEEN'); + }); + + it('should handle array operations', () => { + const query = User.select() + .where({ + name: { [Op.in]: ['John', 'Jane', 'Bob'] }, + }) + .getQuery(); + expect(query).to.include('IN'); + expect(query).to.include("N'John'"); + expect(query).to.include("N'Jane'"); + expect(query).to.include("N'Bob'"); + }); + + it('should quote identifiers properly for MSSQL', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + + // MSSQL uses square brackets for identifiers + expect(query).to.include('[name]'); + expect(query).to.include('[email]'); + expect(query).to.include('[users]'); + expect(query).to.include('[User]'); + expect(query).to.include('[User].[active]'); + }); + + it('should handle boolean values as 1/0', () => { + const query = User.select().where({ active: true }).getQuery(); + expect(query).to.include('= 1'); + + const falseQuery = User.select().where({ active: false }).getQuery(); + expect(falseQuery).to.include('= 0'); + }); + }); + + describe('Error handling', () => { + it('should throw error when getQuery is called on non-select builder', () => { + expect(() => { + const builder = new (User.select().constructor)(User); + builder.getQuery(); + }).to.throw(); + }); + + it('should handle empty attributes array', () => { + expect(() => { + User.select().attributes([]).getQuery(); + }).to.throw( + "Attempted a SELECT query for model 'User' as [User] without selecting any columns", + ); + }); + + it('should handle null/undefined where conditions gracefully', () => { + // null where should throw an error as it's invalid + expect(() => { + User.select().where(null).getQuery(); + }).to.throw(); + + // undefined where should work (no where clause) + expect(() => { + User.select().where(undefined).getQuery(); + }).to.not.throw(); + }); + }); + + describe('Integration with different models', () => { + it('should work with different models', () => { + const userQuery = User.select().attributes(['name']).getQuery(); + const postQuery = Post.select().attributes(['title']).getQuery(); + + expect(userQuery).to.equal('SELECT [name] FROM [users] AS [User];'); + expect(postQuery).to.equal('SELECT [title] FROM [posts] AS [Post];'); + }); + + it('should use correct table names and aliases', () => { + const query = User.select().getQuery(); + expect(query).to.include('[users]'); + expect(query).to.include('AS [User]'); + + const postQuery = Post.select().getQuery(); + expect(postQuery).to.include('[posts]'); + expect(postQuery).to.include('AS [Post]'); + }); + }); + + describe('Complex WHERE conditions', () => { + it('should handle complex nested conditions', () => { + const query = User.select() + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], + }, + ], + }) + .getQuery(); + + expect(query).to.include('OR'); + expect(query).to.include('AND'); + expect(query).to.be.a('string'); + }); + + it('should handle IS NULL conditions', () => { + const query = User.select().where({ age: null }).getQuery(); + expect(query).to.include('IS NULL'); + }); + + it('should handle NOT NULL conditions', () => { + const query = User.select() + .where({ age: { [Op.ne]: null } }) + .getQuery(); + expect(query).to.include('IS NOT NULL'); + }); + }); + }); +} diff --git a/packages/core/test/integration/dialects/mysql/query-builder.test.js b/packages/core/test/integration/dialects/mysql/query-builder.test.js new file mode 100644 index 000000000000..1d8b32d67ea5 --- /dev/null +++ b/packages/core/test/integration/dialects/mysql/query-builder.test.js @@ -0,0 +1,281 @@ +'use strict'; + +const chai = require('chai'); + +const expect = chai.expect; +const Support = require('../../support'); +const { DataTypes, Op } = require('@sequelize/core'); + +const dialect = Support.getTestDialect(); + +if (dialect.startsWith('mysql')) { + describe('[MYSQL] QueryBuilder', () => { + let sequelize; + let User; + let Post; + + beforeEach(async () => { + sequelize = Support.createSingleTestSequelizeInstance(); + + User = sequelize.define( + 'User', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + age: { + type: DataTypes.INTEGER, + }, + }, + { + tableName: 'users', + }, + ); + + Post = sequelize.define( + 'Post', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + content: { + type: DataTypes.TEXT, + }, + published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + }, + { + tableName: 'posts', + }, + ); + + User.hasMany(Post, { foreignKey: 'userId' }); + Post.belongsTo(User, { foreignKey: 'userId' }); + }); + + afterEach(() => { + return sequelize?.close(); + }); + + describe('Basic QueryBuilder functionality', () => { + it('should generate basic SELECT query', () => { + const query = User.select().getQuery(); + expect(query).to.equal('SELECT * FROM `users` AS `User`;'); + }); + + it('should generate SELECT query with specific attributes', () => { + const query = User.select().attributes(['name', 'email']).getQuery(); + expect(query).to.equal('SELECT `name`, `email` FROM `users` AS `User`;'); + }); + + it('should generate SELECT query with WHERE clause', () => { + const query = User.select().where({ active: true }).getQuery(); + expect(query).to.equal('SELECT * FROM `users` AS `User` WHERE `User`.`active` = true;'); + }); + + it('should generate SELECT query with multiple WHERE conditions', () => { + const query = User.select().where({ active: true, age: 25 }).getQuery(); + expect(query).to.equal( + 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = true AND `User`.`age` = 25;', + ); + }); + + it('should generate complete SELECT query with attributes and WHERE', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + expect(query).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = true;', + ); + }); + }); + + describe('Functional/Immutable behavior', () => { + it('should return new instances for each method call', () => { + const builder1 = User.select(); + const builder2 = builder1.attributes(['name']); + const builder3 = builder2.where({ active: true }); + + expect(builder1).to.not.equal(builder2); + expect(builder2).to.not.equal(builder3); + expect(builder1).to.not.equal(builder3); + }); + + it('should not mutate original builder when chaining', () => { + const baseBuilder = User.select(); + const builderWithAttributes = baseBuilder.attributes(['name']); + const builderWithWhere = baseBuilder.where({ active: true }); + + // Base builder should remain unchanged + const baseQuery = baseBuilder.getQuery(); + expect(baseQuery).to.equal('SELECT * FROM `users` AS `User`;'); + + // Other builders should have their modifications + const attributesQuery = builderWithAttributes.getQuery(); + expect(attributesQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); + + const whereQuery = builderWithWhere.getQuery(); + expect(whereQuery).to.equal( + 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = true;', + ); + }); + + it('should allow building different queries from same base', () => { + const baseBuilder = User.select().attributes(['name', 'email']); + + const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); + const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); + + expect(activeUsersQuery).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = true;', + ); + expect(youngUsersQuery).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`age` < 30;', + ); + }); + }); + + describe('MySQL-specific features', () => { + it('should handle MySQL operators correctly', () => { + const query = User.select() + .where({ + name: { [Op.like]: '%john%' }, + age: { [Op.between]: [18, 65] }, + }) + .getQuery(); + expect(query).to.include('LIKE'); + expect(query).to.include('BETWEEN'); + }); + + it('should handle array operations', () => { + const query = User.select() + .where({ + name: { [Op.in]: ['John', 'Jane', 'Bob'] }, + }) + .getQuery(); + expect(query).to.include('IN'); + expect(query).to.include("'John'"); + expect(query).to.include("'Jane'"); + expect(query).to.include("'Bob'"); + }); + + it('should quote identifiers properly for MySQL', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + + // MySQL uses backticks for identifiers + expect(query).to.include('`name`'); + expect(query).to.include('`email`'); + expect(query).to.include('`users`'); + expect(query).to.include('`User`'); + expect(query).to.include('`User`.`active`'); + }); + }); + + describe('Error handling', () => { + it('should throw error when getQuery is called on non-select builder', () => { + expect(() => { + const builder = new (User.select().constructor)(User); + builder.getQuery(); + }).to.throw(); + }); + + it('should handle empty attributes array', () => { + expect(() => { + User.select().attributes([]).getQuery(); + }).to.throw( + "Attempted a SELECT query for model 'User' as `User` without selecting any columns", + ); + }); + + it('should handle null/undefined where conditions gracefully', () => { + // null where should throw an error as it's invalid + expect(() => { + User.select().where(null).getQuery(); + }).to.throw(); + + // undefined where should work (no where clause) + expect(() => { + User.select().where(undefined).getQuery(); + }).to.not.throw(); + }); + }); + + describe('Integration with different models', () => { + it('should work with different models', () => { + const userQuery = User.select().attributes(['name']).getQuery(); + const postQuery = Post.select().attributes(['title']).getQuery(); + + expect(userQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); + expect(postQuery).to.equal('SELECT `title` FROM `posts` AS `Post`;'); + }); + + it('should use correct table names and aliases', () => { + const query = User.select().getQuery(); + expect(query).to.include('`users`'); + expect(query).to.include('AS `User`'); + + const postQuery = Post.select().getQuery(); + expect(postQuery).to.include('`posts`'); + expect(postQuery).to.include('AS `Post`'); + }); + }); + + describe('Complex WHERE conditions', () => { + it('should handle complex nested conditions', () => { + const query = User.select() + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], + }, + ], + }) + .getQuery(); + + expect(query).to.include('OR'); + expect(query).to.include('AND'); + expect(query).to.be.a('string'); + }); + + it('should handle IS NULL conditions', () => { + const query = User.select().where({ age: null }).getQuery(); + expect(query).to.include('IS NULL'); + }); + + it('should handle NOT NULL conditions', () => { + const query = User.select() + .where({ age: { [Op.ne]: null } }) + .getQuery(); + expect(query).to.include('IS NOT NULL'); + }); + }); + }); +} diff --git a/packages/core/test/integration/dialects/postgres/query-builder.test.js b/packages/core/test/integration/dialects/postgres/query-builder.test.js new file mode 100644 index 000000000000..bb949314b745 --- /dev/null +++ b/packages/core/test/integration/dialects/postgres/query-builder.test.js @@ -0,0 +1,281 @@ +'use strict'; + +const chai = require('chai'); + +const expect = chai.expect; +const Support = require('../../support'); +const { DataTypes, Op } = require('@sequelize/core'); + +const dialect = Support.getTestDialect(); + +if (dialect.startsWith('postgres')) { + describe('[POSTGRES] QueryBuilder', () => { + let sequelize; + let User; + let Post; + + beforeEach(async () => { + sequelize = Support.createSingleTestSequelizeInstance(); + + User = sequelize.define( + 'User', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + age: { + type: DataTypes.INTEGER, + }, + }, + { + tableName: 'users', + }, + ); + + Post = sequelize.define( + 'Post', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + content: { + type: DataTypes.TEXT, + }, + published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + }, + { + tableName: 'posts', + }, + ); + + User.hasMany(Post, { foreignKey: 'userId' }); + Post.belongsTo(User, { foreignKey: 'userId' }); + }); + + afterEach(() => { + return sequelize?.close(); + }); + + describe('Basic QueryBuilder functionality', () => { + it('should generate basic SELECT query', () => { + const query = User.select().getQuery(); + expect(query).to.equal('SELECT * FROM "users" AS "User";'); + }); + + it('should generate SELECT query with specific attributes', () => { + const query = User.select().attributes(['name', 'email']).getQuery(); + expect(query).to.equal('SELECT "name", "email" FROM "users" AS "User";'); + }); + + it('should generate SELECT query with WHERE clause', () => { + const query = User.select().where({ active: true }).getQuery(); + expect(query).to.equal('SELECT * FROM "users" AS "User" WHERE "User"."active" = true;'); + }); + + it('should generate SELECT query with multiple WHERE conditions', () => { + const query = User.select().where({ active: true, age: 25 }).getQuery(); + expect(query).to.equal( + 'SELECT * FROM "users" AS "User" WHERE "User"."active" = true AND "User"."age" = 25;', + ); + }); + + it('should generate complete SELECT query with attributes and WHERE', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + expect(query).to.equal( + 'SELECT "name", "email" FROM "users" AS "User" WHERE "User"."active" = true;', + ); + }); + }); + + describe('Functional/Immutable behavior', () => { + it('should return new instances for each method call', () => { + const builder1 = User.select(); + const builder2 = builder1.attributes(['name']); + const builder3 = builder2.where({ active: true }); + + expect(builder1).to.not.equal(builder2); + expect(builder2).to.not.equal(builder3); + expect(builder1).to.not.equal(builder3); + }); + + it('should not mutate original builder when chaining', () => { + const baseBuilder = User.select(); + const builderWithAttributes = baseBuilder.attributes(['name']); + const builderWithWhere = baseBuilder.where({ active: true }); + + // Base builder should remain unchanged + const baseQuery = baseBuilder.getQuery(); + expect(baseQuery).to.equal('SELECT * FROM "users" AS "User";'); + + // Other builders should have their modifications + const attributesQuery = builderWithAttributes.getQuery(); + expect(attributesQuery).to.equal('SELECT "name" FROM "users" AS "User";'); + + const whereQuery = builderWithWhere.getQuery(); + expect(whereQuery).to.equal( + 'SELECT * FROM "users" AS "User" WHERE "User"."active" = true;', + ); + }); + + it('should allow building different queries from same base', () => { + const baseBuilder = User.select().attributes(['name', 'email']); + + const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); + const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); + + expect(activeUsersQuery).to.equal( + 'SELECT "name", "email" FROM "users" AS "User" WHERE "User"."active" = true;', + ); + expect(youngUsersQuery).to.equal( + 'SELECT "name", "email" FROM "users" AS "User" WHERE "User"."age" < 30;', + ); + }); + }); + + describe('PostgreSQL-specific features', () => { + it('should handle PostgreSQL operators correctly', () => { + const query = User.select() + .where({ + name: { [Op.iLike]: '%john%' }, + age: { [Op.between]: [18, 65] }, + }) + .getQuery(); + expect(query).to.include('ILIKE'); + expect(query).to.include('BETWEEN'); + }); + + it('should handle array operations', () => { + const query = User.select() + .where({ + name: { [Op.in]: ['John', 'Jane', 'Bob'] }, + }) + .getQuery(); + expect(query).to.include('IN'); + expect(query).to.include("'John'"); + expect(query).to.include("'Jane'"); + expect(query).to.include("'Bob'"); + }); + + it('should quote identifiers properly for PostgreSQL', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + + // PostgreSQL uses double quotes for identifiers + expect(query).to.include('"name"'); + expect(query).to.include('"email"'); + expect(query).to.include('"users"'); + expect(query).to.include('"User"'); + expect(query).to.include('"User"."active"'); + }); + }); + + describe('Error handling', () => { + it('should throw error when getQuery is called on non-select builder', () => { + expect(() => { + const builder = new (User.select().constructor)(User); + builder.getQuery(); + }).to.throw(); + }); + + it('should handle empty attributes array', () => { + expect(() => { + User.select().attributes([]).getQuery(); + }).to.throw( + 'Attempted a SELECT query for model \'User\' as "User" without selecting any columns', + ); + }); + + it('should handle null/undefined where conditions gracefully', () => { + // null where should throw an error as it's invalid + expect(() => { + User.select().where(null).getQuery(); + }).to.throw(); + + // undefined where should work (no where clause) + expect(() => { + User.select().where(undefined).getQuery(); + }).to.not.throw(); + }); + }); + + describe('Integration with different models', () => { + it('should work with different models', () => { + const userQuery = User.select().attributes(['name']).getQuery(); + const postQuery = Post.select().attributes(['title']).getQuery(); + + expect(userQuery).to.equal('SELECT "name" FROM "users" AS "User";'); + expect(postQuery).to.equal('SELECT "title" FROM "posts" AS "Post";'); + }); + + it('should use correct table names and aliases', () => { + const query = User.select().getQuery(); + expect(query).to.include('"users"'); + expect(query).to.include('AS "User"'); + + const postQuery = Post.select().getQuery(); + expect(postQuery).to.include('"posts"'); + expect(postQuery).to.include('AS "Post"'); + }); + }); + + describe('Complex WHERE conditions', () => { + it('should handle complex nested conditions', () => { + const query = User.select() + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], + }, + ], + }) + .getQuery(); + + expect(query).to.include('OR'); + expect(query).to.include('AND'); + expect(query).to.be.a('string'); + }); + + it('should handle IS NULL conditions', () => { + const query = User.select().where({ age: null }).getQuery(); + expect(query).to.include('IS NULL'); + }); + + it('should handle NOT NULL conditions', () => { + const query = User.select() + .where({ age: { [Op.ne]: null } }) + .getQuery(); + expect(query).to.include('IS NOT NULL'); + }); + }); + }); +} diff --git a/packages/core/test/integration/dialects/sqlite/query-builder.test.js b/packages/core/test/integration/dialects/sqlite/query-builder.test.js new file mode 100644 index 000000000000..e6e5dca4f86d --- /dev/null +++ b/packages/core/test/integration/dialects/sqlite/query-builder.test.js @@ -0,0 +1,287 @@ +'use strict'; + +const chai = require('chai'); + +const expect = chai.expect; +const Support = require('../../support'); +const { DataTypes, Op } = require('@sequelize/core'); + +const dialect = Support.getTestDialect(); + +if (dialect.startsWith('sqlite')) { + describe('[SQLITE] QueryBuilder', () => { + let sequelize; + let User; + let Post; + + beforeEach(async () => { + sequelize = Support.createSingleTestSequelizeInstance(); + + User = sequelize.define( + 'User', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + age: { + type: DataTypes.INTEGER, + }, + }, + { + tableName: 'users', + }, + ); + + Post = sequelize.define( + 'Post', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + content: { + type: DataTypes.TEXT, + }, + published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + }, + { + tableName: 'posts', + }, + ); + + User.hasMany(Post, { foreignKey: 'userId' }); + Post.belongsTo(User, { foreignKey: 'userId' }); + }); + + afterEach(() => { + return sequelize?.close(); + }); + + describe('Basic QueryBuilder functionality', () => { + it('should generate basic SELECT query', () => { + const query = User.select().getQuery(); + expect(query).to.equal('SELECT * FROM `users` AS `User`;'); + }); + + it('should generate SELECT query with specific attributes', () => { + const query = User.select().attributes(['name', 'email']).getQuery(); + expect(query).to.equal('SELECT `name`, `email` FROM `users` AS `User`;'); + }); + + it('should generate SELECT query with WHERE clause', () => { + const query = User.select().where({ active: true }).getQuery(); + expect(query).to.equal('SELECT * FROM `users` AS `User` WHERE `User`.`active` = 1;'); + }); + + it('should generate SELECT query with multiple WHERE conditions', () => { + const query = User.select().where({ active: true, age: 25 }).getQuery(); + expect(query).to.equal( + 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = 1 AND `User`.`age` = 25;', + ); + }); + + it('should generate complete SELECT query with attributes and WHERE', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + expect(query).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = 1;', + ); + }); + }); + + describe('Functional/Immutable behavior', () => { + it('should return new instances for each method call', () => { + const builder1 = User.select(); + const builder2 = builder1.attributes(['name']); + const builder3 = builder2.where({ active: true }); + + expect(builder1).to.not.equal(builder2); + expect(builder2).to.not.equal(builder3); + expect(builder1).to.not.equal(builder3); + }); + + it('should not mutate original builder when chaining', () => { + const baseBuilder = User.select(); + const builderWithAttributes = baseBuilder.attributes(['name']); + const builderWithWhere = baseBuilder.where({ active: true }); + + // Base builder should remain unchanged + const baseQuery = baseBuilder.getQuery(); + expect(baseQuery).to.equal('SELECT * FROM `users` AS `User`;'); + + // Other builders should have their modifications + const attributesQuery = builderWithAttributes.getQuery(); + expect(attributesQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); + + const whereQuery = builderWithWhere.getQuery(); + expect(whereQuery).to.equal('SELECT * FROM `users` AS `User` WHERE `User`.`active` = 1;'); + }); + + it('should allow building different queries from same base', () => { + const baseBuilder = User.select().attributes(['name', 'email']); + + const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); + const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); + + expect(activeUsersQuery).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = 1;', + ); + expect(youngUsersQuery).to.equal( + 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`age` < 30;', + ); + }); + }); + + describe('SQLite-specific features', () => { + it('should handle SQLite operators correctly', () => { + const query = User.select() + .where({ + name: { [Op.like]: '%john%' }, + age: { [Op.between]: [18, 65] }, + }) + .getQuery(); + expect(query).to.include('LIKE'); + expect(query).to.include('BETWEEN'); + }); + + it('should handle array operations', () => { + const query = User.select() + .where({ + name: { [Op.in]: ['John', 'Jane', 'Bob'] }, + }) + .getQuery(); + expect(query).to.include('IN'); + expect(query).to.include("'John'"); + expect(query).to.include("'Jane'"); + expect(query).to.include("'Bob'"); + }); + + it('should quote identifiers properly for SQLite', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + + // SQLite uses backticks for identifiers + expect(query).to.include('`name`'); + expect(query).to.include('`email`'); + expect(query).to.include('`users`'); + expect(query).to.include('`User`'); + expect(query).to.include('`User`.`active`'); + }); + + it('should handle boolean values as 1/0', () => { + const query = User.select().where({ active: true }).getQuery(); + expect(query).to.include('= 1'); + + const falseQuery = User.select().where({ active: false }).getQuery(); + expect(falseQuery).to.include('= 0'); + }); + }); + + describe('Error handling', () => { + it('should throw error when getQuery is called on non-select builder', () => { + expect(() => { + const builder = new (User.select().constructor)(User); + builder.getQuery(); + }).to.throw(); + }); + + it('should handle empty attributes array', () => { + expect(() => { + User.select().attributes([]).getQuery(); + }).to.throw( + "Attempted a SELECT query for model 'User' as `User` without selecting any columns", + ); + }); + + it('should handle null/undefined where conditions gracefully', () => { + // null where should throw an error as it's invalid + expect(() => { + User.select().where(null).getQuery(); + }).to.throw(); + + // undefined where should work (no where clause) + expect(() => { + User.select().where(undefined).getQuery(); + }).to.not.throw(); + }); + }); + + describe('Integration with different models', () => { + it('should work with different models', () => { + const userQuery = User.select().attributes(['name']).getQuery(); + const postQuery = Post.select().attributes(['title']).getQuery(); + + expect(userQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); + expect(postQuery).to.equal('SELECT `title` FROM `posts` AS `Post`;'); + }); + + it('should use correct table names and aliases', () => { + const query = User.select().getQuery(); + expect(query).to.include('`users`'); + expect(query).to.include('AS `User`'); + + const postQuery = Post.select().getQuery(); + expect(postQuery).to.include('`posts`'); + expect(postQuery).to.include('AS `Post`'); + }); + }); + + describe('Complex WHERE conditions', () => { + it('should handle complex nested conditions', () => { + const query = User.select() + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], + }, + ], + }) + .getQuery(); + + expect(query).to.include('OR'); + expect(query).to.include('AND'); + expect(query).to.be.a('string'); + }); + + it('should handle IS NULL conditions', () => { + const query = User.select().where({ age: null }).getQuery(); + expect(query).to.include('IS NULL'); + }); + + it('should handle NOT NULL conditions', () => { + const query = User.select() + .where({ age: { [Op.ne]: null } }) + .getQuery(); + expect(query).to.include('IS NOT NULL'); + }); + }); + }); +} diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md deleted file mode 100644 index a2a340e9bbc7..000000000000 --- a/packages/utils/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.0.0-alpha.46](https://github.com/sequelize/sequelize/compare/v7.0.0-alpha.45...v7.0.0-alpha.46) (2025-03-22) - -### Bug Fixes - -- **cli:** remove redundant types export in package.json ([#17781](https://github.com/sequelize/sequelize/issues/17781)) ([2391263](https://github.com/sequelize/sequelize/commit/2391263eaa09ae8c3fe1ce624b3f696ccfae8501)) -- **core:** fix issues with composite PK in `findByPk` ([#17747](https://github.com/sequelize/sequelize/issues/17747)) ([dd587cb](https://github.com/sequelize/sequelize/commit/dd587cb86a1b636cdc9cc490c9325e2f6e7640a8)) -- update typescript to v5.8.2 ([#17728](https://github.com/sequelize/sequelize/issues/17728)) ([6c5a82d](https://github.com/sequelize/sequelize/commit/6c5a82dbc82ec45bbe85112c51e1b496f3f7dbaa)) - -### Features - -- **core:** add `sql.join` & improve `sql.identifier` ([#17744](https://github.com/sequelize/sequelize/issues/17744)) ([e914861](https://github.com/sequelize/sequelize/commit/e914861c084ef0ed8f12ca7b59be4965326e9641)) diff --git a/packages/validator-js/CHANGELOG.md b/packages/validator-js/CHANGELOG.md deleted file mode 100644 index 4c1d58f0d789..000000000000 --- a/packages/validator-js/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.0.0-alpha.46](https://github.com/sequelize/sequelize/compare/v7.0.0-alpha.45...v7.0.0-alpha.46) (2025-03-22) - -**Note:** Version bump only for package @sequelize/validator.js From cb21278837e97b9d8906990a8b0b0c6818538c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sun, 6 Jul 2025 12:32:06 -0300 Subject: [PATCH 02/14] restore changelog files, not sure why they were deleted... From baee10d4938382397498c43c654e5f7e85381e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sun, 6 Jul 2025 12:42:24 -0300 Subject: [PATCH 03/14] restore changelog files without pre-commit hook --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ packages/core/CHANGELOG.md | 20 ++++++++++++++++++++ packages/utils/CHANGELOG.md | 16 ++++++++++++++++ packages/validator-js/CHANGELOG.md | 8 ++++++++ 4 files changed, 74 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 packages/core/CHANGELOG.md create mode 100644 packages/utils/CHANGELOG.md create mode 100644 packages/validator-js/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000000..b6e3e1c16f21 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# v0.1.0 # +- first stable version +- implemented all basic functions +- associations are working + +# v0.2.0 # +- added methods for setting associations +- added method for chaining an arbitraty amount of queries + +# 0.2.1 # +- fixed date bug + +# 0.2.2 # +- released project as npm package + +# 0.2.3 # +- added latest mysql connection library + - fixed id handling on save + - fixed text handling (varchar > 255; text) +- using the inflection library for naming tables more convenient +- Sequelize.TEXT is now using MySQL datatype TEXT instead of varchar(4000) + +# 0.2.4 # +- fixed bug when using cross associated tables (many to many associations) + +# 0.2.5 # +- added BOOLEAN type +- added FLOAT type +- fixed DATE type issue +- fixed npm package \ No newline at end of file diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 000000000000..dc829955d269 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,20 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.0.0-alpha.46](https://github.com/sequelize/sequelize/compare/v7.0.0-alpha.45...v7.0.0-alpha.46) (2025-03-22) + +### Bug Fixes + +- **core:** fix issues with composite PK in `findByPk` ([#17747](https://github.com/sequelize/sequelize/issues/17747)) ([dd587cb](https://github.com/sequelize/sequelize/commit/dd587cb86a1b636cdc9cc490c9325e2f6e7640a8)) +- **core:** fix msg of error thrown when decorating a non-model ([#17745](https://github.com/sequelize/sequelize/issues/17745)) ([c43c270](https://github.com/sequelize/sequelize/commit/c43c2708d75535edd0fd78e990884a3e38f2fb0d)) +- **core:** proper check upsert support in query-interface ([#17358](https://github.com/sequelize/sequelize/issues/17358)) ([68d7d75](https://github.com/sequelize/sequelize/commit/68d7d758671e0f80bafd68c6980be9dc818683fd)) +- **postgres:** correct existing enum type matching ([#17576](https://github.com/sequelize/sequelize/issues/17576)) ([425d217](https://github.com/sequelize/sequelize/commit/425d21718af40f86015f6496ea6cf721cc61b981)) +- **postgres:** update to postgres 17 ([#17740](https://github.com/sequelize/sequelize/issues/17740)) ([b5c2b26](https://github.com/sequelize/sequelize/commit/b5c2b2667004b3b27e5634c677507f5593987938)) +- update typescript to v5.8.2 ([#17728](https://github.com/sequelize/sequelize/issues/17728)) ([6c5a82d](https://github.com/sequelize/sequelize/commit/6c5a82dbc82ec45bbe85112c51e1b496f3f7dbaa)) + +### Features + +- **core:** add `sql.join` & improve `sql.identifier` ([#17744](https://github.com/sequelize/sequelize/issues/17744)) ([e914861](https://github.com/sequelize/sequelize/commit/e914861c084ef0ed8f12ca7b59be4965326e9641)) +- **core:** count grouped rows ([#17751](https://github.com/sequelize/sequelize/issues/17751)) ([a396673](https://github.com/sequelize/sequelize/commit/a396673b4edad0d3d3379111a3b1cbf3695d22cc)) diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md new file mode 100644 index 000000000000..a2a340e9bbc7 --- /dev/null +++ b/packages/utils/CHANGELOG.md @@ -0,0 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.0.0-alpha.46](https://github.com/sequelize/sequelize/compare/v7.0.0-alpha.45...v7.0.0-alpha.46) (2025-03-22) + +### Bug Fixes + +- **cli:** remove redundant types export in package.json ([#17781](https://github.com/sequelize/sequelize/issues/17781)) ([2391263](https://github.com/sequelize/sequelize/commit/2391263eaa09ae8c3fe1ce624b3f696ccfae8501)) +- **core:** fix issues with composite PK in `findByPk` ([#17747](https://github.com/sequelize/sequelize/issues/17747)) ([dd587cb](https://github.com/sequelize/sequelize/commit/dd587cb86a1b636cdc9cc490c9325e2f6e7640a8)) +- update typescript to v5.8.2 ([#17728](https://github.com/sequelize/sequelize/issues/17728)) ([6c5a82d](https://github.com/sequelize/sequelize/commit/6c5a82dbc82ec45bbe85112c51e1b496f3f7dbaa)) + +### Features + +- **core:** add `sql.join` & improve `sql.identifier` ([#17744](https://github.com/sequelize/sequelize/issues/17744)) ([e914861](https://github.com/sequelize/sequelize/commit/e914861c084ef0ed8f12ca7b59be4965326e9641)) diff --git a/packages/validator-js/CHANGELOG.md b/packages/validator-js/CHANGELOG.md new file mode 100644 index 000000000000..4c1d58f0d789 --- /dev/null +++ b/packages/validator-js/CHANGELOG.md @@ -0,0 +1,8 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.0.0-alpha.46](https://github.com/sequelize/sequelize/compare/v7.0.0-alpha.45...v7.0.0-alpha.46) (2025-03-22) + +**Note:** Version bump only for package @sequelize/validator.js From b35958312ba7ea179d5fa9a1d2d70c9b1de9a891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sun, 6 Jul 2025 12:44:00 -0300 Subject: [PATCH 04/14] fix main changelog --- CHANGELOG.md | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e3e1c16f21..b9f7b9b8f41f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,30 +1,21 @@ -# v0.1.0 # -- first stable version -- implemented all basic functions -- associations are working +# Change Log -# v0.2.0 # -- added methods for setting associations -- added method for chaining an arbitraty amount of queries +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# 0.2.1 # -- fixed date bug +# [7.0.0-alpha.46](https://github.com/sequelize/sequelize/compare/v7.0.0-alpha.45...v7.0.0-alpha.46) (2025-03-22) -# 0.2.2 # -- released project as npm package +### Bug Fixes -# 0.2.3 # -- added latest mysql connection library - - fixed id handling on save - - fixed text handling (varchar > 255; text) -- using the inflection library for naming tables more convenient -- Sequelize.TEXT is now using MySQL datatype TEXT instead of varchar(4000) +- **cli:** remove redundant types export in package.json ([#17781](https://github.com/sequelize/sequelize/issues/17781)) ([2391263](https://github.com/sequelize/sequelize/commit/2391263eaa09ae8c3fe1ce624b3f696ccfae8501)) +- **core:** fix issues with composite PK in `findByPk` ([#17747](https://github.com/sequelize/sequelize/issues/17747)) ([dd587cb](https://github.com/sequelize/sequelize/commit/dd587cb86a1b636cdc9cc490c9325e2f6e7640a8)) +- **core:** fix msg of error thrown when decorating a non-model ([#17745](https://github.com/sequelize/sequelize/issues/17745)) ([c43c270](https://github.com/sequelize/sequelize/commit/c43c2708d75535edd0fd78e990884a3e38f2fb0d)) +- **core:** proper check upsert support in query-interface ([#17358](https://github.com/sequelize/sequelize/issues/17358)) ([68d7d75](https://github.com/sequelize/sequelize/commit/68d7d758671e0f80bafd68c6980be9dc818683fd)) +- **postgres:** correct existing enum type matching ([#17576](https://github.com/sequelize/sequelize/issues/17576)) ([425d217](https://github.com/sequelize/sequelize/commit/425d21718af40f86015f6496ea6cf721cc61b981)) +- **postgres:** update to postgres 17 ([#17740](https://github.com/sequelize/sequelize/issues/17740)) ([b5c2b26](https://github.com/sequelize/sequelize/commit/b5c2b2667004b3b27e5634c677507f5593987938)) +- update typescript to v5.8.2 ([#17728](https://github.com/sequelize/sequelize/issues/17728)) ([6c5a82d](https://github.com/sequelize/sequelize/commit/6c5a82dbc82ec45bbe85112c51e1b496f3f7dbaa)) -# 0.2.4 # -- fixed bug when using cross associated tables (many to many associations) +### Features -# 0.2.5 # -- added BOOLEAN type -- added FLOAT type -- fixed DATE type issue -- fixed npm package \ No newline at end of file +- **core:** add `sql.join` & improve `sql.identifier` ([#17744](https://github.com/sequelize/sequelize/issues/17744)) ([e914861](https://github.com/sequelize/sequelize/commit/e914861c084ef0ed8f12ca7b59be4965326e9641)) +- **core:** count grouped rows ([#17751](https://github.com/sequelize/sequelize/issues/17751)) ([a396673](https://github.com/sequelize/sequelize/commit/a396673b4edad0d3d3379111a3b1cbf3695d22cc)) From 5b2d5539a18a6440c4f6115d2d2c92d35ec56dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sun, 6 Jul 2025 13:22:37 -0300 Subject: [PATCH 05/14] refactor tests --- .../dialects/mariadb/query-builder.test.js | 281 ----------------- .../dialects/mssql/query-builder.test.js | 287 ----------------- .../dialects/mysql/query-builder.test.js | 281 ----------------- .../dialects/postgres/query-builder.test.js | 281 ----------------- .../dialects/sqlite/query-builder.test.js | 287 ----------------- .../query-builder/query-builder.test.js | 292 ++++++++++++++++++ 6 files changed, 292 insertions(+), 1417 deletions(-) delete mode 100644 packages/core/test/integration/dialects/mariadb/query-builder.test.js delete mode 100644 packages/core/test/integration/dialects/mssql/query-builder.test.js delete mode 100644 packages/core/test/integration/dialects/mysql/query-builder.test.js delete mode 100644 packages/core/test/integration/dialects/postgres/query-builder.test.js delete mode 100644 packages/core/test/integration/dialects/sqlite/query-builder.test.js create mode 100644 packages/core/test/integration/query-builder/query-builder.test.js diff --git a/packages/core/test/integration/dialects/mariadb/query-builder.test.js b/packages/core/test/integration/dialects/mariadb/query-builder.test.js deleted file mode 100644 index 46132839ee9d..000000000000 --- a/packages/core/test/integration/dialects/mariadb/query-builder.test.js +++ /dev/null @@ -1,281 +0,0 @@ -'use strict'; - -const chai = require('chai'); - -const expect = chai.expect; -const Support = require('../../support'); -const { DataTypes, Op } = require('@sequelize/core'); - -const dialect = Support.getTestDialect(); - -if (dialect.startsWith('mariadb')) { - describe('[MARIADB] QueryBuilder', () => { - let sequelize; - let User; - let Post; - - beforeEach(async () => { - sequelize = Support.createSingleTestSequelizeInstance(); - - User = sequelize.define( - 'User', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - email: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - }, - active: { - type: DataTypes.BOOLEAN, - defaultValue: true, - }, - age: { - type: DataTypes.INTEGER, - }, - }, - { - tableName: 'users', - }, - ); - - Post = sequelize.define( - 'Post', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - content: { - type: DataTypes.TEXT, - }, - published: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, - }, - { - tableName: 'posts', - }, - ); - - User.hasMany(Post, { foreignKey: 'userId' }); - Post.belongsTo(User, { foreignKey: 'userId' }); - }); - - afterEach(() => { - return sequelize?.close(); - }); - - describe('Basic QueryBuilder functionality', () => { - it('should generate basic SELECT query', () => { - const query = User.select().getQuery(); - expect(query).to.equal('SELECT * FROM `users` AS `User`;'); - }); - - it('should generate SELECT query with specific attributes', () => { - const query = User.select().attributes(['name', 'email']).getQuery(); - expect(query).to.equal('SELECT `name`, `email` FROM `users` AS `User`;'); - }); - - it('should generate SELECT query with WHERE clause', () => { - const query = User.select().where({ active: true }).getQuery(); - expect(query).to.equal('SELECT * FROM `users` AS `User` WHERE `User`.`active` = true;'); - }); - - it('should generate SELECT query with multiple WHERE conditions', () => { - const query = User.select().where({ active: true, age: 25 }).getQuery(); - expect(query).to.equal( - 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = true AND `User`.`age` = 25;', - ); - }); - - it('should generate complete SELECT query with attributes and WHERE', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - expect(query).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = true;', - ); - }); - }); - - describe('Functional/Immutable behavior', () => { - it('should return new instances for each method call', () => { - const builder1 = User.select(); - const builder2 = builder1.attributes(['name']); - const builder3 = builder2.where({ active: true }); - - expect(builder1).to.not.equal(builder2); - expect(builder2).to.not.equal(builder3); - expect(builder1).to.not.equal(builder3); - }); - - it('should not mutate original builder when chaining', () => { - const baseBuilder = User.select(); - const builderWithAttributes = baseBuilder.attributes(['name']); - const builderWithWhere = baseBuilder.where({ active: true }); - - // Base builder should remain unchanged - const baseQuery = baseBuilder.getQuery(); - expect(baseQuery).to.equal('SELECT * FROM `users` AS `User`;'); - - // Other builders should have their modifications - const attributesQuery = builderWithAttributes.getQuery(); - expect(attributesQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); - - const whereQuery = builderWithWhere.getQuery(); - expect(whereQuery).to.equal( - 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = true;', - ); - }); - - it('should allow building different queries from same base', () => { - const baseBuilder = User.select().attributes(['name', 'email']); - - const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); - const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); - - expect(activeUsersQuery).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = true;', - ); - expect(youngUsersQuery).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`age` < 30;', - ); - }); - }); - - describe('MariaDB-specific features', () => { - it('should handle MariaDB operators correctly', () => { - const query = User.select() - .where({ - name: { [Op.like]: '%john%' }, - age: { [Op.between]: [18, 65] }, - }) - .getQuery(); - expect(query).to.include('LIKE'); - expect(query).to.include('BETWEEN'); - }); - - it('should handle array operations', () => { - const query = User.select() - .where({ - name: { [Op.in]: ['John', 'Jane', 'Bob'] }, - }) - .getQuery(); - expect(query).to.include('IN'); - expect(query).to.include("'John'"); - expect(query).to.include("'Jane'"); - expect(query).to.include("'Bob'"); - }); - - it('should quote identifiers properly for MariaDB', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - - // MariaDB uses backticks for identifiers (like MySQL) - expect(query).to.include('`name`'); - expect(query).to.include('`email`'); - expect(query).to.include('`users`'); - expect(query).to.include('`User`'); - expect(query).to.include('`User`.`active`'); - }); - }); - - describe('Error handling', () => { - it('should throw error when getQuery is called on non-select builder', () => { - expect(() => { - const builder = new (User.select().constructor)(User); - builder.getQuery(); - }).to.throw(); - }); - - it('should handle empty attributes array', () => { - expect(() => { - User.select().attributes([]).getQuery(); - }).to.throw( - "Attempted a SELECT query for model 'User' as `User` without selecting any columns", - ); - }); - - it('should handle null/undefined where conditions gracefully', () => { - // null where should throw an error as it's invalid - expect(() => { - User.select().where(null).getQuery(); - }).to.throw(); - - // undefined where should work (no where clause) - expect(() => { - User.select().where(undefined).getQuery(); - }).to.not.throw(); - }); - }); - - describe('Integration with different models', () => { - it('should work with different models', () => { - const userQuery = User.select().attributes(['name']).getQuery(); - const postQuery = Post.select().attributes(['title']).getQuery(); - - expect(userQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); - expect(postQuery).to.equal('SELECT `title` FROM `posts` AS `Post`;'); - }); - - it('should use correct table names and aliases', () => { - const query = User.select().getQuery(); - expect(query).to.include('`users`'); - expect(query).to.include('AS `User`'); - - const postQuery = Post.select().getQuery(); - expect(postQuery).to.include('`posts`'); - expect(postQuery).to.include('AS `Post`'); - }); - }); - - describe('Complex WHERE conditions', () => { - it('should handle complex nested conditions', () => { - const query = User.select() - .where({ - [Op.or]: [ - { active: true }, - { - [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], - }, - ], - }) - .getQuery(); - - expect(query).to.include('OR'); - expect(query).to.include('AND'); - expect(query).to.be.a('string'); - }); - - it('should handle IS NULL conditions', () => { - const query = User.select().where({ age: null }).getQuery(); - expect(query).to.include('IS NULL'); - }); - - it('should handle NOT NULL conditions', () => { - const query = User.select() - .where({ age: { [Op.ne]: null } }) - .getQuery(); - expect(query).to.include('IS NOT NULL'); - }); - }); - }); -} diff --git a/packages/core/test/integration/dialects/mssql/query-builder.test.js b/packages/core/test/integration/dialects/mssql/query-builder.test.js deleted file mode 100644 index 974abfe81e51..000000000000 --- a/packages/core/test/integration/dialects/mssql/query-builder.test.js +++ /dev/null @@ -1,287 +0,0 @@ -'use strict'; - -const chai = require('chai'); - -const expect = chai.expect; -const Support = require('../../support'); -const { DataTypes, Op } = require('@sequelize/core'); - -const dialect = Support.getTestDialect(); - -if (dialect.startsWith('mssql')) { - describe('[MSSQL] QueryBuilder', () => { - let sequelize; - let User; - let Post; - - beforeEach(async () => { - sequelize = Support.createSingleTestSequelizeInstance(); - - User = sequelize.define( - 'User', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - email: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - }, - active: { - type: DataTypes.BOOLEAN, - defaultValue: true, - }, - age: { - type: DataTypes.INTEGER, - }, - }, - { - tableName: 'users', - }, - ); - - Post = sequelize.define( - 'Post', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - content: { - type: DataTypes.TEXT, - }, - published: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, - }, - { - tableName: 'posts', - }, - ); - - User.hasMany(Post, { foreignKey: 'userId' }); - Post.belongsTo(User, { foreignKey: 'userId' }); - }); - - afterEach(() => { - return sequelize?.close(); - }); - - describe('Basic QueryBuilder functionality', () => { - it('should generate basic SELECT query', () => { - const query = User.select().getQuery(); - expect(query).to.equal('SELECT * FROM [users] AS [User];'); - }); - - it('should generate SELECT query with specific attributes', () => { - const query = User.select().attributes(['name', 'email']).getQuery(); - expect(query).to.equal('SELECT [name], [email] FROM [users] AS [User];'); - }); - - it('should generate SELECT query with WHERE clause', () => { - const query = User.select().where({ active: true }).getQuery(); - expect(query).to.equal('SELECT * FROM [users] AS [User] WHERE [User].[active] = 1;'); - }); - - it('should generate SELECT query with multiple WHERE conditions', () => { - const query = User.select().where({ active: true, age: 25 }).getQuery(); - expect(query).to.equal( - 'SELECT * FROM [users] AS [User] WHERE [User].[active] = 1 AND [User].[age] = 25;', - ); - }); - - it('should generate complete SELECT query with attributes and WHERE', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - expect(query).to.equal( - 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;', - ); - }); - }); - - describe('Functional/Immutable behavior', () => { - it('should return new instances for each method call', () => { - const builder1 = User.select(); - const builder2 = builder1.attributes(['name']); - const builder3 = builder2.where({ active: true }); - - expect(builder1).to.not.equal(builder2); - expect(builder2).to.not.equal(builder3); - expect(builder1).to.not.equal(builder3); - }); - - it('should not mutate original builder when chaining', () => { - const baseBuilder = User.select(); - const builderWithAttributes = baseBuilder.attributes(['name']); - const builderWithWhere = baseBuilder.where({ active: true }); - - // Base builder should remain unchanged - const baseQuery = baseBuilder.getQuery(); - expect(baseQuery).to.equal('SELECT * FROM [users] AS [User];'); - - // Other builders should have their modifications - const attributesQuery = builderWithAttributes.getQuery(); - expect(attributesQuery).to.equal('SELECT [name] FROM [users] AS [User];'); - - const whereQuery = builderWithWhere.getQuery(); - expect(whereQuery).to.equal('SELECT * FROM [users] AS [User] WHERE [User].[active] = 1;'); - }); - - it('should allow building different queries from same base', () => { - const baseBuilder = User.select().attributes(['name', 'email']); - - const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); - const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); - - expect(activeUsersQuery).to.equal( - 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;', - ); - expect(youngUsersQuery).to.equal( - 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[age] < 30;', - ); - }); - }); - - describe('MSSQL-specific features', () => { - it('should handle MSSQL operators correctly', () => { - const query = User.select() - .where({ - name: { [Op.like]: '%john%' }, - age: { [Op.between]: [18, 65] }, - }) - .getQuery(); - expect(query).to.include('LIKE'); - expect(query).to.include('BETWEEN'); - }); - - it('should handle array operations', () => { - const query = User.select() - .where({ - name: { [Op.in]: ['John', 'Jane', 'Bob'] }, - }) - .getQuery(); - expect(query).to.include('IN'); - expect(query).to.include("N'John'"); - expect(query).to.include("N'Jane'"); - expect(query).to.include("N'Bob'"); - }); - - it('should quote identifiers properly for MSSQL', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - - // MSSQL uses square brackets for identifiers - expect(query).to.include('[name]'); - expect(query).to.include('[email]'); - expect(query).to.include('[users]'); - expect(query).to.include('[User]'); - expect(query).to.include('[User].[active]'); - }); - - it('should handle boolean values as 1/0', () => { - const query = User.select().where({ active: true }).getQuery(); - expect(query).to.include('= 1'); - - const falseQuery = User.select().where({ active: false }).getQuery(); - expect(falseQuery).to.include('= 0'); - }); - }); - - describe('Error handling', () => { - it('should throw error when getQuery is called on non-select builder', () => { - expect(() => { - const builder = new (User.select().constructor)(User); - builder.getQuery(); - }).to.throw(); - }); - - it('should handle empty attributes array', () => { - expect(() => { - User.select().attributes([]).getQuery(); - }).to.throw( - "Attempted a SELECT query for model 'User' as [User] without selecting any columns", - ); - }); - - it('should handle null/undefined where conditions gracefully', () => { - // null where should throw an error as it's invalid - expect(() => { - User.select().where(null).getQuery(); - }).to.throw(); - - // undefined where should work (no where clause) - expect(() => { - User.select().where(undefined).getQuery(); - }).to.not.throw(); - }); - }); - - describe('Integration with different models', () => { - it('should work with different models', () => { - const userQuery = User.select().attributes(['name']).getQuery(); - const postQuery = Post.select().attributes(['title']).getQuery(); - - expect(userQuery).to.equal('SELECT [name] FROM [users] AS [User];'); - expect(postQuery).to.equal('SELECT [title] FROM [posts] AS [Post];'); - }); - - it('should use correct table names and aliases', () => { - const query = User.select().getQuery(); - expect(query).to.include('[users]'); - expect(query).to.include('AS [User]'); - - const postQuery = Post.select().getQuery(); - expect(postQuery).to.include('[posts]'); - expect(postQuery).to.include('AS [Post]'); - }); - }); - - describe('Complex WHERE conditions', () => { - it('should handle complex nested conditions', () => { - const query = User.select() - .where({ - [Op.or]: [ - { active: true }, - { - [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], - }, - ], - }) - .getQuery(); - - expect(query).to.include('OR'); - expect(query).to.include('AND'); - expect(query).to.be.a('string'); - }); - - it('should handle IS NULL conditions', () => { - const query = User.select().where({ age: null }).getQuery(); - expect(query).to.include('IS NULL'); - }); - - it('should handle NOT NULL conditions', () => { - const query = User.select() - .where({ age: { [Op.ne]: null } }) - .getQuery(); - expect(query).to.include('IS NOT NULL'); - }); - }); - }); -} diff --git a/packages/core/test/integration/dialects/mysql/query-builder.test.js b/packages/core/test/integration/dialects/mysql/query-builder.test.js deleted file mode 100644 index 1d8b32d67ea5..000000000000 --- a/packages/core/test/integration/dialects/mysql/query-builder.test.js +++ /dev/null @@ -1,281 +0,0 @@ -'use strict'; - -const chai = require('chai'); - -const expect = chai.expect; -const Support = require('../../support'); -const { DataTypes, Op } = require('@sequelize/core'); - -const dialect = Support.getTestDialect(); - -if (dialect.startsWith('mysql')) { - describe('[MYSQL] QueryBuilder', () => { - let sequelize; - let User; - let Post; - - beforeEach(async () => { - sequelize = Support.createSingleTestSequelizeInstance(); - - User = sequelize.define( - 'User', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - email: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - }, - active: { - type: DataTypes.BOOLEAN, - defaultValue: true, - }, - age: { - type: DataTypes.INTEGER, - }, - }, - { - tableName: 'users', - }, - ); - - Post = sequelize.define( - 'Post', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - content: { - type: DataTypes.TEXT, - }, - published: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, - }, - { - tableName: 'posts', - }, - ); - - User.hasMany(Post, { foreignKey: 'userId' }); - Post.belongsTo(User, { foreignKey: 'userId' }); - }); - - afterEach(() => { - return sequelize?.close(); - }); - - describe('Basic QueryBuilder functionality', () => { - it('should generate basic SELECT query', () => { - const query = User.select().getQuery(); - expect(query).to.equal('SELECT * FROM `users` AS `User`;'); - }); - - it('should generate SELECT query with specific attributes', () => { - const query = User.select().attributes(['name', 'email']).getQuery(); - expect(query).to.equal('SELECT `name`, `email` FROM `users` AS `User`;'); - }); - - it('should generate SELECT query with WHERE clause', () => { - const query = User.select().where({ active: true }).getQuery(); - expect(query).to.equal('SELECT * FROM `users` AS `User` WHERE `User`.`active` = true;'); - }); - - it('should generate SELECT query with multiple WHERE conditions', () => { - const query = User.select().where({ active: true, age: 25 }).getQuery(); - expect(query).to.equal( - 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = true AND `User`.`age` = 25;', - ); - }); - - it('should generate complete SELECT query with attributes and WHERE', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - expect(query).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = true;', - ); - }); - }); - - describe('Functional/Immutable behavior', () => { - it('should return new instances for each method call', () => { - const builder1 = User.select(); - const builder2 = builder1.attributes(['name']); - const builder3 = builder2.where({ active: true }); - - expect(builder1).to.not.equal(builder2); - expect(builder2).to.not.equal(builder3); - expect(builder1).to.not.equal(builder3); - }); - - it('should not mutate original builder when chaining', () => { - const baseBuilder = User.select(); - const builderWithAttributes = baseBuilder.attributes(['name']); - const builderWithWhere = baseBuilder.where({ active: true }); - - // Base builder should remain unchanged - const baseQuery = baseBuilder.getQuery(); - expect(baseQuery).to.equal('SELECT * FROM `users` AS `User`;'); - - // Other builders should have their modifications - const attributesQuery = builderWithAttributes.getQuery(); - expect(attributesQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); - - const whereQuery = builderWithWhere.getQuery(); - expect(whereQuery).to.equal( - 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = true;', - ); - }); - - it('should allow building different queries from same base', () => { - const baseBuilder = User.select().attributes(['name', 'email']); - - const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); - const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); - - expect(activeUsersQuery).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = true;', - ); - expect(youngUsersQuery).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`age` < 30;', - ); - }); - }); - - describe('MySQL-specific features', () => { - it('should handle MySQL operators correctly', () => { - const query = User.select() - .where({ - name: { [Op.like]: '%john%' }, - age: { [Op.between]: [18, 65] }, - }) - .getQuery(); - expect(query).to.include('LIKE'); - expect(query).to.include('BETWEEN'); - }); - - it('should handle array operations', () => { - const query = User.select() - .where({ - name: { [Op.in]: ['John', 'Jane', 'Bob'] }, - }) - .getQuery(); - expect(query).to.include('IN'); - expect(query).to.include("'John'"); - expect(query).to.include("'Jane'"); - expect(query).to.include("'Bob'"); - }); - - it('should quote identifiers properly for MySQL', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - - // MySQL uses backticks for identifiers - expect(query).to.include('`name`'); - expect(query).to.include('`email`'); - expect(query).to.include('`users`'); - expect(query).to.include('`User`'); - expect(query).to.include('`User`.`active`'); - }); - }); - - describe('Error handling', () => { - it('should throw error when getQuery is called on non-select builder', () => { - expect(() => { - const builder = new (User.select().constructor)(User); - builder.getQuery(); - }).to.throw(); - }); - - it('should handle empty attributes array', () => { - expect(() => { - User.select().attributes([]).getQuery(); - }).to.throw( - "Attempted a SELECT query for model 'User' as `User` without selecting any columns", - ); - }); - - it('should handle null/undefined where conditions gracefully', () => { - // null where should throw an error as it's invalid - expect(() => { - User.select().where(null).getQuery(); - }).to.throw(); - - // undefined where should work (no where clause) - expect(() => { - User.select().where(undefined).getQuery(); - }).to.not.throw(); - }); - }); - - describe('Integration with different models', () => { - it('should work with different models', () => { - const userQuery = User.select().attributes(['name']).getQuery(); - const postQuery = Post.select().attributes(['title']).getQuery(); - - expect(userQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); - expect(postQuery).to.equal('SELECT `title` FROM `posts` AS `Post`;'); - }); - - it('should use correct table names and aliases', () => { - const query = User.select().getQuery(); - expect(query).to.include('`users`'); - expect(query).to.include('AS `User`'); - - const postQuery = Post.select().getQuery(); - expect(postQuery).to.include('`posts`'); - expect(postQuery).to.include('AS `Post`'); - }); - }); - - describe('Complex WHERE conditions', () => { - it('should handle complex nested conditions', () => { - const query = User.select() - .where({ - [Op.or]: [ - { active: true }, - { - [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], - }, - ], - }) - .getQuery(); - - expect(query).to.include('OR'); - expect(query).to.include('AND'); - expect(query).to.be.a('string'); - }); - - it('should handle IS NULL conditions', () => { - const query = User.select().where({ age: null }).getQuery(); - expect(query).to.include('IS NULL'); - }); - - it('should handle NOT NULL conditions', () => { - const query = User.select() - .where({ age: { [Op.ne]: null } }) - .getQuery(); - expect(query).to.include('IS NOT NULL'); - }); - }); - }); -} diff --git a/packages/core/test/integration/dialects/postgres/query-builder.test.js b/packages/core/test/integration/dialects/postgres/query-builder.test.js deleted file mode 100644 index bb949314b745..000000000000 --- a/packages/core/test/integration/dialects/postgres/query-builder.test.js +++ /dev/null @@ -1,281 +0,0 @@ -'use strict'; - -const chai = require('chai'); - -const expect = chai.expect; -const Support = require('../../support'); -const { DataTypes, Op } = require('@sequelize/core'); - -const dialect = Support.getTestDialect(); - -if (dialect.startsWith('postgres')) { - describe('[POSTGRES] QueryBuilder', () => { - let sequelize; - let User; - let Post; - - beforeEach(async () => { - sequelize = Support.createSingleTestSequelizeInstance(); - - User = sequelize.define( - 'User', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - email: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - }, - active: { - type: DataTypes.BOOLEAN, - defaultValue: true, - }, - age: { - type: DataTypes.INTEGER, - }, - }, - { - tableName: 'users', - }, - ); - - Post = sequelize.define( - 'Post', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - content: { - type: DataTypes.TEXT, - }, - published: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, - }, - { - tableName: 'posts', - }, - ); - - User.hasMany(Post, { foreignKey: 'userId' }); - Post.belongsTo(User, { foreignKey: 'userId' }); - }); - - afterEach(() => { - return sequelize?.close(); - }); - - describe('Basic QueryBuilder functionality', () => { - it('should generate basic SELECT query', () => { - const query = User.select().getQuery(); - expect(query).to.equal('SELECT * FROM "users" AS "User";'); - }); - - it('should generate SELECT query with specific attributes', () => { - const query = User.select().attributes(['name', 'email']).getQuery(); - expect(query).to.equal('SELECT "name", "email" FROM "users" AS "User";'); - }); - - it('should generate SELECT query with WHERE clause', () => { - const query = User.select().where({ active: true }).getQuery(); - expect(query).to.equal('SELECT * FROM "users" AS "User" WHERE "User"."active" = true;'); - }); - - it('should generate SELECT query with multiple WHERE conditions', () => { - const query = User.select().where({ active: true, age: 25 }).getQuery(); - expect(query).to.equal( - 'SELECT * FROM "users" AS "User" WHERE "User"."active" = true AND "User"."age" = 25;', - ); - }); - - it('should generate complete SELECT query with attributes and WHERE', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - expect(query).to.equal( - 'SELECT "name", "email" FROM "users" AS "User" WHERE "User"."active" = true;', - ); - }); - }); - - describe('Functional/Immutable behavior', () => { - it('should return new instances for each method call', () => { - const builder1 = User.select(); - const builder2 = builder1.attributes(['name']); - const builder3 = builder2.where({ active: true }); - - expect(builder1).to.not.equal(builder2); - expect(builder2).to.not.equal(builder3); - expect(builder1).to.not.equal(builder3); - }); - - it('should not mutate original builder when chaining', () => { - const baseBuilder = User.select(); - const builderWithAttributes = baseBuilder.attributes(['name']); - const builderWithWhere = baseBuilder.where({ active: true }); - - // Base builder should remain unchanged - const baseQuery = baseBuilder.getQuery(); - expect(baseQuery).to.equal('SELECT * FROM "users" AS "User";'); - - // Other builders should have their modifications - const attributesQuery = builderWithAttributes.getQuery(); - expect(attributesQuery).to.equal('SELECT "name" FROM "users" AS "User";'); - - const whereQuery = builderWithWhere.getQuery(); - expect(whereQuery).to.equal( - 'SELECT * FROM "users" AS "User" WHERE "User"."active" = true;', - ); - }); - - it('should allow building different queries from same base', () => { - const baseBuilder = User.select().attributes(['name', 'email']); - - const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); - const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); - - expect(activeUsersQuery).to.equal( - 'SELECT "name", "email" FROM "users" AS "User" WHERE "User"."active" = true;', - ); - expect(youngUsersQuery).to.equal( - 'SELECT "name", "email" FROM "users" AS "User" WHERE "User"."age" < 30;', - ); - }); - }); - - describe('PostgreSQL-specific features', () => { - it('should handle PostgreSQL operators correctly', () => { - const query = User.select() - .where({ - name: { [Op.iLike]: '%john%' }, - age: { [Op.between]: [18, 65] }, - }) - .getQuery(); - expect(query).to.include('ILIKE'); - expect(query).to.include('BETWEEN'); - }); - - it('should handle array operations', () => { - const query = User.select() - .where({ - name: { [Op.in]: ['John', 'Jane', 'Bob'] }, - }) - .getQuery(); - expect(query).to.include('IN'); - expect(query).to.include("'John'"); - expect(query).to.include("'Jane'"); - expect(query).to.include("'Bob'"); - }); - - it('should quote identifiers properly for PostgreSQL', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - - // PostgreSQL uses double quotes for identifiers - expect(query).to.include('"name"'); - expect(query).to.include('"email"'); - expect(query).to.include('"users"'); - expect(query).to.include('"User"'); - expect(query).to.include('"User"."active"'); - }); - }); - - describe('Error handling', () => { - it('should throw error when getQuery is called on non-select builder', () => { - expect(() => { - const builder = new (User.select().constructor)(User); - builder.getQuery(); - }).to.throw(); - }); - - it('should handle empty attributes array', () => { - expect(() => { - User.select().attributes([]).getQuery(); - }).to.throw( - 'Attempted a SELECT query for model \'User\' as "User" without selecting any columns', - ); - }); - - it('should handle null/undefined where conditions gracefully', () => { - // null where should throw an error as it's invalid - expect(() => { - User.select().where(null).getQuery(); - }).to.throw(); - - // undefined where should work (no where clause) - expect(() => { - User.select().where(undefined).getQuery(); - }).to.not.throw(); - }); - }); - - describe('Integration with different models', () => { - it('should work with different models', () => { - const userQuery = User.select().attributes(['name']).getQuery(); - const postQuery = Post.select().attributes(['title']).getQuery(); - - expect(userQuery).to.equal('SELECT "name" FROM "users" AS "User";'); - expect(postQuery).to.equal('SELECT "title" FROM "posts" AS "Post";'); - }); - - it('should use correct table names and aliases', () => { - const query = User.select().getQuery(); - expect(query).to.include('"users"'); - expect(query).to.include('AS "User"'); - - const postQuery = Post.select().getQuery(); - expect(postQuery).to.include('"posts"'); - expect(postQuery).to.include('AS "Post"'); - }); - }); - - describe('Complex WHERE conditions', () => { - it('should handle complex nested conditions', () => { - const query = User.select() - .where({ - [Op.or]: [ - { active: true }, - { - [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], - }, - ], - }) - .getQuery(); - - expect(query).to.include('OR'); - expect(query).to.include('AND'); - expect(query).to.be.a('string'); - }); - - it('should handle IS NULL conditions', () => { - const query = User.select().where({ age: null }).getQuery(); - expect(query).to.include('IS NULL'); - }); - - it('should handle NOT NULL conditions', () => { - const query = User.select() - .where({ age: { [Op.ne]: null } }) - .getQuery(); - expect(query).to.include('IS NOT NULL'); - }); - }); - }); -} diff --git a/packages/core/test/integration/dialects/sqlite/query-builder.test.js b/packages/core/test/integration/dialects/sqlite/query-builder.test.js deleted file mode 100644 index e6e5dca4f86d..000000000000 --- a/packages/core/test/integration/dialects/sqlite/query-builder.test.js +++ /dev/null @@ -1,287 +0,0 @@ -'use strict'; - -const chai = require('chai'); - -const expect = chai.expect; -const Support = require('../../support'); -const { DataTypes, Op } = require('@sequelize/core'); - -const dialect = Support.getTestDialect(); - -if (dialect.startsWith('sqlite')) { - describe('[SQLITE] QueryBuilder', () => { - let sequelize; - let User; - let Post; - - beforeEach(async () => { - sequelize = Support.createSingleTestSequelizeInstance(); - - User = sequelize.define( - 'User', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - email: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - }, - active: { - type: DataTypes.BOOLEAN, - defaultValue: true, - }, - age: { - type: DataTypes.INTEGER, - }, - }, - { - tableName: 'users', - }, - ); - - Post = sequelize.define( - 'Post', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - content: { - type: DataTypes.TEXT, - }, - published: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, - }, - { - tableName: 'posts', - }, - ); - - User.hasMany(Post, { foreignKey: 'userId' }); - Post.belongsTo(User, { foreignKey: 'userId' }); - }); - - afterEach(() => { - return sequelize?.close(); - }); - - describe('Basic QueryBuilder functionality', () => { - it('should generate basic SELECT query', () => { - const query = User.select().getQuery(); - expect(query).to.equal('SELECT * FROM `users` AS `User`;'); - }); - - it('should generate SELECT query with specific attributes', () => { - const query = User.select().attributes(['name', 'email']).getQuery(); - expect(query).to.equal('SELECT `name`, `email` FROM `users` AS `User`;'); - }); - - it('should generate SELECT query with WHERE clause', () => { - const query = User.select().where({ active: true }).getQuery(); - expect(query).to.equal('SELECT * FROM `users` AS `User` WHERE `User`.`active` = 1;'); - }); - - it('should generate SELECT query with multiple WHERE conditions', () => { - const query = User.select().where({ active: true, age: 25 }).getQuery(); - expect(query).to.equal( - 'SELECT * FROM `users` AS `User` WHERE `User`.`active` = 1 AND `User`.`age` = 25;', - ); - }); - - it('should generate complete SELECT query with attributes and WHERE', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - expect(query).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = 1;', - ); - }); - }); - - describe('Functional/Immutable behavior', () => { - it('should return new instances for each method call', () => { - const builder1 = User.select(); - const builder2 = builder1.attributes(['name']); - const builder3 = builder2.where({ active: true }); - - expect(builder1).to.not.equal(builder2); - expect(builder2).to.not.equal(builder3); - expect(builder1).to.not.equal(builder3); - }); - - it('should not mutate original builder when chaining', () => { - const baseBuilder = User.select(); - const builderWithAttributes = baseBuilder.attributes(['name']); - const builderWithWhere = baseBuilder.where({ active: true }); - - // Base builder should remain unchanged - const baseQuery = baseBuilder.getQuery(); - expect(baseQuery).to.equal('SELECT * FROM `users` AS `User`;'); - - // Other builders should have their modifications - const attributesQuery = builderWithAttributes.getQuery(); - expect(attributesQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); - - const whereQuery = builderWithWhere.getQuery(); - expect(whereQuery).to.equal('SELECT * FROM `users` AS `User` WHERE `User`.`active` = 1;'); - }); - - it('should allow building different queries from same base', () => { - const baseBuilder = User.select().attributes(['name', 'email']); - - const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); - const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); - - expect(activeUsersQuery).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = 1;', - ); - expect(youngUsersQuery).to.equal( - 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`age` < 30;', - ); - }); - }); - - describe('SQLite-specific features', () => { - it('should handle SQLite operators correctly', () => { - const query = User.select() - .where({ - name: { [Op.like]: '%john%' }, - age: { [Op.between]: [18, 65] }, - }) - .getQuery(); - expect(query).to.include('LIKE'); - expect(query).to.include('BETWEEN'); - }); - - it('should handle array operations', () => { - const query = User.select() - .where({ - name: { [Op.in]: ['John', 'Jane', 'Bob'] }, - }) - .getQuery(); - expect(query).to.include('IN'); - expect(query).to.include("'John'"); - expect(query).to.include("'Jane'"); - expect(query).to.include("'Bob'"); - }); - - it('should quote identifiers properly for SQLite', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - - // SQLite uses backticks for identifiers - expect(query).to.include('`name`'); - expect(query).to.include('`email`'); - expect(query).to.include('`users`'); - expect(query).to.include('`User`'); - expect(query).to.include('`User`.`active`'); - }); - - it('should handle boolean values as 1/0', () => { - const query = User.select().where({ active: true }).getQuery(); - expect(query).to.include('= 1'); - - const falseQuery = User.select().where({ active: false }).getQuery(); - expect(falseQuery).to.include('= 0'); - }); - }); - - describe('Error handling', () => { - it('should throw error when getQuery is called on non-select builder', () => { - expect(() => { - const builder = new (User.select().constructor)(User); - builder.getQuery(); - }).to.throw(); - }); - - it('should handle empty attributes array', () => { - expect(() => { - User.select().attributes([]).getQuery(); - }).to.throw( - "Attempted a SELECT query for model 'User' as `User` without selecting any columns", - ); - }); - - it('should handle null/undefined where conditions gracefully', () => { - // null where should throw an error as it's invalid - expect(() => { - User.select().where(null).getQuery(); - }).to.throw(); - - // undefined where should work (no where clause) - expect(() => { - User.select().where(undefined).getQuery(); - }).to.not.throw(); - }); - }); - - describe('Integration with different models', () => { - it('should work with different models', () => { - const userQuery = User.select().attributes(['name']).getQuery(); - const postQuery = Post.select().attributes(['title']).getQuery(); - - expect(userQuery).to.equal('SELECT `name` FROM `users` AS `User`;'); - expect(postQuery).to.equal('SELECT `title` FROM `posts` AS `Post`;'); - }); - - it('should use correct table names and aliases', () => { - const query = User.select().getQuery(); - expect(query).to.include('`users`'); - expect(query).to.include('AS `User`'); - - const postQuery = Post.select().getQuery(); - expect(postQuery).to.include('`posts`'); - expect(postQuery).to.include('AS `Post`'); - }); - }); - - describe('Complex WHERE conditions', () => { - it('should handle complex nested conditions', () => { - const query = User.select() - .where({ - [Op.or]: [ - { active: true }, - { - [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], - }, - ], - }) - .getQuery(); - - expect(query).to.include('OR'); - expect(query).to.include('AND'); - expect(query).to.be.a('string'); - }); - - it('should handle IS NULL conditions', () => { - const query = User.select().where({ age: null }).getQuery(); - expect(query).to.include('IS NULL'); - }); - - it('should handle NOT NULL conditions', () => { - const query = User.select() - .where({ age: { [Op.ne]: null } }) - .getQuery(); - expect(query).to.include('IS NOT NULL'); - }); - }); - }); -} diff --git a/packages/core/test/integration/query-builder/query-builder.test.js b/packages/core/test/integration/query-builder/query-builder.test.js new file mode 100644 index 000000000000..c8e170cb9a64 --- /dev/null +++ b/packages/core/test/integration/query-builder/query-builder.test.js @@ -0,0 +1,292 @@ +'use strict'; + +const chai = require('chai'); + +const expect = chai.expect; +const Support = require('../../support'); +const { DataTypes, Op } = require('@sequelize/core'); +const { QueryBuilder } = require('../../../lib/expression-builders/query-builder'); + +const dialect = Support.getTestDialect(); + +// Get the appropriate quote character for the current dialect +function getQuoteChar(dialectName) { + switch (dialectName) { + case 'postgres': + case 'snowflake': + return '"'; + case 'mysql': + case 'mariadb': + case 'sqlite3': + return '`'; + case 'mssql': + return ['[', ']']; + default: + return '"'; // default to double quotes + } +} + +const quoteChar = getQuoteChar(dialect); +const openQuote = Array.isArray(quoteChar) ? quoteChar[0] : quoteChar; +const closeQuote = Array.isArray(quoteChar) ? quoteChar[1] : quoteChar; + +// Helper function to quote identifiers +function q(identifier) { + return `${openQuote}${identifier}${closeQuote}`; +} + +function b(bool) { + switch (dialect) { + case 'mssql': + case 'sqlite3': + return bool === 'true' ? '1' : '0'; + default: + return bool; + } +} + +describe(Support.getTestDialectTeaser('QueryBuilder'), () => { + let sequelize; + let User; + let Post; + + beforeEach(async () => { + sequelize = Support.createSequelizeInstance(); + + User = sequelize.define( + 'User', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + age: { + type: DataTypes.INTEGER, + }, + }, + { + tableName: 'users', + }, + ); + + Post = sequelize.define( + 'Post', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + content: { + type: DataTypes.TEXT, + }, + published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + }, + { + tableName: 'posts', + }, + ); + + User.hasMany(Post, { foreignKey: 'userId' }); + Post.belongsTo(User, { foreignKey: 'userId' }); + }); + + afterEach(() => { + return sequelize?.close(); + }); + + describe('Basic QueryBuilder functionality', () => { + it('should generate basic SELECT query', () => { + const query = User.select().getQuery(); + const expected = `SELECT * FROM ${q('users')} AS ${q('User')};`; + expect(query).to.equal(expected); + }); + + it('should generate SELECT query with specific attributes', () => { + const query = User.select().attributes(['name', 'email']).getQuery(); + const expected = `SELECT ${q('name')}, ${q('email')} FROM ${q('users')} AS ${q('User')};`; + expect(query).to.equal(expected); + }); + + it('should generate SELECT query with WHERE clause', () => { + const query = User.select().where({ active: true }).getQuery(); + const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')};`; + expect(query).to.equal(expected); + }); + + it('should generate SELECT query with multiple WHERE conditions', () => { + const query = User.select().where({ active: true, age: 25 }).getQuery(); + const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')} AND ${q('User')}.${q('age')} = 25;`; + expect(query).to.equal(expected); + }); + + it('should generate complete SELECT query with attributes and WHERE', () => { + const query = User.select().attributes(['name', 'email']).where({ active: true }).getQuery(); + const expected = `SELECT ${q('name')}, ${q('email')} FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')};`; + expect(query).to.equal(expected); + }); + }); + + describe('Functional/Immutable behavior', () => { + it('should return new instances for each method call', () => { + const builder1 = User.select(); + const builder2 = builder1.attributes(['name']); + const builder3 = builder2.where({ active: true }); + + expect(builder1).to.not.equal(builder2); + expect(builder2).to.not.equal(builder3); + expect(builder1).to.not.equal(builder3); + }); + + it('should not mutate original builder when chaining', () => { + const baseBuilder = User.select(); + const builderWithAttributes = baseBuilder.attributes(['name']); + const builderWithWhere = baseBuilder.where({ active: true }); + + // Base builder should remain unchanged + const baseQuery = baseBuilder.getQuery(); + const expectedBase = `SELECT * FROM ${q('users')} AS ${q('User')};`; + expect(baseQuery).to.equal(expectedBase); + + // Other builders should have their modifications + const attributesQuery = builderWithAttributes.getQuery(); + const expectedAttributes = `SELECT ${q('name')} FROM ${q('users')} AS ${q('User')};`; + expect(attributesQuery).to.equal(expectedAttributes); + + const whereQuery = builderWithWhere.getQuery(); + const expectedWhere = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')};`; + expect(whereQuery).to.equal(expectedWhere); + }); + + it('should allow building different queries from same base', () => { + const baseBuilder = User.select().attributes(['name', 'email']); + + const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); + const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); + + const expectedActive = `SELECT ${q('name')}, ${q('email')} FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')};`; + const expectedYoung = `SELECT ${q('name')}, ${q('email')} FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('age')} < 30;`; + + expect(activeUsersQuery).to.equal(expectedActive); + expect(youngUsersQuery).to.equal(expectedYoung); + }); + }); + + if (dialect.startsWith('postgres')) { + describe('PostgreSQL-specific features', () => { + it('should handle PostgreSQL operators correctly', () => { + const query = User.select() + .where({ + name: { [Op.iLike]: '%john%' }, + age: { [Op.between]: [18, 65] }, + }) + .getQuery(); + const expected = `SELECT * FROM "users" AS "User" WHERE "User"."name" ILIKE '%john%' AND ("User"."age" BETWEEN 18 AND 65);`; + expect(query).to.equal(expected); + }); + + it('should handle array operations', () => { + const query = User.select() + .where({ + name: { [Op.in]: ['John', 'Jane', 'Bob'] }, + }) + .getQuery(); + const expected = `SELECT * FROM "users" AS "User" WHERE "User"."name" IN ('John', 'Jane', 'Bob');`; + expect(query).to.equal(expected); + }); + + it('should quote identifiers properly for PostgreSQL', () => { + const query = User.select() + .attributes(['name', 'email']) + .where({ active: true }) + .getQuery(); + + const expected = `SELECT "name", "email" FROM "users" AS "User" WHERE "User"."active" = true;`; + expect(query).to.equal(expected); + }); + }); + } + + describe('Error handling', () => { + it('should throw error when getQuery is called on non-select builder', () => { + expect(() => { + const builder = new QueryBuilder(User); + builder.getQuery(); + }).to.throw(); + }); + + it('should handle empty attributes array', () => { + expect(() => { + User.select().attributes([]).getQuery(); + }).to.throw( + `Attempted a SELECT query for model 'User' as ${q('User')} without selecting any columns`, + ); + }); + + it('should handle null/undefined where conditions gracefully', () => { + // null where should throw an error as it's invalid + expect(() => { + User.select().where(null).getQuery(); + }).to.throw(); + + // undefined where should work (no where clause) + expect(() => { + User.select().where(undefined).getQuery(); + }).to.not.throw(); + }); + }); + + describe('Complex WHERE conditions', () => { + it('should handle complex nested conditions', () => { + const query = User.select() + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], + }, + ], + }) + .getQuery(); + + const likePrefix = dialect === 'mssql' ? 'N' : ''; + const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')} OR (${q('User')}.${q('age')} >= 18 AND ${q('User')}.${q('name')} LIKE ${likePrefix}'%admin%');`; + expect(query).to.equal(expected); + }); + + it('should handle IS NULL conditions', () => { + const query = User.select().where({ age: null }).getQuery(); + const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('age')} IS NULL;`; + expect(query).to.equal(expected); + }); + + it('should handle NOT NULL conditions', () => { + const query = User.select() + .where({ age: { [Op.ne]: null } }) + .getQuery(); + const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('age')} IS NOT NULL;`; + expect(query).to.equal(expected); + }); + }); +}); From 0e3503d2a5d8fc5213a68b7bfd3197a4af4edb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sun, 6 Jul 2025 18:19:19 -0300 Subject: [PATCH 06/14] add execute method to query builder --- packages/core/run_for_all_dialects.sh | 19 +++++++++++++++++++ .../src/expression-builders/query-builder.ts | 14 ++++++++++++++ .../query-builder/query-builder.test.js | 14 ++++++++++++++ 3 files changed, 47 insertions(+) create mode 100755 packages/core/run_for_all_dialects.sh diff --git a/packages/core/run_for_all_dialects.sh b/packages/core/run_for_all_dialects.sh new file mode 100755 index 000000000000..ce3c29e795a7 --- /dev/null +++ b/packages/core/run_for_all_dialects.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Runs tests for all dialects +# Specify the test you want to run on .mocharc.jsonc on packages/core with the following content: +# { +# "file": "test/integration/query-builder/query-builder.test.js" +# } +# See https://github.com/sequelize/sequelize/blob/main/CONTRIBUTING.md#41-running-only-some-tests +# Remember to run the `start.sh` scripts for the dialects you want to test from the dev folder. + +DIALECT=postgres yarn mocha && \ +DIALECT=mysql yarn mocha && \ +DIALECT=mssql yarn mocha && \ +DIALECT=sqlite3 yarn mocha && \ +DIALECT=mariadb yarn mocha && \ +# DIALECT=snowflake yarn mocha && \ ## Experimental +# DIALECT=ibmi yarn mocha && \ ## Experimental +# DIALECT=db2 yarn mocha && \ ## No matching manifest for arm64 +echo "Done" \ No newline at end of file diff --git a/packages/core/src/expression-builders/query-builder.ts b/packages/core/src/expression-builders/query-builder.ts index aa2787998ffb..f663b9000e21 100644 --- a/packages/core/src/expression-builders/query-builder.ts +++ b/packages/core/src/expression-builders/query-builder.ts @@ -1,5 +1,6 @@ import type { WhereOptions } from '../abstract-dialect/where-sql-builder-types.js'; import type { FindAttributeOptions, Model, ModelStatic } from '../model.d.ts'; +import type { Sequelize } from '../sequelize.js'; import { BaseSqlExpression, SQL_IDENTIFIER } from './base-sql-expression.js'; /** @@ -11,11 +12,13 @@ export class QueryBuilder extends BaseSqlExpression { private readonly _model: ModelStatic; private _attributes?: FindAttributeOptions; private _where?: WhereOptions; + private readonly _sequelize: Sequelize; private _isSelect: boolean = false; constructor(model: ModelStatic) { super(); this._model = model; + this._sequelize = model.sequelize; } /** @@ -97,6 +100,17 @@ export class QueryBuilder extends BaseSqlExpression { return sql; } + /** + * Executes the raw query + * + * @returns The result of the query + */ + async execute(): Promise<[unknown[], unknown]> { + const sql = this.getQuery(); + + return this._sequelize.queryRaw(sql); + } + /** * Get the table name for this query * diff --git a/packages/core/test/integration/query-builder/query-builder.test.js b/packages/core/test/integration/query-builder/query-builder.test.js index c8e170cb9a64..d7fc06520522 100644 --- a/packages/core/test/integration/query-builder/query-builder.test.js +++ b/packages/core/test/integration/query-builder/query-builder.test.js @@ -110,6 +110,8 @@ describe(Support.getTestDialectTeaser('QueryBuilder'), () => { User.hasMany(Post, { foreignKey: 'userId' }); Post.belongsTo(User, { foreignKey: 'userId' }); + await User.sync(); + await User.truncate(); }); afterEach(() => { @@ -289,4 +291,16 @@ describe(Support.getTestDialectTeaser('QueryBuilder'), () => { expect(query).to.equal(expected); }); }); + + describe('execute', () => { + it('should execute the query', async () => { + await User.create({ name: 'John', email: 'john@example.com', active: true }); + const result = await User.select() + .attributes(['name']) + .where({ active: true, name: 'John' }) + .execute(); + const [row] = result; + expect(row).to.deep.equal([{ name: 'John' }]); + }); + }); }); From 80861b6f087cf2b49dae387282c9246f74d757db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sun, 6 Jul 2025 22:38:58 -0300 Subject: [PATCH 07/14] Implement limit + offset, refactor test to use expectsql --- .../src/expression-builders/query-builder.ts | 76 ++++-- .../query-builder/query-builder.test.js | 227 +++++++++--------- 2 files changed, 168 insertions(+), 135 deletions(-) diff --git a/packages/core/src/expression-builders/query-builder.ts b/packages/core/src/expression-builders/query-builder.ts index f663b9000e21..e31e81bfb317 100644 --- a/packages/core/src/expression-builders/query-builder.ts +++ b/packages/core/src/expression-builders/query-builder.ts @@ -1,3 +1,4 @@ +import type { SelectOptions } from '../abstract-dialect/query-generator.js'; import type { WhereOptions } from '../abstract-dialect/where-sql-builder-types.js'; import type { FindAttributeOptions, Model, ModelStatic } from '../model.d.ts'; import type { Sequelize } from '../sequelize.js'; @@ -10,8 +11,10 @@ export class QueryBuilder extends BaseSqlExpression { declare protected readonly [SQL_IDENTIFIER]: 'queryBuilder'; private readonly _model: ModelStatic; - private _attributes?: FindAttributeOptions; + private _attributes?: FindAttributeOptions | undefined; private _where?: WhereOptions; + private _limit?: number | undefined; + private _offset?: number | undefined; private readonly _sequelize: Sequelize; private _isSelect: boolean = false; @@ -21,6 +24,22 @@ export class QueryBuilder extends BaseSqlExpression { this._sequelize = model.sequelize; } + /** + * Creates a clone of the current query builder instance with all properties copied over + * + * @returns A new QueryBuilder instance with the same properties + */ + clone(): QueryBuilder { + const newBuilder = new QueryBuilder(this._model); + newBuilder._isSelect = this._isSelect; + newBuilder._attributes = this._attributes; + newBuilder._where = this._where; + newBuilder._limit = this._limit; + newBuilder._offset = this._offset; + + return newBuilder; + } + /** * Initialize a SELECT query * @@ -29,13 +48,6 @@ export class QueryBuilder extends BaseSqlExpression { select(): QueryBuilder { const newBuilder = new QueryBuilder(this._model); newBuilder._isSelect = true; - if (this._attributes !== undefined) { - newBuilder._attributes = this._attributes; - } - - if (this._where !== undefined) { - newBuilder._where = this._where; - } return newBuilder; } @@ -47,10 +59,8 @@ export class QueryBuilder extends BaseSqlExpression { * @returns The query builder instance for chaining */ attributes(attributes: FindAttributeOptions): QueryBuilder { - const newBuilder = new QueryBuilder(this._model); - newBuilder._isSelect = this._isSelect; + const newBuilder = this.clone(); newBuilder._attributes = attributes; - newBuilder._where = this._where; return newBuilder; } @@ -62,17 +72,38 @@ export class QueryBuilder extends BaseSqlExpression { * @returns The query builder instance for chaining */ where(conditions: WhereOptions): QueryBuilder { - const newBuilder = new QueryBuilder(this._model); - newBuilder._isSelect = this._isSelect; - if (this._attributes !== undefined) { - newBuilder._attributes = this._attributes; - } - + const newBuilder = this.clone(); newBuilder._where = conditions; return newBuilder; } + /** + * Set a LIMIT clause on the query + * + * @param limit - Maximum number of rows to return + * @returns The query builder instance for chaining + */ + limit(limit: number): QueryBuilder { + const newBuilder = this.clone(); + newBuilder._limit = limit; + + return newBuilder; + } + + /** + * Set an OFFSET clause on the query + * + * @param offset - Number of rows to skip + * @returns The query builder instance for chaining + */ + offset(offset: number): QueryBuilder { + const newBuilder = this.clone(); + newBuilder._offset = offset; + + return newBuilder; + } + /** * Generate the SQL query string * @@ -84,14 +115,17 @@ export class QueryBuilder extends BaseSqlExpression { } const queryGenerator = this._model.queryGenerator; - const tableName = this._model.tableName; + const tableName = this.tableName; // Build the options object that matches Sequelize's FindOptions pattern - const options: any = { - attributes: this._attributes, + const options: SelectOptions = { + attributes: this._attributes as any, where: this._where, + limit: this._limit, + offset: this._offset, raw: true, plain: false, + model: this._model, }; // Generate the SQL using the existing query generator @@ -117,7 +151,7 @@ export class QueryBuilder extends BaseSqlExpression { * @returns The table name */ get tableName(): string { - return this._model.tableName; + return this._model.table.tableName; } /** diff --git a/packages/core/test/integration/query-builder/query-builder.test.js b/packages/core/test/integration/query-builder/query-builder.test.js index d7fc06520522..c5f2afaeedd7 100644 --- a/packages/core/test/integration/query-builder/query-builder.test.js +++ b/packages/core/test/integration/query-builder/query-builder.test.js @@ -6,45 +6,10 @@ const expect = chai.expect; const Support = require('../../support'); const { DataTypes, Op } = require('@sequelize/core'); const { QueryBuilder } = require('../../../lib/expression-builders/query-builder'); +const { expectsql } = require('../../support'); const dialect = Support.getTestDialect(); -// Get the appropriate quote character for the current dialect -function getQuoteChar(dialectName) { - switch (dialectName) { - case 'postgres': - case 'snowflake': - return '"'; - case 'mysql': - case 'mariadb': - case 'sqlite3': - return '`'; - case 'mssql': - return ['[', ']']; - default: - return '"'; // default to double quotes - } -} - -const quoteChar = getQuoteChar(dialect); -const openQuote = Array.isArray(quoteChar) ? quoteChar[0] : quoteChar; -const closeQuote = Array.isArray(quoteChar) ? quoteChar[1] : quoteChar; - -// Helper function to quote identifiers -function q(identifier) { - return `${openQuote}${identifier}${closeQuote}`; -} - -function b(bool) { - switch (dialect) { - case 'mssql': - case 'sqlite3': - return bool === 'true' ? '1' : '0'; - default: - return bool; - } -} - describe(Support.getTestDialectTeaser('QueryBuilder'), () => { let sequelize; let User; @@ -120,33 +85,53 @@ describe(Support.getTestDialectTeaser('QueryBuilder'), () => { describe('Basic QueryBuilder functionality', () => { it('should generate basic SELECT query', () => { - const query = User.select().getQuery(); - const expected = `SELECT * FROM ${q('users')} AS ${q('User')};`; - expect(query).to.equal(expected); + expectsql(() => User.select().getQuery(), { + default: `SELECT * FROM [users] AS [User];`, + }); }); it('should generate SELECT query with specific attributes', () => { - const query = User.select().attributes(['name', 'email']).getQuery(); - const expected = `SELECT ${q('name')}, ${q('email')} FROM ${q('users')} AS ${q('User')};`; - expect(query).to.equal(expected); + expectsql(() => User.select().attributes(['name', 'email']).getQuery(), { + default: `SELECT [name], [email] FROM [users] AS [User];`, + }); }); it('should generate SELECT query with WHERE clause', () => { - const query = User.select().where({ active: true }).getQuery(); - const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')};`; - expect(query).to.equal(expected); + expectsql(() => User.select().where({ active: true }).getQuery(), { + default: `SELECT * FROM [users] AS [User] WHERE [User].[active] = true;`, + 'mssql sqlite3': `SELECT * FROM [users] AS [User] WHERE [User].[active] = 1;`, + }); }); it('should generate SELECT query with multiple WHERE conditions', () => { - const query = User.select().where({ active: true, age: 25 }).getQuery(); - const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')} AND ${q('User')}.${q('age')} = 25;`; - expect(query).to.equal(expected); + expectsql(() => User.select().where({ active: true, age: 25 }).getQuery(), { + default: `SELECT * FROM [users] AS [User] WHERE [User].[active] = true AND [User].[age] = 25;`, + 'mssql sqlite3': `SELECT * FROM [users] AS [User] WHERE [User].[active] = 1 AND [User].[age] = 25;`, + }); }); it('should generate complete SELECT query with attributes and WHERE', () => { - const query = User.select().attributes(['name', 'email']).where({ active: true }).getQuery(); - const expected = `SELECT ${q('name')}, ${q('email')} FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')};`; - expect(query).to.equal(expected); + expectsql( + () => User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), + { + default: `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;`, + 'mssql sqlite3': `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;`, + }, + ); + }); + + it('should generate SELECT query with LIMIT', () => { + expectsql(() => User.select().limit(10).getQuery(), { + default: `SELECT * FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10;`, + mssql: `SELECT * FROM [users] AS [User] ORDER BY [User].[id] OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;`, + }); + }); + + it('should generate SELECT query with LIMIT and OFFSET', () => { + expectsql(() => User.select().limit(10).offset(5).getQuery(), { + default: `SELECT * FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10 OFFSET 5;`, + mssql: `SELECT * FROM [users] AS [User] ORDER BY [User].[id] OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY;`, + }); }); }); @@ -167,65 +152,73 @@ describe(Support.getTestDialectTeaser('QueryBuilder'), () => { const builderWithWhere = baseBuilder.where({ active: true }); // Base builder should remain unchanged - const baseQuery = baseBuilder.getQuery(); - const expectedBase = `SELECT * FROM ${q('users')} AS ${q('User')};`; - expect(baseQuery).to.equal(expectedBase); + expectsql(() => baseBuilder.getQuery(), { + default: `SELECT * FROM [users] AS [User];`, + }); // Other builders should have their modifications - const attributesQuery = builderWithAttributes.getQuery(); - const expectedAttributes = `SELECT ${q('name')} FROM ${q('users')} AS ${q('User')};`; - expect(attributesQuery).to.equal(expectedAttributes); + expectsql(() => builderWithAttributes.getQuery(), { + default: `SELECT [name] FROM [users] AS [User];`, + }); - const whereQuery = builderWithWhere.getQuery(); - const expectedWhere = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')};`; - expect(whereQuery).to.equal(expectedWhere); + expectsql(() => builderWithWhere.getQuery(), { + default: `SELECT * FROM [users] AS [User] WHERE [User].[active] = true;`, + 'mssql sqlite3': `SELECT * FROM [users] AS [User] WHERE [User].[active] = 1;`, + }); }); it('should allow building different queries from same base', () => { const baseBuilder = User.select().attributes(['name', 'email']); - const activeUsersQuery = baseBuilder.where({ active: true }).getQuery(); - const youngUsersQuery = baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(); - - const expectedActive = `SELECT ${q('name')}, ${q('email')} FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')};`; - const expectedYoung = `SELECT ${q('name')}, ${q('email')} FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('age')} < 30;`; + expectsql(() => baseBuilder.where({ active: true }).getQuery(), { + default: `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;`, + 'mssql sqlite3': `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;`, + }); - expect(activeUsersQuery).to.equal(expectedActive); - expect(youngUsersQuery).to.equal(expectedYoung); + expectsql(() => baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(), { + default: `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[age] < 30;`, + }); }); }); if (dialect.startsWith('postgres')) { describe('PostgreSQL-specific features', () => { it('should handle PostgreSQL operators correctly', () => { - const query = User.select() - .where({ - name: { [Op.iLike]: '%john%' }, - age: { [Op.between]: [18, 65] }, - }) - .getQuery(); - const expected = `SELECT * FROM "users" AS "User" WHERE "User"."name" ILIKE '%john%' AND ("User"."age" BETWEEN 18 AND 65);`; - expect(query).to.equal(expected); + expectsql( + () => + User.select() + .where({ + name: { [Op.iLike]: '%john%' }, + age: { [Op.between]: [18, 65] }, + }) + .getQuery(), + { + default: `SELECT * FROM [users] AS [User] WHERE [User].[name] ILIKE '%john%' AND ([User].[age] BETWEEN 18 AND 65);`, + }, + ); }); it('should handle array operations', () => { - const query = User.select() - .where({ - name: { [Op.in]: ['John', 'Jane', 'Bob'] }, - }) - .getQuery(); - const expected = `SELECT * FROM "users" AS "User" WHERE "User"."name" IN ('John', 'Jane', 'Bob');`; - expect(query).to.equal(expected); + expectsql( + () => + User.select() + .where({ + name: { [Op.in]: ['John', 'Jane', 'Bob'] }, + }) + .getQuery(), + { + default: `SELECT * FROM [users] AS [User] WHERE [User].[name] IN ('John', 'Jane', 'Bob');`, + }, + ); }); it('should quote identifiers properly for PostgreSQL', () => { - const query = User.select() - .attributes(['name', 'email']) - .where({ active: true }) - .getQuery(); - - const expected = `SELECT "name", "email" FROM "users" AS "User" WHERE "User"."active" = true;`; - expect(query).to.equal(expected); + expectsql( + () => User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), + { + default: `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;`, + }, + ); }); }); } @@ -241,9 +234,7 @@ describe(Support.getTestDialectTeaser('QueryBuilder'), () => { it('should handle empty attributes array', () => { expect(() => { User.select().attributes([]).getQuery(); - }).to.throw( - `Attempted a SELECT query for model 'User' as ${q('User')} without selecting any columns`, - ); + }).to.throw(/Attempted a SELECT query for model 'User' as .* without selecting any columns/); }); it('should handle null/undefined where conditions gracefully', () => { @@ -261,34 +252,42 @@ describe(Support.getTestDialectTeaser('QueryBuilder'), () => { describe('Complex WHERE conditions', () => { it('should handle complex nested conditions', () => { - const query = User.select() - .where({ - [Op.or]: [ - { active: true }, - { - [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], - }, - ], - }) - .getQuery(); - - const likePrefix = dialect === 'mssql' ? 'N' : ''; - const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('active')} = ${b('true')} OR (${q('User')}.${q('age')} >= 18 AND ${q('User')}.${q('name')} LIKE ${likePrefix}'%admin%');`; - expect(query).to.equal(expected); + expectsql( + () => + User.select() + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], + }, + ], + }) + .getQuery(), + { + default: `SELECT * FROM [users] AS [User] WHERE [User].[active] = true OR ([User].[age] >= 18 AND [User].[name] LIKE '%admin%');`, + sqlite3: `SELECT * FROM \`users\` AS \`User\` WHERE \`User\`.\`active\` = 1 OR (\`User\`.\`age\` >= 18 AND \`User\`.\`name\` LIKE '%admin%');`, + mssql: `SELECT * FROM [users] AS [User] WHERE [User].[active] = 1 OR ([User].[age] >= 18 AND [User].[name] LIKE N'%admin%');`, + }, + ); }); it('should handle IS NULL conditions', () => { - const query = User.select().where({ age: null }).getQuery(); - const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('age')} IS NULL;`; - expect(query).to.equal(expected); + expectsql(() => User.select().where({ age: null }).getQuery(), { + default: `SELECT * FROM [users] AS [User] WHERE [User].[age] IS NULL;`, + }); }); it('should handle NOT NULL conditions', () => { - const query = User.select() - .where({ age: { [Op.ne]: null } }) - .getQuery(); - const expected = `SELECT * FROM ${q('users')} AS ${q('User')} WHERE ${q('User')}.${q('age')} IS NOT NULL;`; - expect(query).to.equal(expected); + expectsql( + () => + User.select() + .where({ age: { [Op.ne]: null } }) + .getQuery(), + { + default: `SELECT * FROM [users] AS [User] WHERE [User].[age] IS NOT NULL;`, + }, + ); }); }); From e78b0d14e0b39cdb9c55dd6f1d76f696fff56c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sat, 26 Jul 2025 10:24:48 -0300 Subject: [PATCH 08/14] feat: implement remaining features --- packages/core/run_for_all_dialects.sh | 6 +- .../src/abstract-dialect/query-generator.js | 76 +++ .../src/expression-builders/query-builder.ts | 171 ++++- .../query-builder/query-builder.test.ts | 608 ++++++++++++++++++ 4 files changed, 853 insertions(+), 8 deletions(-) create mode 100644 packages/core/test/integration/query-builder/query-builder.test.ts diff --git a/packages/core/run_for_all_dialects.sh b/packages/core/run_for_all_dialects.sh index ce3c29e795a7..8551a88e43c9 100755 --- a/packages/core/run_for_all_dialects.sh +++ b/packages/core/run_for_all_dialects.sh @@ -8,11 +8,11 @@ # See https://github.com/sequelize/sequelize/blob/main/CONTRIBUTING.md#41-running-only-some-tests # Remember to run the `start.sh` scripts for the dialects you want to test from the dev folder. -DIALECT=postgres yarn mocha && \ -DIALECT=mysql yarn mocha && \ -DIALECT=mssql yarn mocha && \ DIALECT=sqlite3 yarn mocha && \ +DIALECT=mysql yarn mocha && \ DIALECT=mariadb yarn mocha && \ +DIALECT=postgres yarn mocha && \ +# DIALECT=mssql yarn mocha && \ # DIALECT=snowflake yarn mocha && \ ## Experimental # DIALECT=ibmi yarn mocha && \ ## Experimental # DIALECT=db2 yarn mocha && \ ## No matching manifest for arm64 diff --git a/packages/core/src/abstract-dialect/query-generator.js b/packages/core/src/abstract-dialect/query-generator.js index 8190b2fc614d..9ba25aaf0e8f 100644 --- a/packages/core/src/abstract-dialect/query-generator.js +++ b/packages/core/src/abstract-dialect/query-generator.js @@ -1590,6 +1590,8 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { topLevelInfo, { minifyAliases: options.minifyAliases }, ); + } else if (include._isCustomJoin) { + joinQuery = this.generateCustomJoin(include, includeAs, topLevelInfo); } else { this._generateSubQueryFilter(include, includeAs, topLevelInfo); joinQuery = this.generateJoin(include, topLevelInfo, options); @@ -1710,6 +1712,80 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { ); } + generateCustomJoin(include, includeAs, topLevelInfo) { + const right = include.model; + const asRight = includeAs.internalAs; + let joinCondition; + let joinWhere; + + if (!include.on) { + throw new Error('Custom joins require an "on" condition to be specified'); + } + + // Handle the custom join condition + joinCondition = this.whereItemsQuery(include.on, { + mainAlias: asRight, + model: include.model, + replacements: topLevelInfo.options?.replacements, + }); + + if (include.where) { + joinWhere = this.whereItemsQuery(include.where, { + mainAlias: asRight, + model: include.model, + replacements: topLevelInfo.options?.replacements, + }); + if (joinWhere) { + if (include.or) { + joinCondition += ` OR ${joinWhere}`; + } else { + joinCondition += ` AND ${joinWhere}`; + } + } + } + + // Handle alias minification like in generateJoin + if (topLevelInfo.options?.minifyAliases && asRight.length > 63) { + const alias = `%${topLevelInfo.options.includeAliases.size}`; + topLevelInfo.options.includeAliases.set(alias, asRight); + } + + // Generate attributes for the joined table + const attributes = []; + const rightAttributes = right.modelDefinition.attributes; + + // Process each attribute based on include.attributes or all attributes + const attributesToInclude = (include.attributes && include.attributes.length > 0) ? include.attributes : Array.from(rightAttributes.keys()); + + for (const attr of attributesToInclude) { + if (typeof attr === 'string') { + // Simple attribute name + const field = rightAttributes.get(attr)?.columnName || attr; + attributes.push(`${this.quoteTable(asRight)}.${this.quoteIdentifier(field)} AS ${this.quoteIdentifier(`${asRight}.${attr}`)}`); + } else if (Array.isArray(attr)) { + // [field, alias] format + const [field, alias] = attr; + if (typeof field === 'string') { + const columnName = rightAttributes.get(field)?.columnName || field; + attributes.push(`${this.quoteTable(asRight)}.${this.quoteIdentifier(columnName)} AS ${this.quoteIdentifier(`${asRight}.${alias}`)}`); + } else { + // Handle complex expressions + attributes.push(`${this.formatSqlExpression(field)} AS ${this.quoteIdentifier(`${asRight}.${alias}`)}`); + } + } + } + + return { + join: include.required ? 'INNER JOIN' : include.right && this._dialect.supports['RIGHT JOIN'] ? 'RIGHT OUTER JOIN' : 'LEFT OUTER JOIN', + body: this.quoteTable(right, { ...topLevelInfo.options, ...include, alias: asRight }), + condition: joinCondition, + attributes: { + main: attributes, + subQuery: [] + } + }; + } + generateJoin(include, topLevelInfo, options) { const association = include.association; const parent = include.parent; diff --git a/packages/core/src/expression-builders/query-builder.ts b/packages/core/src/expression-builders/query-builder.ts index e31e81bfb317..e2e308b1e8c9 100644 --- a/packages/core/src/expression-builders/query-builder.ts +++ b/packages/core/src/expression-builders/query-builder.ts @@ -1,8 +1,37 @@ import type { SelectOptions } from '../abstract-dialect/query-generator.js'; import type { WhereOptions } from '../abstract-dialect/where-sql-builder-types.js'; -import type { FindAttributeOptions, Model, ModelStatic } from '../model.d.ts'; +import type { FindAttributeOptions, GroupOption, Model, ModelStatic, Order, OrderItem } from '../model.d.ts'; +import { Op } from '../operators.js'; import type { Sequelize } from '../sequelize.js'; import { BaseSqlExpression, SQL_IDENTIFIER } from './base-sql-expression.js'; +import type { Col } from './col.js'; +import type { Literal } from './literal.js'; +import type { Where } from './where.js'; + +type QueryBuilderIncludeOptions = { + model: ModelStatic; + as?: string; + on?: Record | Where; + attributes?: FindAttributeOptions; + where?: WhereOptions; + required?: boolean; + joinType?: "LEFT" | "INNER" | "RIGHT"; +}; + +type QueryBuilderGetQueryOptions = { + multiline?: boolean; +}; + +type IncludeOption = { + model: ModelStatic; + as: string; + required: boolean; + right: boolean; + on: Record | Where; + where: WhereOptions, + attributes: FindAttributeOptions | string[]; + _isCustomJoin: boolean; +}; /** * Do not use me directly. Use Model.select() instead. @@ -13,6 +42,10 @@ export class QueryBuilder extends BaseSqlExpression { private readonly _model: ModelStatic; private _attributes?: FindAttributeOptions | undefined; private _where?: WhereOptions; + private _group: GroupOption | undefined; + private _having: Literal[] | undefined; + private _order: Order | undefined; + private _include: IncludeOption[]; private _limit?: number | undefined; private _offset?: number | undefined; private readonly _sequelize: Sequelize; @@ -22,6 +55,7 @@ export class QueryBuilder extends BaseSqlExpression { super(); this._model = model; this._sequelize = model.sequelize; + this._include = []; } /** @@ -33,9 +67,13 @@ export class QueryBuilder extends BaseSqlExpression { const newBuilder = new QueryBuilder(this._model); newBuilder._isSelect = this._isSelect; newBuilder._attributes = this._attributes; + newBuilder._group = this._group; + newBuilder._having = this._having; newBuilder._where = this._where; + newBuilder._order = this._order; newBuilder._limit = this._limit; newBuilder._offset = this._offset; + newBuilder._include = this._include.map((include) => ({ ...include })); return newBuilder; } @@ -78,6 +116,58 @@ export class QueryBuilder extends BaseSqlExpression { return newBuilder; } + /** + * Sets the GROUP BY clause for the query + * + * @param group + * @returns The query builder instance for chaining + */ + groupBy(group: GroupOption): QueryBuilder { + const newBuilder = this.clone(); + newBuilder._group = group; + + return newBuilder; + } + + /** + * Sets the HAVING clause for the query (supports only Literal condition) + * + * @param having + * @returns The query builder instance for chaining + */ + having(having: Literal): QueryBuilder { + const newBuilder = this.clone(); + newBuilder._having = [having]; + + return newBuilder; + } + + /** + * Allows chaining of additional HAVING conditions + * + * @param having + * @returns The query builder instance for chaining + */ + andHaving(having: Literal): QueryBuilder { + const newBuilder = this.clone(); + newBuilder._having = [...newBuilder._having || [], having]; + + return newBuilder; + } + + /** + * Set the ORDER BY clause for the query + * + * @param order - The order to apply to the query + * @returns The query builder instance for chaining + */ + orderBy(order: OrderItem[]): QueryBuilder { + const newBuilder = this.clone(); + newBuilder._order = order; + + return newBuilder; + } + /** * Set a LIMIT clause on the query * @@ -104,12 +194,52 @@ export class QueryBuilder extends BaseSqlExpression { return newBuilder; } + /** + * Add includes (joins) to the query for custom joins with static models + * + * @param options - Include options + * @returns The query builder instance for chaining + */ + includes(options: QueryBuilderIncludeOptions) { + if (!options.model) { + throw new Error('Model is required for includes'); + } + + if (!options.on) { + throw new Error('Custom joins require an "on" condition to be specified'); + } + + const newBuilder = this.clone(); + + const defaultAttributes = [...options.model.modelDefinition.attributes.keys()]; + const includeOptions = { + model: options.model, + as: options.as || options.model.name, + required: options.required || options.joinType === 'INNER' || false, + right: options.joinType === 'RIGHT' || false, + on: options.on, + where: options.where, + attributes: options.attributes || defaultAttributes, + _isCustomJoin: true, + }; + + if (!newBuilder._include) { + newBuilder._include = []; + } + + newBuilder._include.push(includeOptions); + + return newBuilder; + } + /** * Generate the SQL query string * + * @param options + * @param options.multiline send true if you want to break the SQL into multiple lintes * @returns The SQL query */ - getQuery(): string { + getQuery({ multiline = false }: QueryBuilderGetQueryOptions = {}): string { if (!this._isSelect) { throw new Error('Query builder requires select() to be called first'); } @@ -117,12 +247,39 @@ export class QueryBuilder extends BaseSqlExpression { const queryGenerator = this._model.queryGenerator; const tableName = this.tableName; + // Process custom includes if they exist + let processedIncludes = this._include; + if (this._include && this._include.length > 0) { + processedIncludes = this._include.map(include => { + if (include._isCustomJoin) { + // Ensure the include has all required properties for Sequelize's include system + return { + ...include, + duplicating: false, + association: { source: this._model }, // No association for custom joins + parent: { + model: this._model, + as: this._model.name, + }, + }; + } + + return include; + }); + } + // Build the options object that matches Sequelize's FindOptions pattern - const options: SelectOptions = { - attributes: this._attributes as any, + const options: SelectOptions = { + attributes: this._attributes!, where: this._where, + include: processedIncludes, + order: this._order!, limit: this._limit, offset: this._offset, + group: this._group!, + having: this._having && this._having.length > 0 ? { + [Op.and]: this._having || [], + } : undefined, raw: true, plain: false, model: this._model, @@ -131,6 +288,10 @@ export class QueryBuilder extends BaseSqlExpression { // Generate the SQL using the existing query generator const sql = queryGenerator.selectQuery(tableName, options, this._model); + if (multiline) { + return sql.replaceAll(/FROM|LEFT|INNER|RIGHT|WHERE|GROUP|HAVING|ORDER/g, '\n$&'); + } + return sql; } @@ -151,7 +312,7 @@ export class QueryBuilder extends BaseSqlExpression { * @returns The table name */ get tableName(): string { - return this._model.table.tableName; + return this._model.modelDefinition.table.tableName; } /** diff --git a/packages/core/test/integration/query-builder/query-builder.test.ts b/packages/core/test/integration/query-builder/query-builder.test.ts new file mode 100644 index 000000000000..d5df5e68d426 --- /dev/null +++ b/packages/core/test/integration/query-builder/query-builder.test.ts @@ -0,0 +1,608 @@ +import type { InferAttributes, InferCreationAttributes, ModelStatic, Sequelize, Model } from '@sequelize/core'; +import { DataTypes, Op, sql, where } from '@sequelize/core'; +import { expect } from 'chai'; +import { QueryBuilder } from '../../../lib/expression-builders/query-builder'; +import { expectsql, getTestDialect, getTestDialectTeaser, createSequelizeInstance } from '../../support'; + +interface TUser extends Model, InferCreationAttributes> { + id: number; + name: string; + active: boolean; + age?: number; +} + +interface TPost extends Model, InferCreationAttributes> { + id: number; + title: string; + content?: string; + userId?: number; +} + +describe(getTestDialectTeaser('QueryBuilder'), () => { + let sequelize: Sequelize; + let User: ModelStatic; + let Post: ModelStatic; + + beforeEach(async () => { + sequelize = createSequelizeInstance(); + + User = sequelize.define( + 'User', + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING, allowNull: false }, + active: { type: DataTypes.BOOLEAN, defaultValue: true }, + age: { type: DataTypes.INTEGER }, + }, + { + tableName: 'users', + }, + ); + + Post = sequelize.define( + 'Post', + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + title: { type: DataTypes.STRING, allowNull: false }, + content: { type: DataTypes.TEXT }, + userId: { type: DataTypes.INTEGER }, + }, + { + tableName: 'posts', + }, + ); + + await Post.sync({ force: true }); + await Post.truncate(); + await User.sync({ force: true }); + await User.truncate(); + }); + + afterEach(async () => { + return await sequelize?.close(); + }); + + describe('Basic QueryBuilder functionality', () => { + it('should generate basic SELECT query', () => { + expectsql(User.select().getQuery(), { + default: `SELECT [User].* FROM [users] AS [User];`, + }); + }); + + it('should generate SELECT query with specific attributes', () => { + expectsql(User.select().attributes(['name', 'email']).getQuery(), { + default: `SELECT [name], [email] FROM [users] AS [User];`, + }); + }); + + // Won't work with minified aliases + if (!process.env.SEQ_PG_MINIFY_ALIASES) { + it('should generate SELECT query with aliased attributes', () => { + expectsql(User.select().attributes([['name', 'username'], 'email']).getQuery(), { + default: 'SELECT [name] AS [username], [email] FROM [users] AS [User];', + }); + }); + + it('should generate SELECT query with literal attributes', () => { + expectsql(User.select().attributes([sql.literal('"User"."email" AS "personalEmail"')]).getQuery(), { + default: 'SELECT "User"."email" AS "personalEmail" FROM [users] AS [User];', // literal + }); + + expectsql(User.select().attributes([[sql.literal('"User"."email"'), 'personalEmail']]).getQuery(), { + default: 'SELECT "User"."email" AS [personalEmail] FROM [users] AS [User];', + }); + }); + } + + it('should generate SELECT query with WHERE clause', () => { + expectsql(User.select().where({ active: true }).getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = true;', + sqlite3: 'SELECT `User`.* FROM `users` AS `User` WHERE `User`.`active` = 1;', + mssql: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = 1;', + }); + }); + + it('should generate SELECT query with multiple WHERE conditions', () => { + expectsql(User.select().where({ active: true, age: 25 }).getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = true AND [User].[age] = 25;', + sqlite3: 'SELECT `User`.* FROM `users` AS `User` WHERE `User`.`active` = 1 AND `User`.`age` = 25;', + mssql: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = 1 AND [User].[age] = 25;', + }); + }); + + it('should generate complete SELECT query with attributes and WHERE', () => { + expectsql( + User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), + { + default: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;', + sqlite3: 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = 1;', + mssql: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;', + }, + ); + }); + + it('should generate SELECT query with LIMIT', () => { + expectsql(User.select().limit(10).getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10;', + mssql: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;', + }); + }); + + it('should generate SELECT query with LIMIT and OFFSET', () => { + expectsql(User.select().limit(10).offset(5).getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10 OFFSET 5;', + 'mysql mariadb sqlite3': 'SELECT [User].* FROM `users` AS `User` ORDER BY `User`.`id` LIMIT 10 OFFSET 5;', + mssql: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY;', + }); + }); + + it('should generate SELECT query with ORDER BY', () => { + expectsql(User.select().orderBy(['name']).getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[name];', + }); + + expectsql(User.select().orderBy([['age', 'DESC']]).getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[age] DESC;', + }); + }); + + // TODO: Figure out how to implement this + // it('should support ORDER BY with position notation', () => { + // expectsql(User.select().orderBy([2]).getQuery(), { + // default: 'SELECT [User].* FROM [users] AS [User] ORDER BY 2;', + // }); + + // expectsql(User.select().orderBy([[3, 'DESC']]).getQuery(), { + // default: 'SELECT [User].* FROM [users] AS [User] ORDER BY 3 DESC;', + // }); + // }); + + // Won't work with minified aliases + if (!process.env.SEQ_PG_MINIFY_ALIASES) { + it('should generate SELECT query with GROUP BY', () => { + expectsql(User + .select() + .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) + .groupBy('name') + .orderBy([[sql.literal('MAX("age")'), 'DESC']]) + .getQuery(), { + default: 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] ORDER BY MAX("age") DESC;', + }); + }); + + it('should generate SELECT query with GROUP BY and HAVING', () => { + expectsql(User + .select() + .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) + .groupBy('name') + .having(sql.literal('MAX("age") > 30')) + .getQuery(), { + default: 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] HAVING MAX("age") > 30;', + }); + + expectsql(User + .select() + .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) + .groupBy('name') + .having(sql.literal('MAX("age") > 30')) + .andHaving(sql.literal('COUNT(*) > 1')) + .getQuery(), { + default: 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] HAVING MAX("age") > 30 AND COUNT(*) > 1;', + }); + }); + } + }); + + describe('Functional/Immutable behavior', () => { + it('should return new instances for each method call', () => { + const builder1 = User.select(); + const builder2 = builder1.attributes(['name']); + const builder3 = builder2.where({ active: true }); + + expect(builder1).to.not.equal(builder2); + expect(builder2).to.not.equal(builder3); + expect(builder1).to.not.equal(builder3); + }); + + it('should not mutate original builder when chaining', () => { + const baseBuilder = User.select(); + const builderWithAttributes = baseBuilder.attributes(['name']); + const builderWithWhere = baseBuilder.where({ active: true }); + + // Base builder should remain unchanged + expectsql(baseBuilder.getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User];', + }); + + // Other builders should have their modifications + expectsql(builderWithAttributes.getQuery(), { + default: 'SELECT [name] FROM [users] AS [User];', + }); + + expectsql(builderWithWhere.getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = true;', + sqlite3: 'SELECT `User`.* FROM `users` AS `User` WHERE `User`.`active` = 1;', + mssql: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = 1;', + }); + }); + + it('should allow building different queries from same base', () => { + const baseBuilder = User.select().attributes(['name', 'email']); + + expectsql(baseBuilder.where({ active: true }).getQuery(), { + default: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;', + sqlite3: 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = 1;', + mssql: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;', + }); + + expectsql(baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(), { + default: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[age] < 30;', + }); + }); + }); + + if (getTestDialect() === 'postgres') { + describe('PostgreSQL-specific features', () => { + it('should handle PostgreSQL operators correctly', () => { + expectsql( + User.select() + .where({ + name: { [Op.iLike]: '%john%' }, + age: { [Op.between]: [18, 65] }, + }) + .getQuery(), + { + default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[name] ILIKE \'%john%\' AND ([User].[age] BETWEEN 18 AND 65);', + }, + ); + }); + + it('should handle array operations', () => { + expectsql( + User.select() + .where({ + name: { [Op.in]: ['John', 'Jane', 'Bob'] }, + }) + .getQuery(), + { + default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[name] IN (\'John\', \'Jane\', \'Bob\');', + }, + ); + }); + + it('should quote identifiers properly for PostgreSQL', () => { + expectsql( + User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), + { + default: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;', + }, + ); + }); + }); + } + + describe('Error handling', () => { + it('should throw error when getQuery is called on non-select builder', () => { + expect(() => { + const builder = new QueryBuilder(User); + builder.getQuery(); + }).to.throw(); + }); + + it('should handle empty attributes array', () => { + expect(() => { + User.select().attributes([]).getQuery(); + }).to.throw(/Attempted a SELECT query for model 'User' as .* without selecting any columns/); + }); + }); + + describe('Complex WHERE conditions', () => { + it('should handle complex nested conditions', () => { + expectsql( + User.select() + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], + }, + ], + }) + .getQuery(), + { + default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = true OR ([User].[age] >= 18 AND [User].[name] LIKE \'%admin%\');', + sqlite3: 'SELECT `User`.* FROM `users` AS `User` WHERE `User`.`active` = 1 OR (`User`.`age` >= 18 AND `User`.`name` LIKE \'%admin%\');', + mssql: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = 1 OR ([User].[age] >= 18 AND [User].[name] LIKE N\'%admin%\');', + }, + ); + }); + + it('should handle IS NULL conditions', () => { + expectsql(User.select().where({ age: null }).getQuery(), { + default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[age] IS NULL;', + }); + }); + + it('should handle NOT NULL conditions', () => { + expectsql( + User.select() + .where({ age: { [Op.ne]: null } }) + .getQuery(), + { + default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[age] IS NOT NULL;', + }, + ); + }); + + it('should generate multiline query', () => { + expectsql( + User.select() + .attributes(['name', 'email']) + .where({ age: { [Op.gt]: 30 } }) + .getQuery({ multiline: true }), + { + default: [ + 'SELECT [name], [email]', + 'FROM [users] AS [User]', + 'WHERE [User].[age] > 30;', + ].join('\n'), + }, + ); + }); + + if (getTestDialect() === 'postgres' && !process.env.SEQ_PG_MINIFY_ALIASES) { + it('should handle complex conditions with multiple joins', async () => { + const Comments = sequelize.define('Comments', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + userId: DataTypes.INTEGER, + content: DataTypes.STRING, + likes: DataTypes.INTEGER, + }, { tableName: 'comments' }); + await Comments.sync({ force: true }); + await Post.sync({ force: true }); + await User.sync({ force: true }); + + await User.create({ name: 'Alice', email: 'alice@example.com', active: true, age: 20 }); + await User.create({ name: 'Bob', email: 'bob@example.com', active: true, age: 25 }); + await Post.create({ title: 'Creed', userId: 1 }); + await Post.create({ title: 'Crocodiles', userId: 2 }); + await Post.create({ title: 'Cronos', userId: 2 }); + await Comments.create({ content: 'Comment 1', userId: 1, likes: 10 }); + await Comments.create({ content: 'Comment 2', userId: 1, likes: 20 }); + await Comments.create({ content: 'Comment 3', userId: 2, likes: 50 }); + + const qb = User.select() + .attributes(['name', ['age', 'userAge']]) + .includes({ + model: Post, + as: 'p', + on: where(sql.col('User.id'), Op.eq, sql.col('p.userId')), + attributes: ['title'], + where: { title: { [Op.iLike]: '%cr%' } }, + required: true, + }) + .includes({ + model: Comments, + as: 'c', + on: where(sql.col('User.id'), Op.eq, sql.col('c.userId')), + attributes: [[sql.literal('SUM("c"."likes")'), 'likeCount']], + joinType: 'LEFT', + }) + .where({ + [Op.or]: [ + { active: true }, + { + [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.iLike]: '%admin%' } }], + }, + ], + }) + .groupBy([sql.col('User.id'), sql.col('p.id')]) + .having(sql.literal('SUM("c"."likes") > 10')) + .andHaving(sql.literal('SUM("c"."likes") < 300')) + .orderBy([['name', 'DESC'], [sql.col('p.title'), 'ASC']]); + const query = qb.getQuery({ multiline: true }); + expectsql(query, { + default: [ + 'SELECT "User"."name", "User"."age" AS "userAge", "p"."title" AS "p.title", SUM("c"."likes") AS "c.likeCount"', + 'FROM "users" AS "User"', + 'INNER JOIN "posts" AS "p" ON "User"."id" = "p"."userId" AND "p"."title" ILIKE \'%cr%\'', + 'LEFT OUTER JOIN "comments" AS "c" ON "User"."id" = "c"."userId"', + 'WHERE "User"."active" = true OR ("User"."age" >= 18 AND "User"."name" ILIKE \'%admin%\')', + 'GROUP BY "User"."id", "p"."id"', + 'HAVING SUM("c"."likes") > 10 AND SUM("c"."likes") < 300', + 'ORDER BY "User"."name" DESC, "p"."title" ASC;', + ].join('\n'), + }); + const [result] = await qb.execute(); + expect(result).to.have.lengthOf(3); + expect(result).to.deep.equal([ + { + name: 'Bob', + userAge: 25, + 'p.title': 'Crocodiles', + 'c.likeCount': '50', + }, + { + name: 'Bob', + userAge: 25, + 'p.title': 'Cronos', + 'c.likeCount': '50', + }, + { + name: 'Alice', + userAge: 20, + 'p.title': 'Creed', + 'c.likeCount': '30', + }, + ]); + }); + } + }); + + describe('includes (custom joins)', () => { + + if (!process.env.SEQ_PG_MINIFY_ALIASES) { + it('should generate LEFT JOIN with custom condition', () => { + expectsql( + User.select() + .includes({ + model: Post, + as: 'Posts', + on: where(sql.col('User.id'), Op.eq, sql.col('Posts.userId')), + }) + .getQuery(), + { + default: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', + }, + ); + }); + + it('should generate INNER JOIN when required is true', () => { + expectsql( + User.select() + .includes({ + model: Post, + as: 'Posts', + required: true, + on: where(sql.col('User.id'), Op.eq, sql.col('Posts.userId')), + }) + .getQuery(), + { + default: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] INNER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', + }, + ); + }); + + it('should generate INNER JOIN when joinType is INNER', () => { + expectsql( + User.select() + .includes({ + model: Post, + as: 'Posts', + joinType: 'INNER', + on: where(sql.col('User.id'), Op.eq, sql.col('Posts.userId')), + }) + .getQuery(), + { + default: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] INNER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', + }, + ); + }); + + it('should handle custom WHERE conditions on joined table', () => { + expectsql( + User.select() + .includes({ + model: Post, + as: 'Posts', + on: where(sql.col('User.id'), Op.eq, sql.col('Posts.userId')), + where: { + title: 'Hello World', + }, + }) + .getQuery(), + { + default: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId] AND [Posts].[title] = \'Hello World\';', + mssql: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId] AND [Posts].[title] = N\'Hello World\';', + }, + ); + }); + + it('should support custom attributes from joined table', () => { + expectsql( + User.select() + .includes({ + model: Post, + as: 'Posts', + attributes: ['title'], + on: where(sql.col('User.id'), Op.eq, sql.col('Posts.userId')), + }) + .getQuery(), + { + default: 'SELECT [User].*, [Posts].[title] AS [Posts.title] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', + }, + ); + }); + + it('should generate multiline query', () => { + expectsql( + User.select() + .attributes(['name']) + .includes({ + model: Post, + as: 'Posts', + attributes: ['title'], + on: where(sql.col('User.id'), Op.eq, sql.col('Posts.userId')), + }) + .where({ age: { [Op.gt]: 30 } }) + .getQuery({ multiline: true }), + { + default: [ + 'SELECT [User].[name], [Posts].[title] AS [Posts.title]', + 'FROM [users] AS [User]', + 'LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId]', + 'WHERE [User].[age] > 30;', + ].join('\n'), + }, + ); + }); + } + + it('should throw error when model is not provided', () => { + expect(() => { + User.select().includes({ + on: where(sql.col('User.id'), Op.eq, sql.col('Posts.userId')), + } as never); + }).to.throw(Error, 'Model is required for includes'); + }); + + it('should throw error when on condition is not provided', () => { + expect(() => { + User.select() + .includes({ + model: Post, + as: 'Posts', + }) + .getQuery(); + }).to.throw(Error, 'Custom joins require an "on" condition to be specified'); + }); + }); + + describe('execute', () => { + it('should execute the query', async () => { + await User.sync({ force: true }); + await User.create({ name: 'John', email: 'john@example.com', active: true }); + const result = await User.select() + .attributes(['name']) + .where({ active: true, name: 'John' }) + .execute(); + const [row] = result; + expect(row).to.deep.equal([{ name: 'John' }]); + }); + + if (!process.env.SEQ_PG_MINIFY_ALIASES) { + it('should execute the query with custom join, returning multiple rows', async () => { + await User.sync({ force: true }); + await Post.sync({ force: true }); + const user = await User.create({ name: 'John', email: 'john@example.com', active: true }); + await Post.create({ title: 'Post 1', userId: user.id }); + await Post.create({ title: 'Post 2', userId: user.id }); + const [result] = await User.select() + .includes({ + model: Post, + as: 'Posts', + on: where(sql.col('User.id'), Op.eq, sql.col('Posts.userId')), + }) + .where({ id: user.id }) + .execute(); + expect(result).to.have.lengthOf(2); + expect((result[0] as any).id).to.equal(user.id); + expect((result[0] as any).name).to.equal(user.name); + expect((result[1] as any).id).to.equal(user.id); + expect((result[1] as any).name).to.equal(user.name); + expect((result[0] as any)['Posts.title']).to.equal('Post 1'); + expect((result[1] as any)['Posts.title']).to.equal('Post 2'); + }); + } + }); +}); From 3c3e148014624c4481b2929c92d79d19f1e9fa18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sat, 26 Jul 2025 10:30:29 -0300 Subject: [PATCH 09/14] fix: prettier --- .../src/abstract-dialect/query-generator.js | 35 ++- .../src/expression-builders/query-builder.ts | 36 +-- .../query-builder/query-builder.test.ts | 215 +++++++++++------- 3 files changed, 183 insertions(+), 103 deletions(-) diff --git a/packages/core/src/abstract-dialect/query-generator.js b/packages/core/src/abstract-dialect/query-generator.js index 9ba25aaf0e8f..72bcddf4e9e6 100644 --- a/packages/core/src/abstract-dialect/query-generator.js +++ b/packages/core/src/abstract-dialect/query-generator.js @@ -1719,7 +1719,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { let joinWhere; if (!include.on) { - throw new Error('Custom joins require an "on" condition to be specified'); + throw new Error('Custom joins require an "on" condition to be specified'); } // Handle the custom join condition @@ -1753,36 +1753,49 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { // Generate attributes for the joined table const attributes = []; const rightAttributes = right.modelDefinition.attributes; - - // Process each attribute based on include.attributes or all attributes - const attributesToInclude = (include.attributes && include.attributes.length > 0) ? include.attributes : Array.from(rightAttributes.keys()); - + + // Process each attribute based on include.attributes or all attributes + const attributesToInclude = + include.attributes && include.attributes.length > 0 + ? include.attributes + : Array.from(rightAttributes.keys()); + for (const attr of attributesToInclude) { if (typeof attr === 'string') { // Simple attribute name const field = rightAttributes.get(attr)?.columnName || attr; - attributes.push(`${this.quoteTable(asRight)}.${this.quoteIdentifier(field)} AS ${this.quoteIdentifier(`${asRight}.${attr}`)}`); + attributes.push( + `${this.quoteTable(asRight)}.${this.quoteIdentifier(field)} AS ${this.quoteIdentifier(`${asRight}.${attr}`)}`, + ); } else if (Array.isArray(attr)) { // [field, alias] format const [field, alias] = attr; if (typeof field === 'string') { const columnName = rightAttributes.get(field)?.columnName || field; - attributes.push(`${this.quoteTable(asRight)}.${this.quoteIdentifier(columnName)} AS ${this.quoteIdentifier(`${asRight}.${alias}`)}`); + attributes.push( + `${this.quoteTable(asRight)}.${this.quoteIdentifier(columnName)} AS ${this.quoteIdentifier(`${asRight}.${alias}`)}`, + ); } else { // Handle complex expressions - attributes.push(`${this.formatSqlExpression(field)} AS ${this.quoteIdentifier(`${asRight}.${alias}`)}`); + attributes.push( + `${this.formatSqlExpression(field)} AS ${this.quoteIdentifier(`${asRight}.${alias}`)}`, + ); } } } return { - join: include.required ? 'INNER JOIN' : include.right && this._dialect.supports['RIGHT JOIN'] ? 'RIGHT OUTER JOIN' : 'LEFT OUTER JOIN', + join: include.required + ? 'INNER JOIN' + : include.right && this._dialect.supports['RIGHT JOIN'] + ? 'RIGHT OUTER JOIN' + : 'LEFT OUTER JOIN', body: this.quoteTable(right, { ...topLevelInfo.options, ...include, alias: asRight }), condition: joinCondition, attributes: { main: attributes, - subQuery: [] - } + subQuery: [], + }, }; } diff --git a/packages/core/src/expression-builders/query-builder.ts b/packages/core/src/expression-builders/query-builder.ts index e2e308b1e8c9..a8a02d196bf0 100644 --- a/packages/core/src/expression-builders/query-builder.ts +++ b/packages/core/src/expression-builders/query-builder.ts @@ -1,6 +1,13 @@ import type { SelectOptions } from '../abstract-dialect/query-generator.js'; import type { WhereOptions } from '../abstract-dialect/where-sql-builder-types.js'; -import type { FindAttributeOptions, GroupOption, Model, ModelStatic, Order, OrderItem } from '../model.d.ts'; +import type { + FindAttributeOptions, + GroupOption, + Model, + ModelStatic, + Order, + OrderItem, +} from '../model.d.ts'; import { Op } from '../operators.js'; import type { Sequelize } from '../sequelize.js'; import { BaseSqlExpression, SQL_IDENTIFIER } from './base-sql-expression.js'; @@ -15,7 +22,7 @@ type QueryBuilderIncludeOptions = { attributes?: FindAttributeOptions; where?: WhereOptions; required?: boolean; - joinType?: "LEFT" | "INNER" | "RIGHT"; + joinType?: 'LEFT' | 'INNER' | 'RIGHT'; }; type QueryBuilderGetQueryOptions = { @@ -28,7 +35,7 @@ type IncludeOption = { required: boolean; right: boolean; on: Record | Where; - where: WhereOptions, + where: WhereOptions; attributes: FindAttributeOptions | string[]; _isCustomJoin: boolean; }; @@ -73,7 +80,7 @@ export class QueryBuilder extends BaseSqlExpression { newBuilder._order = this._order; newBuilder._limit = this._limit; newBuilder._offset = this._offset; - newBuilder._include = this._include.map((include) => ({ ...include })); + newBuilder._include = this._include.map(include => ({ ...include })); return newBuilder; } @@ -118,8 +125,8 @@ export class QueryBuilder extends BaseSqlExpression { /** * Sets the GROUP BY clause for the query - * - * @param group + * + * @param group * @returns The query builder instance for chaining */ groupBy(group: GroupOption): QueryBuilder { @@ -131,7 +138,7 @@ export class QueryBuilder extends BaseSqlExpression { /** * Sets the HAVING clause for the query (supports only Literal condition) - * + * * @param having * @returns The query builder instance for chaining */ @@ -144,20 +151,20 @@ export class QueryBuilder extends BaseSqlExpression { /** * Allows chaining of additional HAVING conditions - * + * * @param having * @returns The query builder instance for chaining */ andHaving(having: Literal): QueryBuilder { const newBuilder = this.clone(); - newBuilder._having = [...newBuilder._having || [], having]; + newBuilder._having = [...(newBuilder._having || []), having]; return newBuilder; } /** * Set the ORDER BY clause for the query - * + * * @param order - The order to apply to the query * @returns The query builder instance for chaining */ @@ -277,9 +284,12 @@ export class QueryBuilder extends BaseSqlExpression { limit: this._limit, offset: this._offset, group: this._group!, - having: this._having && this._having.length > 0 ? { - [Op.and]: this._having || [], - } : undefined, + having: + this._having && this._having.length > 0 + ? { + [Op.and]: this._having || [], + } + : undefined, raw: true, plain: false, model: this._model, diff --git a/packages/core/test/integration/query-builder/query-builder.test.ts b/packages/core/test/integration/query-builder/query-builder.test.ts index d5df5e68d426..75ca0976c915 100644 --- a/packages/core/test/integration/query-builder/query-builder.test.ts +++ b/packages/core/test/integration/query-builder/query-builder.test.ts @@ -1,8 +1,19 @@ -import type { InferAttributes, InferCreationAttributes, ModelStatic, Sequelize, Model } from '@sequelize/core'; +import type { + InferAttributes, + InferCreationAttributes, + Model, + ModelStatic, + Sequelize, +} from '@sequelize/core'; import { DataTypes, Op, sql, where } from '@sequelize/core'; import { expect } from 'chai'; import { QueryBuilder } from '../../../lib/expression-builders/query-builder'; -import { expectsql, getTestDialect, getTestDialectTeaser, createSequelizeInstance } from '../../support'; +import { + createSequelizeInstance, + expectsql, + getTestDialect, + getTestDialectTeaser, +} from '../../support'; interface TUser extends Model, InferCreationAttributes> { id: number; @@ -75,22 +86,37 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }); }); - // Won't work with minified aliases + // Won't work with minified aliases if (!process.env.SEQ_PG_MINIFY_ALIASES) { it('should generate SELECT query with aliased attributes', () => { - expectsql(User.select().attributes([['name', 'username'], 'email']).getQuery(), { - default: 'SELECT [name] AS [username], [email] FROM [users] AS [User];', - }); + expectsql( + User.select() + .attributes([['name', 'username'], 'email']) + .getQuery(), + { + default: 'SELECT [name] AS [username], [email] FROM [users] AS [User];', + }, + ); }); it('should generate SELECT query with literal attributes', () => { - expectsql(User.select().attributes([sql.literal('"User"."email" AS "personalEmail"')]).getQuery(), { - default: 'SELECT "User"."email" AS "personalEmail" FROM [users] AS [User];', // literal - }); + expectsql( + User.select() + .attributes([sql.literal('"User"."email" AS "personalEmail"')]) + .getQuery(), + { + default: 'SELECT "User"."email" AS "personalEmail" FROM [users] AS [User];', // literal + }, + ); - expectsql(User.select().attributes([[sql.literal('"User"."email"'), 'personalEmail']]).getQuery(), { - default: 'SELECT "User"."email" AS [personalEmail] FROM [users] AS [User];', - }); + expectsql( + User.select() + .attributes([[sql.literal('"User"."email"'), 'personalEmail']]) + .getQuery(), + { + default: 'SELECT "User"."email" AS [personalEmail] FROM [users] AS [User];', + }, + ); }); } @@ -104,35 +130,38 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { it('should generate SELECT query with multiple WHERE conditions', () => { expectsql(User.select().where({ active: true, age: 25 }).getQuery(), { - default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = true AND [User].[age] = 25;', - sqlite3: 'SELECT `User`.* FROM `users` AS `User` WHERE `User`.`active` = 1 AND `User`.`age` = 25;', - mssql: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = 1 AND [User].[age] = 25;', + default: + 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = true AND [User].[age] = 25;', + sqlite3: + 'SELECT `User`.* FROM `users` AS `User` WHERE `User`.`active` = 1 AND `User`.`age` = 25;', + mssql: + 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = 1 AND [User].[age] = 25;', }); }); it('should generate complete SELECT query with attributes and WHERE', () => { - expectsql( - User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), - { - default: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;', - sqlite3: 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = 1;', - mssql: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;', - }, - ); + expectsql(User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), { + default: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;', + sqlite3: 'SELECT `name`, `email` FROM `users` AS `User` WHERE `User`.`active` = 1;', + mssql: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;', + }); }); it('should generate SELECT query with LIMIT', () => { expectsql(User.select().limit(10).getQuery(), { default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10;', - mssql: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;', + mssql: + 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;', }); }); it('should generate SELECT query with LIMIT and OFFSET', () => { expectsql(User.select().limit(10).offset(5).getQuery(), { default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10 OFFSET 5;', - 'mysql mariadb sqlite3': 'SELECT [User].* FROM `users` AS `User` ORDER BY `User`.`id` LIMIT 10 OFFSET 5;', - mssql: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY;', + 'mysql mariadb sqlite3': + 'SELECT [User].* FROM `users` AS `User` ORDER BY `User`.`id` LIMIT 10 OFFSET 5;', + mssql: + 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY;', }); }); @@ -141,9 +170,14 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[name];', }); - expectsql(User.select().orderBy([['age', 'DESC']]).getQuery(), { - default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[age] DESC;', - }); + expectsql( + User.select() + .orderBy([['age', 'DESC']]) + .getQuery(), + { + default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[age] DESC;', + }, + ); }); // TODO: Figure out how to implement this @@ -160,35 +194,44 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { // Won't work with minified aliases if (!process.env.SEQ_PG_MINIFY_ALIASES) { it('should generate SELECT query with GROUP BY', () => { - expectsql(User - .select() - .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) - .groupBy('name') - .orderBy([[sql.literal('MAX("age")'), 'DESC']]) - .getQuery(), { - default: 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] ORDER BY MAX("age") DESC;', - }); + expectsql( + User.select() + .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) + .groupBy('name') + .orderBy([[sql.literal('MAX("age")'), 'DESC']]) + .getQuery(), + { + default: + 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] ORDER BY MAX("age") DESC;', + }, + ); }); it('should generate SELECT query with GROUP BY and HAVING', () => { - expectsql(User - .select() - .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) - .groupBy('name') - .having(sql.literal('MAX("age") > 30')) - .getQuery(), { - default: 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] HAVING MAX("age") > 30;', - }); + expectsql( + User.select() + .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) + .groupBy('name') + .having(sql.literal('MAX("age") > 30')) + .getQuery(), + { + default: + 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] HAVING MAX("age") > 30;', + }, + ); - expectsql(User - .select() - .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) - .groupBy('name') - .having(sql.literal('MAX("age") > 30')) - .andHaving(sql.literal('COUNT(*) > 1')) - .getQuery(), { - default: 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] HAVING MAX("age") > 30 AND COUNT(*) > 1;', - }); + expectsql( + User.select() + .attributes(['name', [sql.literal('MAX("age")'), 'maxAge']]) + .groupBy('name') + .having(sql.literal('MAX("age") > 30')) + .andHaving(sql.literal('COUNT(*) > 1')) + .getQuery(), + { + default: + 'SELECT [name], MAX("age") AS [maxAge] FROM [users] AS [User] GROUP BY [name] HAVING MAX("age") > 30 AND COUNT(*) > 1;', + }, + ); }); } }); @@ -252,7 +295,8 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }) .getQuery(), { - default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[name] ILIKE \'%john%\' AND ([User].[age] BETWEEN 18 AND 65);', + default: + "SELECT [User].* FROM [users] AS [User] WHERE [User].[name] ILIKE '%john%' AND ([User].[age] BETWEEN 18 AND 65);", }, ); }); @@ -265,18 +309,16 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }) .getQuery(), { - default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[name] IN (\'John\', \'Jane\', \'Bob\');', + default: + "SELECT [User].* FROM [users] AS [User] WHERE [User].[name] IN ('John', 'Jane', 'Bob');", }, ); }); it('should quote identifiers properly for PostgreSQL', () => { - expectsql( - User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), - { - default: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;', - }, - ); + expectsql(User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), { + default: 'SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;', + }); }); }); } @@ -310,9 +352,12 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }) .getQuery(), { - default: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = true OR ([User].[age] >= 18 AND [User].[name] LIKE \'%admin%\');', - sqlite3: 'SELECT `User`.* FROM `users` AS `User` WHERE `User`.`active` = 1 OR (`User`.`age` >= 18 AND `User`.`name` LIKE \'%admin%\');', - mssql: 'SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = 1 OR ([User].[age] >= 18 AND [User].[name] LIKE N\'%admin%\');', + default: + "SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = true OR ([User].[age] >= 18 AND [User].[name] LIKE '%admin%');", + sqlite3: + "SELECT `User`.* FROM `users` AS `User` WHERE `User`.`active` = 1 OR (`User`.`age` >= 18 AND `User`.`name` LIKE '%admin%');", + mssql: + "SELECT [User].* FROM [users] AS [User] WHERE [User].[active] = 1 OR ([User].[age] >= 18 AND [User].[name] LIKE N'%admin%');", }, ); }); @@ -352,12 +397,16 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { if (getTestDialect() === 'postgres' && !process.env.SEQ_PG_MINIFY_ALIASES) { it('should handle complex conditions with multiple joins', async () => { - const Comments = sequelize.define('Comments', { - id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, - userId: DataTypes.INTEGER, - content: DataTypes.STRING, - likes: DataTypes.INTEGER, - }, { tableName: 'comments' }); + const Comments = sequelize.define( + 'Comments', + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + userId: DataTypes.INTEGER, + content: DataTypes.STRING, + likes: DataTypes.INTEGER, + }, + { tableName: 'comments' }, + ); await Comments.sync({ force: true }); await Post.sync({ force: true }); await User.sync({ force: true }); @@ -399,7 +448,10 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { .groupBy([sql.col('User.id'), sql.col('p.id')]) .having(sql.literal('SUM("c"."likes") > 10')) .andHaving(sql.literal('SUM("c"."likes") < 300')) - .orderBy([['name', 'DESC'], [sql.col('p.title'), 'ASC']]); + .orderBy([ + ['name', 'DESC'], + [sql.col('p.title'), 'ASC'], + ]); const query = qb.getQuery({ multiline: true }); expectsql(query, { default: [ @@ -440,7 +492,6 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }); describe('includes (custom joins)', () => { - if (!process.env.SEQ_PG_MINIFY_ALIASES) { it('should generate LEFT JOIN with custom condition', () => { expectsql( @@ -452,7 +503,8 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }) .getQuery(), { - default: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', + default: + 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', }, ); }); @@ -468,7 +520,8 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }) .getQuery(), { - default: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] INNER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', + default: + 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] INNER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', }, ); }); @@ -484,7 +537,8 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }) .getQuery(), { - default: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] INNER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', + default: + 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] INNER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', }, ); }); @@ -502,8 +556,10 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }) .getQuery(), { - default: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId] AND [Posts].[title] = \'Hello World\';', - mssql: 'SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId] AND [Posts].[title] = N\'Hello World\';', + default: + "SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId] AND [Posts].[title] = 'Hello World';", + mssql: + "SELECT [User].*, [Posts].[id] AS [Posts.id], [Posts].[title] AS [Posts.title], [Posts].[content] AS [Posts.content], [Posts].[userId] AS [Posts.userId], [Posts].[createdAt] AS [Posts.createdAt], [Posts].[updatedAt] AS [Posts.updatedAt] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId] AND [Posts].[title] = N'Hello World';", }, ); }); @@ -519,7 +575,8 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }) .getQuery(), { - default: 'SELECT [User].*, [Posts].[title] AS [Posts.title] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', + default: + 'SELECT [User].*, [Posts].[title] AS [Posts.title] FROM [users] AS [User] LEFT OUTER JOIN [posts] AS [Posts] ON [User].[id] = [Posts].[userId];', }, ); }); From 6e21c9016ce853f8dae99eef307e4b96fcfad1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sat, 26 Jul 2025 10:36:10 -0300 Subject: [PATCH 10/14] fix: eslint --- .../core/test/integration/query-builder/query-builder.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/integration/query-builder/query-builder.test.ts b/packages/core/test/integration/query-builder/query-builder.test.ts index 75ca0976c915..a9809a88d61a 100644 --- a/packages/core/test/integration/query-builder/query-builder.test.ts +++ b/packages/core/test/integration/query-builder/query-builder.test.ts @@ -70,7 +70,7 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { }); afterEach(async () => { - return await sequelize?.close(); + return sequelize?.close(); }); describe('Basic QueryBuilder functionality', () => { From 5f28b9c0287e3f0205cf14d02b88ca7379043238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Sat, 26 Jul 2025 10:55:43 -0300 Subject: [PATCH 11/14] fix: remove js test file --- .../query-builder/query-builder.test.js | 305 ------------------ 1 file changed, 305 deletions(-) delete mode 100644 packages/core/test/integration/query-builder/query-builder.test.js diff --git a/packages/core/test/integration/query-builder/query-builder.test.js b/packages/core/test/integration/query-builder/query-builder.test.js deleted file mode 100644 index c5f2afaeedd7..000000000000 --- a/packages/core/test/integration/query-builder/query-builder.test.js +++ /dev/null @@ -1,305 +0,0 @@ -'use strict'; - -const chai = require('chai'); - -const expect = chai.expect; -const Support = require('../../support'); -const { DataTypes, Op } = require('@sequelize/core'); -const { QueryBuilder } = require('../../../lib/expression-builders/query-builder'); -const { expectsql } = require('../../support'); - -const dialect = Support.getTestDialect(); - -describe(Support.getTestDialectTeaser('QueryBuilder'), () => { - let sequelize; - let User; - let Post; - - beforeEach(async () => { - sequelize = Support.createSequelizeInstance(); - - User = sequelize.define( - 'User', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - email: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - }, - active: { - type: DataTypes.BOOLEAN, - defaultValue: true, - }, - age: { - type: DataTypes.INTEGER, - }, - }, - { - tableName: 'users', - }, - ); - - Post = sequelize.define( - 'Post', - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - content: { - type: DataTypes.TEXT, - }, - published: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, - }, - { - tableName: 'posts', - }, - ); - - User.hasMany(Post, { foreignKey: 'userId' }); - Post.belongsTo(User, { foreignKey: 'userId' }); - await User.sync(); - await User.truncate(); - }); - - afterEach(() => { - return sequelize?.close(); - }); - - describe('Basic QueryBuilder functionality', () => { - it('should generate basic SELECT query', () => { - expectsql(() => User.select().getQuery(), { - default: `SELECT * FROM [users] AS [User];`, - }); - }); - - it('should generate SELECT query with specific attributes', () => { - expectsql(() => User.select().attributes(['name', 'email']).getQuery(), { - default: `SELECT [name], [email] FROM [users] AS [User];`, - }); - }); - - it('should generate SELECT query with WHERE clause', () => { - expectsql(() => User.select().where({ active: true }).getQuery(), { - default: `SELECT * FROM [users] AS [User] WHERE [User].[active] = true;`, - 'mssql sqlite3': `SELECT * FROM [users] AS [User] WHERE [User].[active] = 1;`, - }); - }); - - it('should generate SELECT query with multiple WHERE conditions', () => { - expectsql(() => User.select().where({ active: true, age: 25 }).getQuery(), { - default: `SELECT * FROM [users] AS [User] WHERE [User].[active] = true AND [User].[age] = 25;`, - 'mssql sqlite3': `SELECT * FROM [users] AS [User] WHERE [User].[active] = 1 AND [User].[age] = 25;`, - }); - }); - - it('should generate complete SELECT query with attributes and WHERE', () => { - expectsql( - () => User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), - { - default: `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;`, - 'mssql sqlite3': `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;`, - }, - ); - }); - - it('should generate SELECT query with LIMIT', () => { - expectsql(() => User.select().limit(10).getQuery(), { - default: `SELECT * FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10;`, - mssql: `SELECT * FROM [users] AS [User] ORDER BY [User].[id] OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;`, - }); - }); - - it('should generate SELECT query with LIMIT and OFFSET', () => { - expectsql(() => User.select().limit(10).offset(5).getQuery(), { - default: `SELECT * FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10 OFFSET 5;`, - mssql: `SELECT * FROM [users] AS [User] ORDER BY [User].[id] OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY;`, - }); - }); - }); - - describe('Functional/Immutable behavior', () => { - it('should return new instances for each method call', () => { - const builder1 = User.select(); - const builder2 = builder1.attributes(['name']); - const builder3 = builder2.where({ active: true }); - - expect(builder1).to.not.equal(builder2); - expect(builder2).to.not.equal(builder3); - expect(builder1).to.not.equal(builder3); - }); - - it('should not mutate original builder when chaining', () => { - const baseBuilder = User.select(); - const builderWithAttributes = baseBuilder.attributes(['name']); - const builderWithWhere = baseBuilder.where({ active: true }); - - // Base builder should remain unchanged - expectsql(() => baseBuilder.getQuery(), { - default: `SELECT * FROM [users] AS [User];`, - }); - - // Other builders should have their modifications - expectsql(() => builderWithAttributes.getQuery(), { - default: `SELECT [name] FROM [users] AS [User];`, - }); - - expectsql(() => builderWithWhere.getQuery(), { - default: `SELECT * FROM [users] AS [User] WHERE [User].[active] = true;`, - 'mssql sqlite3': `SELECT * FROM [users] AS [User] WHERE [User].[active] = 1;`, - }); - }); - - it('should allow building different queries from same base', () => { - const baseBuilder = User.select().attributes(['name', 'email']); - - expectsql(() => baseBuilder.where({ active: true }).getQuery(), { - default: `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;`, - 'mssql sqlite3': `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = 1;`, - }); - - expectsql(() => baseBuilder.where({ age: { [Op.lt]: 30 } }).getQuery(), { - default: `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[age] < 30;`, - }); - }); - }); - - if (dialect.startsWith('postgres')) { - describe('PostgreSQL-specific features', () => { - it('should handle PostgreSQL operators correctly', () => { - expectsql( - () => - User.select() - .where({ - name: { [Op.iLike]: '%john%' }, - age: { [Op.between]: [18, 65] }, - }) - .getQuery(), - { - default: `SELECT * FROM [users] AS [User] WHERE [User].[name] ILIKE '%john%' AND ([User].[age] BETWEEN 18 AND 65);`, - }, - ); - }); - - it('should handle array operations', () => { - expectsql( - () => - User.select() - .where({ - name: { [Op.in]: ['John', 'Jane', 'Bob'] }, - }) - .getQuery(), - { - default: `SELECT * FROM [users] AS [User] WHERE [User].[name] IN ('John', 'Jane', 'Bob');`, - }, - ); - }); - - it('should quote identifiers properly for PostgreSQL', () => { - expectsql( - () => User.select().attributes(['name', 'email']).where({ active: true }).getQuery(), - { - default: `SELECT [name], [email] FROM [users] AS [User] WHERE [User].[active] = true;`, - }, - ); - }); - }); - } - - describe('Error handling', () => { - it('should throw error when getQuery is called on non-select builder', () => { - expect(() => { - const builder = new QueryBuilder(User); - builder.getQuery(); - }).to.throw(); - }); - - it('should handle empty attributes array', () => { - expect(() => { - User.select().attributes([]).getQuery(); - }).to.throw(/Attempted a SELECT query for model 'User' as .* without selecting any columns/); - }); - - it('should handle null/undefined where conditions gracefully', () => { - // null where should throw an error as it's invalid - expect(() => { - User.select().where(null).getQuery(); - }).to.throw(); - - // undefined where should work (no where clause) - expect(() => { - User.select().where(undefined).getQuery(); - }).to.not.throw(); - }); - }); - - describe('Complex WHERE conditions', () => { - it('should handle complex nested conditions', () => { - expectsql( - () => - User.select() - .where({ - [Op.or]: [ - { active: true }, - { - [Op.and]: [{ age: { [Op.gte]: 18 } }, { name: { [Op.like]: '%admin%' } }], - }, - ], - }) - .getQuery(), - { - default: `SELECT * FROM [users] AS [User] WHERE [User].[active] = true OR ([User].[age] >= 18 AND [User].[name] LIKE '%admin%');`, - sqlite3: `SELECT * FROM \`users\` AS \`User\` WHERE \`User\`.\`active\` = 1 OR (\`User\`.\`age\` >= 18 AND \`User\`.\`name\` LIKE '%admin%');`, - mssql: `SELECT * FROM [users] AS [User] WHERE [User].[active] = 1 OR ([User].[age] >= 18 AND [User].[name] LIKE N'%admin%');`, - }, - ); - }); - - it('should handle IS NULL conditions', () => { - expectsql(() => User.select().where({ age: null }).getQuery(), { - default: `SELECT * FROM [users] AS [User] WHERE [User].[age] IS NULL;`, - }); - }); - - it('should handle NOT NULL conditions', () => { - expectsql( - () => - User.select() - .where({ age: { [Op.ne]: null } }) - .getQuery(), - { - default: `SELECT * FROM [users] AS [User] WHERE [User].[age] IS NOT NULL;`, - }, - ); - }); - }); - - describe('execute', () => { - it('should execute the query', async () => { - await User.create({ name: 'John', email: 'john@example.com', active: true }); - const result = await User.select() - .attributes(['name']) - .where({ active: true, name: 'John' }) - .execute(); - const [row] = result; - expect(row).to.deep.equal([{ name: 'John' }]); - }); - }); -}); From 7aba72d6fe7852419ff908f99542f4dc609aa968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Tue, 29 Jul 2025 20:22:23 -0300 Subject: [PATCH 12/14] fix: db2 --- .../core/test/integration/query-builder/query-builder.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/integration/query-builder/query-builder.test.ts b/packages/core/test/integration/query-builder/query-builder.test.ts index a9809a88d61a..9694d3ab93bb 100644 --- a/packages/core/test/integration/query-builder/query-builder.test.ts +++ b/packages/core/test/integration/query-builder/query-builder.test.ts @@ -150,7 +150,7 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { it('should generate SELECT query with LIMIT', () => { expectsql(User.select().limit(10).getQuery(), { default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10;', - mssql: + 'mssql db2': 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;', }); }); @@ -160,7 +160,7 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10 OFFSET 5;', 'mysql mariadb sqlite3': 'SELECT [User].* FROM `users` AS `User` ORDER BY `User`.`id` LIMIT 10 OFFSET 5;', - mssql: + 'mssql db2': 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY;', }); }); From 3918fa426e1d6b5e5c3e65b9862421dfc005a082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Tue, 29 Jul 2025 20:31:01 -0300 Subject: [PATCH 13/14] fix: db2 --- .../core/test/integration/query-builder/query-builder.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/test/integration/query-builder/query-builder.test.ts b/packages/core/test/integration/query-builder/query-builder.test.ts index 9694d3ab93bb..7392777c8ad5 100644 --- a/packages/core/test/integration/query-builder/query-builder.test.ts +++ b/packages/core/test/integration/query-builder/query-builder.test.ts @@ -150,8 +150,9 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { it('should generate SELECT query with LIMIT', () => { expectsql(User.select().limit(10).getQuery(), { default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10;', - 'mssql db2': + mssql: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;', + db2: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] FETCH NEXT 10 ROWS ONLY;', }); }); From 5a06374b9b8d871dec406b178feab2dc43e66b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Tue, 29 Jul 2025 20:48:53 -0300 Subject: [PATCH 14/14] fix: db2 --- .../core/test/integration/query-builder/query-builder.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/integration/query-builder/query-builder.test.ts b/packages/core/test/integration/query-builder/query-builder.test.ts index 7392777c8ad5..8eae03d0d6f5 100644 --- a/packages/core/test/integration/query-builder/query-builder.test.ts +++ b/packages/core/test/integration/query-builder/query-builder.test.ts @@ -152,7 +152,7 @@ describe(getTestDialectTeaser('QueryBuilder'), () => { default: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] LIMIT 10;', mssql: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;', - db2: 'SELECT [User].* FROM [users] AS [User] ORDER BY [User].[id] FETCH NEXT 10 ROWS ONLY;', + db2: 'SELECT "User".* FROM "users" AS "User" ORDER BY "User"."id" FETCH NEXT 10 ROWS ONLY;', }); });