diff --git a/packages/lib/src/compiler.ts b/packages/lib/src/compiler.ts index 3662715..8f65b1d 100644 --- a/packages/lib/src/compiler.ts +++ b/packages/lib/src/compiler.ts @@ -1275,13 +1275,14 @@ export class SqlCompilerImpl implements SqlCompiler { case 'NOT IN': filter[field] = { $nin: Array.isArray(value) ? value : [value] }; break; - case 'LIKE': - // Convert SQL LIKE pattern to MongoDB regex - // % wildcard in SQL becomes .* in regex - // _ wildcard in SQL becomes . in regex - const pattern = String(value).replace(/%/g, '.*').replace(/_/g, '.'); + case 'LIKE': { + // Escape JS regex metacharacters in the literal part of the pattern + // before translating SQL wildcards: % -> .*, _ -> . + const escaped = String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = escaped.replace(/%/g, '.*').replace(/_/g, '.'); filter[field] = { $regex: new RegExp(`^${pattern}$`, 'i') }; break; + } case 'BETWEEN': if (Array.isArray(right) && right.length === 2) { filter[field] = { diff --git a/packages/lib/tests/unit/basic.test.ts b/packages/lib/tests/unit/basic.test.ts index 59eaa56..93c756a 100644 --- a/packages/lib/tests/unit/basic.test.ts +++ b/packages/lib/tests/unit/basic.test.ts @@ -382,11 +382,74 @@ describe('QueryLeaf', () => { }); }); + describe('LIKE operator regex escaping', () => { + const parser = new SqlParserImpl(); + const compiler = new SqlCompilerImpl(); + + function getLikeRegex(command: any, field: string): RegExp | undefined { + if (command.type === 'FIND' && command.filter) { + return command.filter[field]?.$regex; + } + if (command.type === 'AGGREGATE') { + const matchStage = command.pipeline.find((s: any) => '$match' in s); + return matchStage?.$match[field]?.$regex; + } + return undefined; + } + + test('escapes dots so they match literally', () => { + const sql = "SELECT * FROM users WHERE email LIKE 'user.name@example.com'"; + const cmds = compiler.compile(parser.parse(sql)); + const re = getLikeRegex(cmds[0], 'email'); + expect(re).toBeDefined(); + expect(re!.test('user.name@example.com')).toBe(true); + expect(re!.test('userXname@example.com')).toBe(false); + }); + + test('escapes parentheses so they match literally', () => { + const sql = "SELECT * FROM books WHERE title LIKE 'Books (Fiction)'"; + const cmds = compiler.compile(parser.parse(sql)); + const re = getLikeRegex(cmds[0], 'title'); + expect(re).toBeDefined(); + expect(re!.test('Books (Fiction)')).toBe(true); + expect(re!.test('Books Fiction')).toBe(false); + }); + + test('escapes $ so pattern is not an invalid mid-string anchor', () => { + const sql = "SELECT * FROM items WHERE label LIKE 'Price: $10'"; + const cmds = compiler.compile(parser.parse(sql)); + const re = getLikeRegex(cmds[0], 'label'); + expect(re).toBeDefined(); + expect(re!.test('Price: $10')).toBe(true); + }); + + test('preserves % wildcard (zero or more chars)', () => { + const sql = "SELECT * FROM users WHERE name LIKE 'Jo%'"; + const cmds = compiler.compile(parser.parse(sql)); + const re = getLikeRegex(cmds[0], 'name'); + expect(re).toBeDefined(); + expect(re!.test('Jo')).toBe(true); + expect(re!.test('John')).toBe(true); + expect(re!.test('Josephine')).toBe(true); + expect(re!.test('Al')).toBe(false); + }); + + test('preserves _ wildcard (exactly one char)', () => { + const sql = "SELECT * FROM users WHERE code LIKE 'A_C'"; + const cmds = compiler.compile(parser.parse(sql)); + const re = getLikeRegex(cmds[0], 'code'); + expect(re).toBeDefined(); + expect(re!.test('ABC')).toBe(true); + expect(re!.test('AC')).toBe(false); + expect(re!.test('ABBC')).toBe(false); + }); + }); + describe('QueryLeaf', () => { test('should execute a SQL query', async () => { const queryLeaf = new QueryLeaf(mockMongoClient, 'test'); const result = await queryLeaf.execute('SELECT * FROM users WHERE age > 18'); - + expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); expect(result[0]).toHaveProperty('name');