From a29aaa77cc18750cb79ce5863fbb6ebc4f81f4f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 15:48:49 +0000 Subject: [PATCH] fix(lib): escape regex metacharacters in SQL LIKE patterns The LIKE-to-regex conversion only translated SQL wildcards (% and _) and passed every other character through to RegExp verbatim, so regex-significant characters in the SQL literal produced wrong matches: - LIKE 'user.name@example.com' matched 'userXname@example.com' (dot is any-char) - LIKE 'Books (Fiction)' matched 'Books Fiction' (parens became a capture group) - LIKE 'Price: \$10' compiled to /^Price: \$10\$/i, which matches nothing Escape JS regex metacharacters in the SQL literal before translating the two wildcards. Adds unit coverage for the three failure cases plus % and _ to lock in wildcard semantics against the escape step. --- packages/lib/src/compiler.ts | 11 ++--- packages/lib/tests/unit/basic.test.ts | 65 ++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 6 deletions(-) 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');