diff --git a/packages/core/run_for_all_dialects.sh b/packages/core/run_for_all_dialects.sh new file mode 100755 index 000000000000..8551a88e43c9 --- /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=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 +echo "Done" \ No newline at end of file diff --git a/packages/core/src/abstract-dialect/query-generator.js b/packages/core/src/abstract-dialect/query-generator.js index 8190b2fc614d..72bcddf4e9e6 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,93 @@ 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 new file mode 100644 index 000000000000..a8a02d196bf0 --- /dev/null +++ b/packages/core/src/expression-builders/query-builder.ts @@ -0,0 +1,346 @@ +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 { 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. + */ +export class QueryBuilder extends BaseSqlExpression { + declare protected readonly [SQL_IDENTIFIER]: 'queryBuilder'; + + 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; + private _isSelect: boolean = false; + + constructor(model: ModelStatic) { + super(); + this._model = model; + this._sequelize = model.sequelize; + this._include = []; + } + + /** + * 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._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; + } + + /** + * Initialize a SELECT query + * + * @returns The query builder instance for chaining + */ + select(): QueryBuilder { + const newBuilder = new QueryBuilder(this._model); + newBuilder._isSelect = true; + + 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 = this.clone(); + newBuilder._attributes = attributes; + + 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 = this.clone(); + newBuilder._where = conditions; + + 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 + * + * @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; + } + + /** + * 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({ multiline = false }: QueryBuilderGetQueryOptions = {}): string { + if (!this._isSelect) { + throw new Error('Query builder requires select() to be called first'); + } + + 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!, + 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, + }; + + // 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; + } + + /** + * 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 + * + * @returns The table name + */ + get tableName(): string { + return this._model.modelDefinition.table.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/query-builder/query-builder.test.ts b/packages/core/test/integration/query-builder/query-builder.test.ts new file mode 100644 index 000000000000..8eae03d0d6f5 --- /dev/null +++ b/packages/core/test/integration/query-builder/query-builder.test.ts @@ -0,0 +1,666 @@ +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 { + createSequelizeInstance, + expectsql, + getTestDialect, + getTestDialectTeaser, +} 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 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;', + db2: 'SELECT "User".* FROM "users" AS "User" ORDER BY "User"."id" 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 db2': + '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'); + }); + } + }); +});