From 4210997d2f4abe97280ee39c1d6d5fd3c3af9576 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Mon, 3 Aug 2026 23:51:20 +0100 Subject: [PATCH] fix LIKE to honour the backslash escape character LIKE ignored the escape character, so an escaped wildcard did not match its literal: 'a%b' LIKE 'a\%b'; -- was false, postgres true 'a_b' LIKE 'a\_b'; -- was false, postgres true buildLikeMatcher now parses the pattern in one pass, treating a backslash as an escape for the next character. The index prefix optimisation is skipped for patterns containing an escape and falls back to a seq scan. --- src/tests/operators.queries.spec.ts | 28 ++++++++++++++++++++++++++++ src/transforms/build-filter.ts | 3 ++- src/utils.ts | 23 +++++++++++++++++++---- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/tests/operators.queries.spec.ts b/src/tests/operators.queries.spec.ts index 08deee0d..5c25d7d1 100644 --- a/src/tests/operators.queries.spec.ts +++ b/src/tests/operators.queries.spec.ts @@ -420,6 +420,34 @@ describe('Operators', () => { ]); }); + it('executes like with an escaped percent sign', () => { + expect(many(`create table test(val text); + insert into test values ('a%b'), ('axb'), ('a_b'), (null); + select * from test where val like 'a\\%b'`)) + .toEqual([ + { val: 'a%b' } + ]); + }); + + it('executes like with an escaped underscore', () => { + expect(many(`create table test(val text); + insert into test values ('a_b'), ('axb'), ('a%b'), (null); + select * from test where val like 'a\\_b'`)) + .toEqual([ + { val: 'a_b' } + ]); + }); + + it('executes like with an escaped percent sign on an indexed column', () => { + expect(many(`create table test(val text); + create index on test(val); + insert into test values ('a%b'), ('axb'), ('a_b'), (null); + select * from test where val like 'a\\%b'`)) + .toEqual([ + { val: 'a%b' } + ]); + }); + it('executes not like', () => { expect(many(`create table test(val text); diff --git a/src/transforms/build-filter.ts b/src/transforms/build-filter.ts index 7b6bca5e..5cd7eb9f 100644 --- a/src/transforms/build-filter.ts +++ b/src/transforms/build-filter.ts @@ -131,7 +131,8 @@ function buildBinaryFilter(this: void, on: _ISelection, filter: ExprBinary): _IS if (nullIsh(str)) { return new FalseFilter(on); } - const got = /^([^%_]+)([%_]?.+)$/.exec(str); + const got = String(str).indexOf('\\') === -1 + && /^([^%_]+)([%_]?.+)$/.exec(str); if (got) { const start = got[1]; if (start.length === str) { diff --git a/src/utils.ts b/src/utils.ts index c4a1d524..2f06481d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -290,11 +290,26 @@ export function queryJson(a: Json, b: Json) { return true; } +function escapeRegexChar(char: string): string { + return char.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} + export function buildLikeMatcher(likeCondition: string, caseSensitive = true) { - // Escape regex characters from likeCondition - likeCondition = likeCondition.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); - let likeRegexString = likeCondition.replace(/\%/g, ".*").replace(/_/g, '.'); - likeRegexString = "^" + likeRegexString + "$"; + let likeRegexString = '^'; + for (let i = 0; i < likeCondition.length; i++) { + const char = likeCondition[i]; + if (char === '\\') { + const escaped = likeCondition[++i]; + likeRegexString += escaped === undefined ? '\\\\' : escapeRegexChar(escaped); + } else if (char === '%') { + likeRegexString += '.*'; + } else if (char === '_') { + likeRegexString += '.'; + } else { + likeRegexString += escapeRegexChar(char); + } + } + likeRegexString += '$'; const reg = new RegExp(likeRegexString, caseSensitive ? '' : 'i'); return (stringToMatch: string | number) => {