UNPKG

@jakub.knejzlik/ts-query

Version:

TypeScript implementation of SQL builder

602 lines (531 loc) 20.9 kB
import dayjs from "dayjs"; import { Cond, Condition } from "./Condition"; import { Fn } from "./Function"; import { Q } from "./Query"; const flavor = Q.flavors.mysql; describe("Condition", () => { // EQUAL it("should format equal", () => { expect(Cond.equal("foo", 123).toSQL(flavor)).toEqual("`foo` = 123"); expect(Cond.equal("foo", "123").toSQL(flavor)).toEqual('`foo` = "123"'); }); // NOT EQUAL it("should format notEqual", () => { expect(Cond.notEqual("foo", 123).toSQL(flavor)).toEqual("`foo` != 123"); expect(Cond.notEqual("foo", "123").toSQL(flavor)).toEqual('`foo` != "123"'); }); // GREATER THAN it("should format greaterThan", () => { expect(Cond.greaterThan("foo", 123).toSQL(flavor)).toEqual("`foo` > 123"); expect(Cond.greaterThan("foo", "123").toSQL(flavor)).toEqual( '`foo` > "123"' ); }); // LESS THAN it("should format lessThan", () => { expect(Cond.lessThan("foo", 123).toSQL(flavor)).toEqual("`foo` < 123"); expect(Cond.lessThan("foo", "123").toSQL(flavor)).toEqual('`foo` < "123"'); }); // GREATER THAN OR EQUAL it("should format greaterThanOrEqual", () => { expect(Cond.greaterThanOrEqual("foo", 123).toSQL(flavor)).toEqual( "`foo` >= 123" ); expect(Cond.greaterThanOrEqual("foo", "123").toSQL(flavor)).toEqual( '`foo` >= "123"' ); }); // LESS THAN OR EQUAL it("should format lessThanOrEqual", () => { expect(Cond.lessThanOrEqual("foo", 123).toSQL(flavor)).toEqual( "`foo` <= 123" ); expect(Cond.lessThanOrEqual("foo", "123").toSQL(flavor)).toEqual( '`foo` <= "123"' ); }); // BETWEEN it("should format between", () => { expect(Cond.between("foo", [1, 2]).toSQL(flavor)).toEqual( "`foo` BETWEEN 1 AND 2" ); expect(Cond.between("foo", ["1", 2]).toSQL(flavor)).toEqual( '`foo` BETWEEN "1" AND 2' ); }); it("should format between", () => { expect(Cond.between("foo", [1, 2]).toSQL(flavor)).toEqual( "`foo` BETWEEN 1 AND 2" ); expect( Cond.between("foo", [dayjs("2024-01-01").startOf("year"), 2]).toSQL( flavor ) ).toEqual('`foo` BETWEEN "2024-01-01 00:00:00" AND 2'); }); // IN it("should format in", () => { expect(Cond.in("foo", [1, 2, 3])?.toSQL(flavor)).toEqual( "`foo` IN (1, 2, 3)" ); expect(Cond.in("foo", ["1", "2", "3"])?.toSQL(flavor)).toEqual( '`foo` IN ("1", "2", "3")' ); // Fail-closed: empty / null lists render `IN (NULL)` (never matches) rather than // dropping the filter, so an empty IN can never silently expose all rows. expect(Cond.in("foo", [])?.toSQL(flavor)).toEqual("`foo` IN (NULL)"); expect(Cond.in("foo", null)?.toSQL(flavor)).toEqual("`foo` IN (NULL)"); }); // NOT IN it("should format notIn", () => { expect(Cond.notIn("foo", [1, 2, 3]).toSQL(flavor)).toEqual( "`foo` NOT IN (1, 2, 3)" ); // BUG: strings in notIn are treated as column names instead of string literals // Expected: '`foo` NOT IN ("1", "2", "3")' // Actual: '`foo` NOT IN (`1`, `2`, `3`)' - treats as columns! expect(Cond.notIn("foo", ["1", "2", "3"]).toSQL(flavor)).toEqual( '`foo` NOT IN ("1", "2", "3")' ); }); // IN and NOT IN API consistency it("should treat string values consistently in both in() and notIn()", () => { // Real-world example from the report const inResult = Cond.in("type", ["IncomingInvoice", "ExpenditureCashSlip"])?.toSQL(flavor); const notInResult = Cond.notIn("type", ["IncomingInvoice", "ExpenditureCashSlip"]).toSQL(flavor); // Both should quote strings as values, not treat them as column names expect(inResult).toEqual('`type` IN ("IncomingInvoice", "ExpenditureCashSlip")'); expect(notInResult).toEqual('`type` NOT IN ("IncomingInvoice", "ExpenditureCashSlip")'); }); // IN/NOT IN edge cases it("should handle single-element arrays", () => { expect(Cond.in("foo", ["only_one"])?.toSQL(flavor)).toEqual( '`foo` IN ("only_one")' ); expect(Cond.notIn("foo", [42]).toSQL(flavor)).toEqual("`foo` NOT IN (42)"); }); it("should handle boolean values in arrays", () => { expect(Cond.in("active", [true, false])?.toSQL(flavor)).toEqual( "`active` IN (true, false)" ); expect(Cond.notIn("active", [true]).toSQL(flavor)).toEqual( "`active` NOT IN (true)" ); }); it("should handle Date objects in arrays", () => { const date1 = new Date("2024-01-15T10:30:00Z"); const date2 = new Date("2024-06-20T14:00:00Z"); const inResult = Cond.in("created_at", [date1, date2])?.toSQL(flavor); expect(inResult).toContain("`created_at` IN ("); expect(inResult).toContain("2024-01-15"); expect(inResult).toContain("2024-06-20"); }); it("should handle dayjs objects in arrays", () => { const day1 = dayjs("2024-01-01"); const day2 = dayjs("2024-12-31"); const inResult = Cond.in("date", [day1, day2])?.toSQL(flavor); expect(inResult).toContain("`date` IN ("); expect(inResult).toContain("2024-01-01"); expect(inResult).toContain("2024-12-31"); }); it("should handle Q.raw() expressions in arrays", () => { expect(Cond.in("col", [Q.raw("NOW()"), Q.raw("CURDATE()")])?.toSQL(flavor)).toEqual( "`col` IN (NOW(), CURDATE())" ); expect(Cond.notIn("col", [Q.raw("NULL")])?.toSQL(flavor)).toEqual( "`col` NOT IN (NULL)" ); }); it("should not allow FunctionExpression in arrays (use Q.raw instead)", () => { // FunctionExpression cannot be used as a value - this is by design expect(() => Cond.in("col", [Fn.max("price")])).toThrow( "FunctionExpression cannot be used as a value" ); // Use Q.raw() for function calls in IN/NOT IN expect(Cond.in("col", [Q.raw("MAX(price)")])?.toSQL(flavor)).toEqual( "`col` IN (MAX(price))" ); }); it("should handle explicit column references with Q.raw()", () => { // When user explicitly wants column references, they can use Q.raw() expect(Cond.in("col", [Q.raw("other_col"), Q.raw("another")])?.toSQL(flavor)).toEqual( "`col` IN (other_col, another)" ); }); it("should handle double negation with not(notIn())", () => { expect(Cond.not(Cond.notIn("foo", [1, 2, 3])).toSQL(flavor)).toEqual( "NOT (`foo` NOT IN (1, 2, 3))" ); expect(Cond.not(Cond.notIn("type", ["a", "b"])).toSQL(flavor)).toEqual( 'NOT (`type` NOT IN ("a", "b"))' ); }); // AND it("should format and", () => { const condition1 = Cond.equal("foo", 123); const condition2 = Cond.greaterThan("bar", 50); expect(Cond.and([condition1, condition2])?.toSQL(flavor)).toEqual( "(`foo` = 123 AND `bar` > 50)" ); expect(Cond.and([])).toBeNull(); expect(Cond.and(null)).toBeNull(); expect(Cond.and([null])).toBeNull(); expect(Cond.and([Cond.or([])])).toBeNull(); }); // OR it("should format or", () => { const condition1 = Cond.equal("foo", 123); const condition2 = Cond.lessThan("bar", 50); expect(Cond.or([condition1, condition2])?.toSQL(flavor)).toEqual( "(`foo` = 123 OR `bar` < 50)" ); expect(Cond.or([])).toBeNull(); expect(Cond.or(null)).toBeNull(); expect(Cond.or([null])).toBeNull(); expect(Cond.or([Cond.and([])])).toBeNull(); }); // NULL it("should format null", () => { expect(Cond.null("foo").toSQL(flavor)).toEqual("`foo` IS NULL"); }); // NOT NULL it("should format not null", () => { expect(Cond.notNull("foo").toSQL(flavor)).toEqual("`foo` IS NOT NULL"); }); // IS NULL it("should format isNull", () => { expect(Cond.isNull("foo").toSQL(flavor)).toEqual("`foo` IS NULL"); }); // IS NOT NULL it("should format isNotNull", () => { expect(Cond.isNotNull("foo").toSQL(flavor)).toEqual("`foo` IS NOT NULL"); }); // LIKE it("should format like", () => { expect(Cond.like("foo", "He_lo wor%").toSQL(flavor)).toEqual( '`foo` LIKE "He_lo wor%"' ); }); // NOT LIKE it("should format like", () => { expect(Cond.notLike("foo", "He_lo wor%").toSQL(flavor)).toEqual( '`foo` NOT LIKE "He_lo wor%"' ); }); // NOT it("should format not", () => { expect(Cond.not(Cond.equal("foo", 123)).toSQL(flavor)).toEqual( "NOT (`foo` = 123)" ); expect(Cond.not(Cond.greaterThan("bar", 50)).toSQL(flavor)).toEqual( "NOT (`bar` > 50)" ); }); it("should format not with complex conditions", () => { const andCondition = Cond.and([ Cond.equal("foo", 123), Cond.greaterThan("bar", 50), ]); expect(Cond.not(andCondition!).toSQL(flavor)).toEqual( "NOT ((`foo` = 123 AND `bar` > 50))" ); const orCondition = Cond.or([ Cond.equal("foo", 123), Cond.lessThan("bar", 50), ]); expect(Cond.not(orCondition!).toSQL(flavor)).toEqual( "NOT ((`foo` = 123 OR `bar` < 50))" ); }); it("should format nested not conditions", () => { const notCondition = Cond.not(Cond.equal("foo", 123)); expect(Cond.not(notCondition).toSQL(flavor)).toEqual( "NOT (NOT (`foo` = 123))" ); }); it("should format not with other condition types", () => { expect(Cond.not(Cond.null("foo")).toSQL(flavor)).toEqual( "NOT (`foo` IS NULL)" ); expect(Cond.not(Cond.in("foo", [1, 2, 3])!).toSQL(flavor)).toEqual( "NOT (`foo` IN (1, 2, 3))" ); expect(Cond.not(Cond.between("foo", [1, 10])).toSQL(flavor)).toEqual( "NOT (`foo` BETWEEN 1 AND 10)" ); expect(Cond.not(Cond.like("foo", "%test%")).toSQL(flavor)).toEqual( 'NOT (`foo` LIKE "%test%")' ); }); }); describe("Conditions.fromString", () => { it("should handle an empty array condition", () => { const condition = Cond.fromString("column", ""); expect(condition!.toSQL(flavor)).toEqual('`column` = ""'); }); it("should handle a single condition string", () => { const condition = Cond.fromString("column", ">=10"); expect(condition!.toSQL(flavor)).toEqual('`column` >= "10"'); }); it("should handle different operators", () => { expect(Cond.fromString("column", ">10")!.toSQL(flavor)).toEqual( '`column` > "10"' ); expect(Cond.fromString("column", "<=20")!.toSQL(flavor)).toEqual( '`column` <= "20"' ); expect(Cond.fromString("column", "~value")!.toSQL(flavor)).toMatch(/LIKE/); }); it("should default to equal condition", () => { const condition = Cond.fromString("column", "value"); expect(condition!.toSQL(flavor)).toEqual('`column` = "value"'); }); it("should use default space delimiter for ~ operator", () => { const condition = Cond.fromString("column", "~test"); const sql = condition!.toSQL(flavor); expect(sql).toContain('`column` LIKE "test%"'); expect(sql).toContain('`column` LIKE "% test%"'); expect(sql).toMatch(/OR/); }); it("should support multiple delimiters for ~ operator", () => { const condition = Cond.fromString("column", "~test", [" ", ","]); const sql = condition!.toSQL(flavor); expect(sql).toContain('`column` LIKE "test%"'); expect(sql).toContain('`column` LIKE "% test%"'); expect(sql).toContain('`column` LIKE "%,test%"'); expect(sql).toMatch(/OR/); }); it("should support custom delimiters for ~ operator", () => { const condition = Cond.fromString("column", "~test", ["-", "_", "."]); const sql = condition!.toSQL(flavor); expect(sql).toContain('`column` LIKE "test%"'); expect(sql).toContain('`column` LIKE "%-test%"'); expect(sql).toContain('`column` LIKE "%_test%"'); expect(sql).toContain('`column` LIKE "%.test%"'); expect(sql).toMatch(/OR/); }); it("should use default space delimiter for !~ operator", () => { const condition = Cond.fromString("column", "!~test"); const sql = condition!.toSQL(flavor); expect(sql).toContain('`column` NOT LIKE "test%"'); expect(sql).toContain('`column` NOT LIKE "% test%"'); expect(sql).toMatch(/AND/); }); it("should support multiple delimiters for !~ operator", () => { const condition = Cond.fromString("column", "!~test", [" ", ","]); const sql = condition!.toSQL(flavor); expect(sql).toContain('`column` NOT LIKE "test%"'); expect(sql).toContain('`column` NOT LIKE "% test%"'); expect(sql).toContain('`column` NOT LIKE "%,test%"'); expect(sql).toMatch(/AND/); }); it("should support custom delimiters for !~ operator", () => { const condition = Cond.fromString("column", "!~test", ["-", "_", "."]); const sql = condition!.toSQL(flavor); expect(sql).toContain('`column` NOT LIKE "test%"'); expect(sql).toContain('`column` NOT LIKE "%-test%"'); expect(sql).toContain('`column` NOT LIKE "%_test%"'); expect(sql).toContain('`column` NOT LIKE "%.test%"'); expect(sql).toMatch(/AND/); }); }); describe("Case-insensitive LIKE conditions", () => { const mysqlFlavor = Q.flavors.mysql; const postgresFlavor = Q.flavors.postgres; it("should generate ILIKE for PostgreSQL", () => { expect( Cond.like("name", "%john%", { caseInsensitive: true }).toSQL( postgresFlavor ) ).toBe('"name" ILIKE \'%john%\''); }); it("should generate LOWER() wrapper for MySQL", () => { expect( Cond.like("name", "%john%", { caseInsensitive: true }).toSQL(mysqlFlavor) ).toBe('LOWER(`name`) LIKE LOWER("%john%")'); }); it("should generate NOT ILIKE for PostgreSQL", () => { expect( Cond.notLike("name", "%john%", { caseInsensitive: true }).toSQL( postgresFlavor ) ).toBe('"name" NOT ILIKE \'%john%\''); }); it("should generate NOT LIKE with LOWER() for MySQL", () => { expect( Cond.notLike("name", "%john%", { caseInsensitive: true }).toSQL( mysqlFlavor ) ).toBe('LOWER(`name`) NOT LIKE LOWER("%john%")'); }); it("should work with ilike() shorthand for PostgreSQL", () => { expect(Cond.ilike("name", "%john%").toSQL(postgresFlavor)).toBe( '"name" ILIKE \'%john%\'' ); }); it("should work with ilike() shorthand for MySQL", () => { expect(Cond.ilike("name", "%john%").toSQL(mysqlFlavor)).toBe( 'LOWER(`name`) LIKE LOWER("%john%")' ); }); it("should work with notIlike() shorthand for PostgreSQL", () => { expect(Cond.notIlike("name", "%john%").toSQL(postgresFlavor)).toBe( '"name" NOT ILIKE \'%john%\'' ); }); it("should work with notIlike() shorthand for MySQL", () => { expect(Cond.notIlike("name", "%john%").toSQL(mysqlFlavor)).toBe( 'LOWER(`name`) NOT LIKE LOWER("%john%")' ); }); it("should generate case-sensitive LIKE when caseInsensitive is false", () => { expect( Cond.like("name", "%john%", { caseInsensitive: false }).toSQL( postgresFlavor ) ).toBe('"name" LIKE \'%john%\''); expect( Cond.like("name", "%john%", { caseInsensitive: false }).toSQL(mysqlFlavor) ).toBe('`name` LIKE "%john%"'); }); it("should generate case-sensitive LIKE by default", () => { expect(Cond.like("name", "%john%").toSQL(postgresFlavor)).toBe( '"name" LIKE \'%john%\'' ); expect(Cond.like("name", "%john%").toSQL(mysqlFlavor)).toBe( '`name` LIKE "%john%"' ); }); it("should serialize and deserialize caseInsensitive flag", () => { const original = Cond.like("name", "%test%", { caseInsensitive: true }); const serialized = original.serialize(); const deserialized = Condition.deserialize(serialized); expect(deserialized!.toSQL(postgresFlavor)).toBe( original.toSQL(postgresFlavor) ); expect(deserialized!.toSQL(mysqlFlavor)).toBe(original.toSQL(mysqlFlavor)); }); it("should serialize and deserialize ilike shorthand", () => { const original = Cond.ilike("name", "%test%"); const serialized = original.serialize(); const deserialized = Condition.deserialize(serialized); expect(deserialized!.toSQL(postgresFlavor)).toBe( original.toSQL(postgresFlavor) ); }); it("should serialize and deserialize notIlike shorthand", () => { const original = Cond.notIlike("name", "%test%"); const serialized = original.serialize(); const deserialized = Condition.deserialize(serialized); expect(deserialized!.toSQL(postgresFlavor)).toBe( original.toSQL(postgresFlavor) ); }); it("should handle caseInsensitive: false when deserializing old data without the flag", () => { // Simulate old serialized data without caseInsensitive field // First, get properly formatted key from a real condition const realCondition = Cond.like("name", "%test%"); const realJson = realCondition.toJSON(); // Create a copy without caseInsensitive to simulate old data const oldJson = { type: realJson.type, key: realJson.key, pattern: realJson.pattern, isLike: realJson.isLike, // no caseInsensitive field }; const deserialized = Condition.fromJSON(oldJson); // Should default to case-sensitive (caseInsensitive: false) expect(deserialized.toSQL(postgresFlavor)).toBe('"name" LIKE \'%test%\''); expect(deserialized.toSQL(mysqlFlavor)).toBe('`name` LIKE "%test%"'); }); }); describe("SQL Injection Prevention", () => { it("should escape quotes in LIKE patterns", () => { const maliciousPattern = "test'; DROP TABLE users; --"; const sql = Cond.like("column", maliciousPattern).toSQL(flavor); // The pattern should be properly escaped and contained within quotes expect(sql).toContain("LIKE"); // The SQL should not allow the injection to break out of the string expect(sql).toEqual('`column` LIKE "test\'; DROP TABLE users; --"'); }); it("should escape quotes in NOT LIKE patterns", () => { const maliciousPattern = 'test"; DELETE FROM users; --'; const sql = Cond.notLike("column", maliciousPattern).toSQL(flavor); // Double quotes should be doubled to prevent injection expect(sql).toContain('NOT LIKE "test""; DELETE FROM users; --"'); }); it("should escape quotes in LIKE patterns via fromString", () => { const maliciousSearch = "~test'; DROP TABLE users; --"; const condition = Cond.fromString("column", maliciousSearch); const sql = condition!.toSQL(flavor); // The injection attempt should be contained within the quoted string expect(sql).toContain('LIKE "test'); expect(sql).toContain('DROP TABLE users; --%"'); }); it("should escape quotes in IN values", () => { const sql = Cond.in("col", ["it's", 'say "hello"'])?.toSQL(flavor); // Single quotes should pass through, double quotes should be escaped expect(sql).toContain("it's"); expect(sql).toContain('say ""hello""'); }); it("should escape SQL injection attempts in IN values", () => { const sql = Cond.in("col", ["'; DROP TABLE x; --"])?.toSQL(flavor); // The malicious string should be properly quoted as a value expect(sql).toEqual('`col` IN ("\'; DROP TABLE x; --")'); }); it("should escape SQL injection attempts in NOT IN values", () => { const sql = Cond.notIn("col", ['"; DELETE FROM y; --']).toSQL(flavor); // Double quotes should be escaped expect(sql).toContain('""; DELETE FROM y; --"'); }); }); describe("Condition with FunctionExpression key (issue #14)", () => { it("Cond.in accepts FunctionExpression as key", () => { const sql = Cond.in(Fn.substring("fd.account", 1, 1), [ "0", "1", "2", "3", "4", ])?.toSQL(flavor); expect(sql).toEqual( 'SUBSTRING(`fd`.`account`,1,1) IN ("0", "1", "2", "3", "4")' ); }); it("Cond.notIn accepts FunctionExpression as key", () => { const sql = Cond.notIn(Fn.substring("fd.account", 1, 1), ["x"]).toSQL( flavor ); expect(sql).toEqual('SUBSTRING(`fd`.`account`,1,1) NOT IN ("x")'); }); it("Cond.like accepts FunctionExpression as key", () => { const sql = Cond.like(Fn.substring("fd.account", 1, 3), "00%").toSQL( flavor ); expect(sql).toEqual('SUBSTRING(`fd`.`account`,1,3) LIKE "00%"'); }); it("Cond.notLike accepts FunctionExpression as key", () => { const sql = Cond.notLike(Fn.substring("fd.account", 1, 3), "00%").toSQL( flavor ); expect(sql).toEqual('SUBSTRING(`fd`.`account`,1,3) NOT LIKE "00%"'); }); it("Cond.isNull / isNotNull accept FunctionExpression as key", () => { expect(Cond.isNull(Fn.max("price")).toSQL(flavor)).toEqual( "MAX(`price`) IS NULL" ); expect(Cond.isNotNull(Fn.max("price")).toSQL(flavor)).toEqual( "MAX(`price`) IS NOT NULL" ); }); it("Cond.columnEqual accepts FunctionExpression on either side", () => { const sql = Cond.columnEqual( Fn.substring("a.col", 1, 1), Fn.substring("b.col", 1, 1) ).toSQL(flavor); expect(sql).toEqual( "SUBSTRING(`a`.`col`,1,1) = SUBSTRING(`b`.`col`,1,1)" ); }); });