diff --git a/docs/docs/data-source/2-data-source-options.md b/docs/docs/data-source/2-data-source-options.md index 2491ed54f..88cfd4606 100644 --- a/docs/docs/data-source/2-data-source-options.md +++ b/docs/docs/data-source/2-data-source-options.md @@ -84,6 +84,20 @@ Different RDBMS-es have their own specific options. - `isolateWhereStatements` - Enables where statement isolation, wrapping each where clause in brackets automatically. eg. `.where("user.firstName = :search OR user.lastName = :search")` becomes `WHERE (user.firstName = ? OR user.lastName = ?)` instead of `WHERE user.firstName = ? OR user.lastName = ?` +- `invalidWhereValuesBehavior` - Controls how null and undefined values are handled in where conditions across all TypeORM operations (find operations, query builders, repository methods). + + - `null` behavior options: + - `'ignore'` (default) - skips null properties + - `'sql-null'` - transforms null to SQL NULL + - `'throw'` - throws an error + - `undefined` behavior options: + - `'ignore'` (default) - skips undefined properties + - `'throw'` - throws an error + + Example: `invalidWhereValuesBehavior: { null: 'sql-null', undefined: 'throw' }`. + + Learn more about [Null and Undefined Handling](./5-null-and-undefined-handling.md). + ## Data Source Options example Here is a small example of data source options for mysql: diff --git a/docs/docs/data-source/5-null-and-undefined-handling.md b/docs/docs/data-source/5-null-and-undefined-handling.md new file mode 100644 index 000000000..7d9796b7f --- /dev/null +++ b/docs/docs/data-source/5-null-and-undefined-handling.md @@ -0,0 +1,252 @@ +# Handling null and undefined values in where conditions + +In 'WHERE' conditions the values `null` and `undefined` are not strictly valid values in TypeORM. + +Passing a known `null` value is disallowed by TypeScript (when you've enabled `strictNullChecks` in tsconfig.json) at compile time. But the default behavior is for `null` values encountered at runtime to be ignored. Similarly, `undefined` values are allowed by TypeScript and ignored at runtime. + +The acceptance of `null` and `undefined` values can sometimes cause unexpected results and requires caution. This is especially a concern when values are passed from user input without adequate validation. + +For example, calling `Repository.findOneBy({ id: undefined })` returns the first row from the table, and `Repository.findBy({ userId: null })` is unfiltered and returns all rows. + +The way in which `null` and `undefined` values are handled can be customised through the `invalidWhereValuesBehavior` configuration option in your data source options. This applies to all operations that support 'WHERE' conditions, including find operations, query builders, and repository methods. + +:::note +The current behavior will be changing in future versions of TypeORM, +we recommend setting both `null` and `undefined` behaviors to throw to prepare for these changes +::: + +## Default Behavior + +By default, TypeORM skips both `null` and `undefined` values in where conditions. This means that if you include a property with a `null` or `undefined` value in your where clause, it will be ignored: + +```typescript +// Both queries will return all posts, ignoring the text property +const posts1 = await repository.find({ + where: { + text: null, + }, +}) + +const posts2 = await repository.find({ + where: { + text: undefined, + }, +}) +``` + +The correct way to match null values in where conditions is to use the `IsNull` operator (for details see [Find Options](../working-with-entity-manager/3-find-options.md)): + +```typescript +const posts = await repository.find({ + where: { + text: IsNull(), + }, +}) +``` + +## Configuration + +You can customize how null and undefined values are handled using the `invalidWhereValuesBehavior` option in your connection configuration: + +```typescript +const dataSource = new DataSource({ + // ... other options + invalidWhereValuesBehavior: { + null: "ignore" | "sql-null" | "throw", + undefined: "ignore" | "throw", + }, +}) +``` + +### Null Behavior Options + +The `null` behavior can be set to one of three values: + +#### `'ignore'` (default) + +JavaScript `null` values in where conditions are ignored and the property is skipped: + +```typescript +const dataSource = new DataSource({ + // ... other options + invalidWhereValuesBehavior: { + null: "ignore", + }, +}) + +// This will return all posts, ignoring the text property +const posts = await repository.find({ + where: { + text: null, + }, +}) +``` + +#### `'sql-null'` + +JavaScript `null` values are transformed into SQL `NULL` conditions: + +```typescript +const dataSource = new DataSource({ + // ... other options + invalidWhereValuesBehavior: { + null: "sql-null", + }, +}) + +// This will only return posts where the text column is NULL in the database +const posts = await repository.find({ + where: { + text: null, + }, +}) +``` + +#### `'throw'` + +JavaScript `null` values cause a TypeORMError to be thrown: + +```typescript +const dataSource = new DataSource({ + // ... other options + invalidWhereValuesBehavior: { + null: "throw", + }, +}) + +// This will throw an error +const posts = await repository.find({ + where: { + text: null, + }, +}) +// Error: Null value encountered in property 'text' of a where condition. +// To match with SQL NULL, the IsNull() operator must be used. +// Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values. +``` + +### Undefined Behavior Options + +The `undefined` behavior can be set to one of two values: + +#### `'ignore'` (default) + +JavaScript `undefined` values in where conditions are ignored and the property is skipped: + +```typescript +const dataSource = new DataSource({ + // ... other options + invalidWhereValuesBehavior: { + undefined: "ignore", + }, +}) + +// This will return all posts, ignoring the text property +const posts = await repository.find({ + where: { + text: undefined, + }, +}) +``` + +#### `'throw'` + +JavaScript `undefined` values cause a TypeORMError to be thrown: + +```typescript +const dataSource = new DataSource({ + // ... other options + invalidWhereValuesBehavior: { + undefined: "throw", + }, +}) + +// This will throw an error +const posts = await repository.find({ + where: { + text: undefined, + }, +}) +// Error: Undefined value encountered in property 'text' of a where condition. +// Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values. +``` + +Note that this only applies to explicitly set `undefined` values, not omitted properties. + +## Using Both Options Together + +You can configure both behaviors independently for comprehensive control: + +```typescript +const dataSource = new DataSource({ + // ... other options + invalidWhereValuesBehavior: { + null: "sql-null", + undefined: "throw", + }, +}) +``` + +This configuration will: + +1. Transform JavaScript `null` values to SQL `NULL` in where conditions +2. Throw an error if any `undefined` values are encountered +3. Still ignore properties that are not provided in the where clause + +This combination is useful when you want to: + +- Be explicit about searching for NULL values in the database +- Catch potential programming errors where undefined values might slip into your queries + +## Works with all where operations + +The `invalidWhereValuesBehavior` configuration applies to **all TypeORM operations** that support where conditions, not just repository find methods: + +### Query Builders + +```typescript +// UpdateQueryBuilder +await dataSource + .createQueryBuilder() + .update(Post) + .set({ title: "Updated" }) + .where({ text: null }) // Respects invalidWhereValuesBehavior + .execute() + +// DeleteQueryBuilder +await dataSource + .createQueryBuilder() + .delete() + .from(Post) + .where({ text: null }) // Respects invalidWhereValuesBehavior + .execute() + +// SoftDeleteQueryBuilder +await dataSource + .createQueryBuilder() + .softDelete() + .from(Post) + .where({ text: null }) // Respects invalidWhereValuesBehavior + .execute() +``` + +### Repository Methods + +```typescript +// Repository.update() +await repository.update({ text: null }, { title: "Updated" }) // Respects invalidWhereValuesBehavior + +// Repository.delete() +await repository.delete({ text: null }) // Respects invalidWhereValuesBehavior + +// EntityManager.update() +await manager.update(Post, { text: null }, { title: "Updated" }) // Respects invalidWhereValuesBehavior + +// EntityManager.delete() +await manager.delete(Post, { text: null }) // Respects invalidWhereValuesBehavior + +// EntityManager.softDelete() +await manager.softDelete(Post, { text: null }) // Respects invalidWhereValuesBehavior +``` + +All these operations will consistently apply your configured `invalidWhereValuesBehavior` settings. diff --git a/src/data-source/BaseDataSourceOptions.ts b/src/data-source/BaseDataSourceOptions.ts index c467d05ed..99a5d299b 100644 --- a/src/data-source/BaseDataSourceOptions.ts +++ b/src/data-source/BaseDataSourceOptions.ts @@ -214,4 +214,24 @@ export interface BaseDataSourceOptions { * Allows automatic isolation of where clauses */ readonly isolateWhereStatements?: boolean + + /** + * Controls how null and undefined values are handled in find operations. + */ + readonly invalidWhereValuesBehavior?: { + /** + * How to handle null values in where conditions. + * - 'ignore': Skip null properties (default) + * - 'sql-null': Transform null to SQL NULL + * - 'throw': Throw an error when null is encountered + */ + readonly null?: "ignore" | "sql-null" | "throw" + + /** + * How to handle undefined values in where conditions. + * - 'ignore': Skip undefined properties (default) + * - 'throw': Throw an error when undefined is encountered + */ + readonly undefined?: "ignore" | "throw" + } } diff --git a/src/query-builder/QueryBuilder.ts b/src/query-builder/QueryBuilder.ts index 91c8465cc..48a5df94f 100644 --- a/src/query-builder/QueryBuilder.ts +++ b/src/query-builder/QueryBuilder.ts @@ -1581,18 +1581,37 @@ export abstract class QueryBuilder { parameters: [aliasPath, ...parameters], } } - // } else if (parameterValue === null) { - // return { - // operator: "isNull", - // parameters: [ - // aliasPath, - // ] - // }; - } else { - return { - operator: "equal", - parameters: [aliasPath, this.createParameter(parameterValue)], + } else if (parameterValue === null) { + const nullBehavior = + this.connection.options.invalidWhereValuesBehavior?.null || + "ignore" + if (nullBehavior === "sql-null") { + return { + operator: "isNull", + parameters: [aliasPath], + } + } else if (nullBehavior === "throw") { + throw new TypeORMError( + `Null value encountered in property '${aliasPath}' of a where condition. ` + + `To match with SQL NULL, the IsNull() operator must be used. ` + + `Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.`, + ) } + } else if (parameterValue === undefined) { + const undefinedBehavior = + this.connection.options.invalidWhereValuesBehavior?.undefined || + "ignore" + if (undefinedBehavior === "throw") { + throw new TypeORMError( + `Undefined value encountered in property '${aliasPath}' of a where condition. ` + + `Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.`, + ) + } + } + + return { + operator: "equal", + parameters: [aliasPath, this.createParameter(parameterValue)], } } diff --git a/src/query-builder/SelectQueryBuilder.ts b/src/query-builder/SelectQueryBuilder.ts index e83d4c1de..3800e7f38 100644 --- a/src/query-builder/SelectQueryBuilder.ts +++ b/src/query-builder/SelectQueryBuilder.ts @@ -3165,6 +3165,7 @@ export class SelectQueryBuilder } this.selects = [] + if (this.findOptions.relations) { const relations = Array.isArray(this.findOptions.relations) ? OrmUtils.propertyPathsToTruthyObject( @@ -4304,7 +4305,7 @@ export class SelectQueryBuilder } else { const andConditions: string[] = [] for (const key in where) { - if (where[key] === undefined || where[key] === null) continue + let parameterValue = where[key] const propertyPath = embedPrefix ? embedPrefix + "." + key : key const column = @@ -4314,21 +4315,56 @@ export class SelectQueryBuilder const relation = metadata.findRelationWithPropertyPath(propertyPath) - if (!embed && !column && !relation) + if (!embed && !column && !relation) { throw new EntityPropertyNotFoundError( propertyPath, metadata, ) + } + + if (parameterValue === undefined) { + const undefinedBehavior = + this.connection.options.invalidWhereValuesBehavior + ?.undefined || "ignore" + if (undefinedBehavior === "throw") { + throw new TypeORMError( + `Undefined value encountered in property '${alias}.${key}' of a where condition. ` + + `Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.`, + ) + } + continue + } + + if (parameterValue === null) { + const nullBehavior = + this.connection.options.invalidWhereValuesBehavior + ?.null || "ignore" + if (nullBehavior === "ignore") { + continue + } else if (nullBehavior === "throw") { + throw new TypeORMError( + `Null value encountered in property '${alias}.${key}' of a where condition. ` + + `To match with SQL NULL, the IsNull() operator must be used. ` + + `Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.`, + ) + } + // 'sql-null' behavior continues to the next logic + } if (column) { let aliasPath = `${alias}.${propertyPath}` if (column.isVirtualProperty && column.query) { aliasPath = `(${column.query(this.escape(alias))})` } + + if (parameterValue === null) { + andConditions.push(`${aliasPath} IS NULL`) + continue + } + // const parameterName = alias + "_" + propertyPath.split(".").join("_") + "_" + parameterIndex; // todo: we need to handle other operators as well? - let parameterValue = where[key] if (InstanceChecker.isEqualOperator(where[key])) { parameterValue = where[key].value } @@ -4351,38 +4387,6 @@ export class SelectQueryBuilder ).parametrizeValues(column, parameterValue) } - // if (parameterValue === null) { - // andConditions.push(`${aliasPath} IS NULL`); - // - // } else if (parameterValue instanceof FindOperator) { - // // let parameters: any[] = []; - // // if (parameterValue.useParameter) { - // // const realParameterValues: any[] = parameterValue.multipleParameters ? parameterValue.value : [parameterValue.value]; - // // realParameterValues.forEach((realParameterValue, realParameterValueIndex) => { - // // - // // // don't create parameters for number to prevent max number of variables issues as much as possible - // // if (typeof realParameterValue === "number") { - // // parameters.push(realParameterValue); - // // - // // } else { - // // this.expressionMap.nativeParameters[parameterName + realParameterValueIndex] = realParameterValue; - // // parameterIndex++; - // // parameters.push(this.connection.driver.createParameter(parameterName + realParameterValueIndex, parameterIndex - 1)); - // // } - // // }); - // // } - // andConditions.push( - // this.createWhereConditionExpression(this.getWherePredicateCondition(aliasPath, parameterValue)) - // // parameterValue.toSql(this.connection, aliasPath, parameters)); - // ) - // - // } else { - // this.expressionMap.nativeParameters[parameterName] = parameterValue; - // parameterIndex++; - // const parameter = this.connection.driver.createParameter(parameterName, parameterIndex - 1); - // andConditions.push(`${aliasPath} = ${parameter}`); - // } - andConditions.push( this.createWhereConditionExpression( this.getWherePredicateCondition( @@ -4404,6 +4408,24 @@ export class SelectQueryBuilder ) if (condition) andConditions.push(condition) } else if (relation) { + if (where[key] === null) { + const nullBehavior = + this.connection.options.invalidWhereValuesBehavior + ?.null || "ignore" + if (nullBehavior === "sql-null") { + andConditions.push( + `${alias}.${propertyPath} IS NULL`, + ) + } else if (nullBehavior === "throw") { + throw new TypeORMError( + `Null value encountered in property '${alias}.${key}' of a where condition. ` + + `Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.`, + ) + } + // 'ignore' behavior falls through to continue + continue + } + // if all properties of where are undefined we don't need to join anything // this can happen when user defines map with conditional queries inside if (typeof where[key] === "object") { diff --git a/test/functional/find-options/empty-properties/find-options-empty-properties.ts b/test/functional/find-options/empty-properties/find-options-empty-properties.ts index c61b56d0e..7af50633a 100644 --- a/test/functional/find-options/empty-properties/find-options-empty-properties.ts +++ b/test/functional/find-options/empty-properties/find-options-empty-properties.ts @@ -61,7 +61,7 @@ describe("find options > where", () => { const posts1 = await connection .createQueryBuilder(Post, "post") .setFindOptions({ - // @ts-expect-error + // @ts-expect-error - null should be marked as unsafe by default where: { title: "Post #1", text: null, @@ -79,7 +79,7 @@ describe("find options > where", () => { const posts2 = await connection .createQueryBuilder(Post, "post") .setFindOptions({ - // @ts-expect-error + // @ts-expect-error - null should be marked as unsafe by default where: { text: null, }, diff --git a/test/functional/null-undefined-handling/entity/Category.ts b/test/functional/null-undefined-handling/entity/Category.ts new file mode 100644 index 000000000..386aa4d41 --- /dev/null +++ b/test/functional/null-undefined-handling/entity/Category.ts @@ -0,0 +1,22 @@ +import { + Column, + Entity, + PrimaryGeneratedColumn, + OneToMany, +} from "../../../../src" +import { Post } from "./Post" + +@Entity() +export class Category { + @PrimaryGeneratedColumn() + id: number + + @Column() + name: string + + @Column({ nullable: true, type: "varchar" }) + slug: string | null + + @OneToMany(() => Post, (post) => post.category) + posts: Post[] +} diff --git a/test/functional/null-undefined-handling/entity/Post.ts b/test/functional/null-undefined-handling/entity/Post.ts new file mode 100644 index 000000000..a6fd29176 --- /dev/null +++ b/test/functional/null-undefined-handling/entity/Post.ts @@ -0,0 +1,24 @@ +import { + Column, + Entity, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, +} from "../../../../src" +import { Category } from "./Category" + +@Entity() +export class Post { + @PrimaryGeneratedColumn() + id: number + + @Column() + title: string + + @Column({ nullable: true, type: "varchar" }) + text: string | null + + @ManyToOne(() => Category, { nullable: true }) + @JoinColumn() + category: Category | null +} diff --git a/test/functional/null-undefined-handling/find-options.ts b/test/functional/null-undefined-handling/find-options.ts new file mode 100644 index 000000000..8a2bc46ef --- /dev/null +++ b/test/functional/null-undefined-handling/find-options.ts @@ -0,0 +1,729 @@ +import "reflect-metadata" +import "../../utils/test-setup" +import { DataSource, TypeORMError } from "../../../src" +import { + closeTestingConnections, + createTestingConnections, + reloadTestingDatabases, +} from "../../utils/test-utils" +import { Post } from "./entity/Post" +import { Category } from "./entity/Category" +import { expect } from "chai" + +describe("find options > null and undefined handling", () => { + let connections: DataSource[] + + describe("with default behavior", () => { + before(async () => { + connections = await createTestingConnections({ + entities: [Post, Category], + schemaCreate: true, + dropSchema: true, + }) + }) + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + async function prepareData(connection: DataSource) { + const category1 = new Category() + category1.name = "Category #1" + await connection.manager.save(category1) + + const category2 = new Category() + category2.name = "Category #2" + await connection.manager.save(category2) + + const post1 = new Post() + post1.title = "Post #1" + post1.text = "About post #1" + post1.category = category1 + await connection.manager.save(post1) + + const post2 = new Post() + post2.title = "Post #2" + post2.text = null + post2.category = category2 + await connection.manager.save(post2) + + const post3 = new Post() + post3.title = "Post #3" + post3.text = "About post #3" + post3.category = null + await connection.manager.save(post3) + } + + it("should skip null properties by default", () => + Promise.all( + connections.map(async (connection) => { + await prepareData(connection) + + // Test with QueryBuilder + const postsWithQb = await connection + .createQueryBuilder(Post, "post") + .setFindOptions({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + title: "Post #1", + text: null, + }, + }) + .getMany() + + // This should return post1 since null properties are skipped by default + postsWithQb.should.be.eql([ + { id: 1, title: "Post #1", text: "About post #1" }, + ]) + + // Test with Repository find + const postsWithRepo = await connection + .getRepository(Post) + .find({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + text: null, + }, + }) + + // This should return all posts since null properties are skipped by default + postsWithRepo.length.should.be.equal(3) + }), + )) + + it("should skip undefined properties by default", () => + Promise.all( + connections.map(async (connection) => { + await prepareData(connection) + + // Test with QueryBuilder + const postsWithQb = await connection + .createQueryBuilder(Post, "post") + .setFindOptions({ + where: { + title: "Post #1", + text: undefined, + }, + }) + .getMany() + + // This should return post1 since undefined properties are skipped by default + postsWithQb.should.be.eql([ + { id: 1, title: "Post #1", text: "About post #1" }, + ]) + + // Test with Repository + const postsWithRepo = await connection + .getRepository(Post) + .find({ + where: { + text: undefined, + }, + }) + + // This should return all posts since undefined properties are skipped by default + postsWithRepo.length.should.be.equal(3) + }), + )) + + it("should skip null relation properties by default", () => + Promise.all( + connections.map(async (connection) => { + await prepareData(connection) + + // Test with QueryBuilder + const postsWithQb = await connection + .createQueryBuilder(Post, "post") + .setFindOptions({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + category: null, + }, + }) + .getMany() + + // This should return all posts since null properties are skipped by default + postsWithQb.length.should.be.equal(3) + + // Test with Repository + const postsWithRepo = await connection + .getRepository(Post) + .find({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + category: null, + }, + }) + + // This should return all posts since null properties are skipped by default + postsWithRepo.length.should.be.equal(3) + }), + )) + + it("should skip undefined relation properties by default", () => + Promise.all( + connections.map(async (connection) => { + await prepareData(connection) + + // Test with QueryBuilder + const postsWithQb = await connection + .createQueryBuilder(Post, "post") + .setFindOptions({ + where: { + category: undefined, + }, + }) + .getMany() + + // This should return all posts since undefined properties are skipped by default + postsWithQb.length.should.be.equal(3) + + // Test with Repository + const postsWithRepo = await connection + .getRepository(Post) + .find({ + where: { + category: undefined, + }, + }) + + // This should return all posts since undefined properties are skipped by default + postsWithRepo.length.should.be.equal(3) + }), + )) + }) + + describe("with invalidWhereValuesBehavior.null set to 'sql-null'", () => { + before(async () => { + connections = await createTestingConnections({ + entities: [Post, Category], + schemaCreate: true, + dropSchema: true, + driverSpecific: { + invalidWhereValuesBehavior: { + null: "sql-null", + }, + }, + }) + }) + + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + async function prepareData(connection: DataSource) { + const category1 = new Category() + category1.name = "Category #1" + await connection.manager.save(category1) + + const post1 = new Post() + post1.title = "Post #1" + post1.text = null + post1.category = null + await connection.manager.save(post1) + + const post2 = new Post() + post2.title = "Post #2" + post2.text = "Some text" + post2.category = category1 + await connection.manager.save(post2) + } + + it("should transform JS null to SQL NULL when invalidWhereValuesBehavior.null is 'sql-null'", () => + Promise.all( + connections.map(async (connection) => { + await prepareData(connection) + + // Test QueryBuilder with null text + const posts1 = await connection + .createQueryBuilder(Post, "post") + .where({ + text: null, + }) + .getMany() + + expect(posts1.length).to.equal(1) + expect(posts1[0].title).to.equal("Post #1") + + // Test Repository with null text + const posts2 = await connection.getRepository(Post).find({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + text: null, + }, + }) + + expect(posts2.length).to.equal(1) + expect(posts2[0].title).to.equal("Post #1") + + // Test with Repository with null text and findOne + const postWithRepo = await connection + .getRepository(Post) + .findOne({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + text: null, + }, + }) + + expect(postWithRepo?.title).to.equal("Post #1") + }), + )) + + it("should transform JS null to SQL NULL for relations when invalidWhereValuesBehavior.null is 'sql-null'", () => + Promise.all( + connections.map(async (connection) => { + await prepareData(connection) + + // Test QueryBuilder with null relation + const posts1 = await connection + .createQueryBuilder(Post, "post") + .where({ + category: null, + }) + .getMany() + + expect(posts1.length).to.equal(1) + expect(posts1[0].title).to.equal("Post #1") + + // Test Repository with null relation + const posts2 = await connection.getRepository(Post).find({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + category: null, + }, + }) + + expect(posts2.length).to.equal(1) + expect(posts2[0].title).to.equal("Post #1") + + // Test with Repository with null relation and findOne + const postWithRepo = await connection + .getRepository(Post) + .findOne({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + category: null, + }, + }) + + expect(postWithRepo?.title).to.equal("Post #1") + + const postWithRepo2 = await connection + .getRepository(Post) + .findOne({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + category: { + slug: null, + }, + }, + }) + + expect(postWithRepo2?.title).to.equal("Post #1") + }), + )) + }) + + describe("with invalidWhereValuesBehavior.undefined set to 'throw'", () => { + before(async () => { + connections = await createTestingConnections({ + entities: [Post, Category], + schemaCreate: true, + dropSchema: true, + driverSpecific: { + invalidWhereValuesBehavior: { + undefined: "throw", + }, + }, + }) + }) + + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + it("should throw an error when undefined is encountered and invalidWhereValuesBehavior.undefined is 'throw'", async () => { + for (const connection of connections) { + try { + await connection + .createQueryBuilder(Post, "post") + .where({ + text: undefined, + }) + .getMany() + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Undefined value encountered in property 'post.text' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.", + ) + } + + try { + await connection.getRepository(Post).find({ + where: { + text: undefined, + }, + }) + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Undefined value encountered in property 'Post.text' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.", + ) + } + + try { + await connection.getRepository(Post).findOne({ + where: { + text: undefined, + }, + }) + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Undefined value encountered in property 'Post.text' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.", + ) + } + } + }) + + it("should throw an error when undefined is encountered in relations and invalidWhereValuesBehavior.undefined is 'throw'", () => + Promise.all( + connections.map(async (connection) => { + try { + await connection + .createQueryBuilder(Post, "post") + .where({ + category: undefined, + }) + .getMany() + + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Undefined value encountered in property 'post.category.id' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.", + ) + } + + try { + await connection.getRepository(Post).find({ + where: { + category: undefined, + }, + }) + + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Undefined value encountered in property 'Post.category' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.", + ) + } + + try { + await connection.getRepository(Post).findOne({ + where: { + category: undefined, + }, + }) + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Undefined value encountered in property 'Post.category' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.", + ) + } + }), + )) + + it("should not throw when a property is not provided", () => + Promise.all( + connections.map(async (connection) => { + // Create test data + const category = new Category() + category.name = "Category #1" + await connection.manager.save(category) + + const post1 = new Post() + post1.title = "Post #1" + post1.text = "Some text" + post1.category = category + await connection.manager.save(post1) + + // Test QueryBuilder + const posts1 = await connection + .createQueryBuilder(Post, "post") + .where({ + title: "Post #1", + }) + .getMany() + + expect(posts1.length).to.equal(1) + expect(posts1[0].title).to.equal("Post #1") + + // Test Repository + const posts2 = await connection.getRepository(Post).find({ + where: { + title: "Post #1", + }, + }) + + expect(posts2.length).to.equal(1) + expect(posts2[0].title).to.equal("Post #1") + + // Test Repository with findOne + const postWithRepo = await connection + .getRepository(Post) + .findOne({ + where: { + title: "Post #1", + }, + }) + + expect(postWithRepo?.title).to.equal("Post #1") + }), + )) + }) + + describe("with both invalidWhereValuesBehavior options enabled", () => { + before(async () => { + connections = await createTestingConnections({ + entities: [Post, Category], + schemaCreate: true, + dropSchema: true, + driverSpecific: { + invalidWhereValuesBehavior: { + null: "sql-null", + undefined: "throw", + }, + }, + }) + }) + + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + async function prepareData(connection: DataSource) { + const category1 = new Category() + category1.name = "Category #1" + await connection.manager.save(category1) + + const post1 = new Post() + post1.title = "Post #1" + post1.text = null + post1.category = null + await connection.manager.save(post1) + + const post2 = new Post() + post2.title = "Post #2" + post2.text = "Some text" + post2.category = category1 + await connection.manager.save(post2) + } + + it("should handle both null and undefined correctly", () => + Promise.all( + connections.map(async (connection) => { + await prepareData(connection) + + // Test null handling for text + const posts = await connection.getRepository(Post).find({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + text: null, + }, + }) + + expect(posts.length).to.equal(1) + expect(posts[0].title).to.equal("Post #1") + + // Test null handling for relations + const postsWithNullCategory = await connection + .getRepository(Post) + .find({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + category: null, + }, + }) + + expect(postsWithNullCategory.length).to.equal(1) + expect(postsWithNullCategory[0].title).to.equal("Post #1") + + // Test undefined handling for text + try { + await connection + .createQueryBuilder(Post, "post") + .where({ + text: undefined, + }) + .getMany() + + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Undefined value encountered in property 'post.text' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.", + ) + } + + // Test undefined handling for relations + try { + await connection + .createQueryBuilder(Post, "post") + .where({ + category: undefined, + }) + .getMany() + + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Undefined value encountered in property 'post.category.id' of a where condition. Set 'invalidWhereValuesBehavior.undefined' to 'ignore' in connection options to skip properties with undefined values.", + ) + } + + // Test omitted property + const posts2 = await connection.getRepository(Post).find({ + where: { + title: "Post #2", + }, + }) + + expect(posts2.length).to.equal(1) + expect(posts2[0].title).to.equal("Post #2") + + // Test Repository with findOne + const postWithRepo = await connection + .getRepository(Post) + .findOne({ + where: { + title: "Post #2", + }, + }) + + expect(postWithRepo?.title).to.equal("Post #2") + }), + )) + }) + + describe("with invalidWhereValuesBehavior.null set to 'throw'", () => { + before(async () => { + connections = await createTestingConnections({ + entities: [Post, Category], + schemaCreate: true, + dropSchema: true, + driverSpecific: { + invalidWhereValuesBehavior: { + null: "throw", + }, + }, + }) + }) + + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + it("should throw an error when null is encountered and invalidWhereValuesBehavior.null is 'throw'", async () => { + for (const connection of connections) { + try { + await connection + .createQueryBuilder(Post, "post") + .where({ + text: null, + }) + .getMany() + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Null value encountered in property 'post.text' of a where condition. To match with SQL NULL, the IsNull() operator must be used. Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.", + ) + } + + try { + await connection.getRepository(Post).find({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + text: null, + }, + }) + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Null value encountered in property 'Post.text' of a where condition. To match with SQL NULL, the IsNull() operator must be used. Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.", + ) + } + + try { + await connection.getRepository(Post).findOne({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + text: null, + }, + }) + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Null value encountered in property 'Post.text' of a where condition. To match with SQL NULL, the IsNull() operator must be used. Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.", + ) + } + } + }) + + it("should throw an error when null is encountered in relations and invalidWhereValuesBehavior.null is 'throw'", () => + Promise.all( + connections.map(async (connection) => { + try { + await connection + .createQueryBuilder(Post, "post") + .where({ + category: null, + }) + .getMany() + + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Null value encountered in property 'post.category.id' of a where condition. To match with SQL NULL, the IsNull() operator must be used. Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.", + ) + } + + try { + await connection.getRepository(Post).find({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + category: null, + }, + }) + + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Null value encountered in property 'Post.category' of a where condition. To match with SQL NULL, the IsNull() operator must be used. Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.", + ) + } + + try { + await connection.getRepository(Post).findOne({ + // @ts-expect-error - null should be marked as unsafe by default + where: { + category: null, + }, + }) + expect.fail("Expected query to throw an error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.equal( + "Null value encountered in property 'Post.category' of a where condition. To match with SQL NULL, the IsNull() operator must be used. Set 'invalidWhereValuesBehavior.null' to 'ignore' or 'sql-null' in connection options to skip or handle null values.", + ) + } + }), + )) + }) +}) diff --git a/test/functional/null-undefined-handling/query-builders.ts b/test/functional/null-undefined-handling/query-builders.ts new file mode 100644 index 000000000..d9ad79fde --- /dev/null +++ b/test/functional/null-undefined-handling/query-builders.ts @@ -0,0 +1,347 @@ +import "reflect-metadata" +import "../../utils/test-setup" +import { DataSource, TypeORMError } from "../../../src" +import { + closeTestingConnections, + createTestingConnections, + reloadTestingDatabases, +} from "../../utils/test-utils" +import { Post } from "./entity/Post" +import { Category } from "./entity/Category" +import { expect } from "chai" + +describe("query builder > invalidWhereValuesBehavior", () => { + let connections: DataSource[] + + before(async () => { + connections = await createTestingConnections({ + entities: [Post, Category], + schemaCreate: true, + dropSchema: true, + driverSpecific: { + invalidWhereValuesBehavior: { + null: "throw", + undefined: "throw", + }, + }, + }) + }) + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + async function prepareData(connection: DataSource) { + const category = new Category() + category.name = "Test Category" + await connection.manager.save(category) + + const post = new Post() + post.title = "Test Post" + post.text = "Some text" + post.category = category + await connection.manager.save(post) + + return { category, post } + } + + it("should throw error for null values in UpdateQueryBuilder", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection + .createQueryBuilder() + .update(Post) + .set({ title: "Updated" }) + .where({ text: null }) + .execute() + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Null value encountered") + } + } + }) + + it("should throw error for undefined values in UpdateQueryBuilder", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection + .createQueryBuilder() + .update(Post) + .set({ title: "Updated" }) + .where({ text: undefined }) + .execute() + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Undefined value encountered") + } + } + }) + + it("should throw error for null values in DeleteQueryBuilder", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection + .createQueryBuilder() + .delete() + .from(Post) + .where({ text: null }) + .execute() + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Null value encountered") + } + } + }) + + it("should throw error for undefined values in DeleteQueryBuilder", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection + .createQueryBuilder() + .delete() + .from(Post) + .where({ text: undefined }) + .execute() + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Undefined value encountered") + } + } + }) + + it("should throw error for null values in SoftDeleteQueryBuilder", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection + .createQueryBuilder() + .softDelete() + .from(Post) + .where({ text: null }) + .execute() + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Null value encountered") + } + } + }) + + it("should throw error for undefined values in SoftDeleteQueryBuilder", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection + .createQueryBuilder() + .softDelete() + .from(Post) + .where({ text: undefined }) + .execute() + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Undefined value encountered") + } + } + }) +}) + +describe("query builder > invalidWhereValuesBehavior sql-null", () => { + let connections: DataSource[] + + before(async () => { + connections = await createTestingConnections({ + entities: [Post, Category], + schemaCreate: true, + dropSchema: true, + driverSpecific: { + invalidWhereValuesBehavior: { + null: "sql-null", + }, + }, + }) + }) + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + async function prepareData(connection: DataSource) { + const category = new Category() + category.name = "Test Category" + await connection.manager.save(category) + + const post1 = new Post() + post1.title = "Post 1" + post1.text = "Some text" + post1.category = category + await connection.manager.save(post1) + + const post2 = new Post() + post2.title = "Post 2" + post2.text = null + post2.category = category + await connection.manager.save(post2) + + return { category, post1, post2 } + } + + it("should handle null as SQL NULL in UpdateQueryBuilder", async () => { + for (const connection of connections) { + const { post2 } = await prepareData(connection) + + const result = await connection + .createQueryBuilder() + .update(Post) + .set({ title: "Updated Null Post" }) + .where({ text: null }) + .execute() + + expect(result.affected).to.equal(1) + + const updatedPost = await connection.manager.findOne(Post, { + where: { id: post2.id }, + }) + expect(updatedPost?.title).to.equal("Updated Null Post") + } + }) + + it("should handle null as SQL NULL in DeleteQueryBuilder", async () => { + for (const connection of connections) { + const { post1 } = await prepareData(connection) + + const result = await connection + .createQueryBuilder() + .delete() + .from(Post) + .where({ text: null }) + .execute() + + expect(result.affected).to.equal(1) + + const remainingPosts = await connection.manager.find(Post) + expect(remainingPosts).to.have.lengthOf(1) + expect(remainingPosts[0].id).to.equal(post1.id) + } + }) +}) + +describe("repository methods > invalidWhereValuesBehavior", () => { + let connections: DataSource[] + + before(async () => { + connections = await createTestingConnections({ + entities: [Post, Category], + schemaCreate: true, + dropSchema: true, + driverSpecific: { + invalidWhereValuesBehavior: { + null: "throw", + undefined: "throw", + }, + }, + }) + }) + beforeEach(() => reloadTestingDatabases(connections)) + after(() => closeTestingConnections(connections)) + + async function prepareData(connection: DataSource) { + const category = new Category() + category.name = "Test Category" + await connection.manager.save(category) + + const post = new Post() + post.title = "Test Post" + post.text = "Some text" + post.category = category + await connection.manager.save(post) + + return { category, post } + } + + it("should throw error for null values in Repository.update()", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection + .getRepository(Post) + .update({ text: null } as any, { title: "Updated" }) + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Null value encountered") + } + } + }) + + it("should throw error for null values in EntityManager.update()", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection.manager.update(Post, { text: null } as any, { + title: "Updated", + }) + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Null value encountered") + } + } + }) + + it("should throw error for null values in EntityManager.delete()", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection.manager.delete(Post, { text: null } as any) + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Null value encountered") + } + } + }) + + it("should throw error for null values in Repository.delete()", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection + .getRepository(Post) + .delete({ text: null } as any) + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Null value encountered") + } + } + }) + + it("should throw error for null values in EntityManager.softDelete()", async () => { + for (const connection of connections) { + await prepareData(connection) + + try { + await connection.manager.softDelete(Post, { text: null } as any) + expect.fail("Expected error") + } catch (error) { + expect(error).to.be.instanceOf(TypeORMError) + expect(error.message).to.include("Null value encountered") + } + } + }) +}) diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts index 2a32582cc..d9961c4d2 100644 --- a/test/utils/test-utils.ts +++ b/test/utils/test-utils.ts @@ -485,6 +485,7 @@ export async function createTestingConnections( * Closes testing connections if they are connected. */ export function closeTestingConnections(connections: DataSource[]) { + if (!connections || connections.length === 0) return Promise.resolve() return Promise.all( connections.map((connection) => connection && connection.isInitialized