Skip to content

Commit eb04362

Browse files
committed
fix: compileLikeRegex mishandled SQL escape sequences \% and \_, canSkipPage LIKE prefix overflow at U+FFFF
Two LIKE bugs fixed: 1. compileLikeRegex escaped all regex metacharacters first, then replaced % and _. SQL escape sequences like \% (literal percent) got broken: the backslash was escaped to \\, then % was still treated as wildcard. Now walks char-by-char: \% → literal %, \_ → literal _, then regex-escapes remaining characters. 2. canSkipPage LIKE prefix computation: charCodeAt(last) + 1 overflows when last char is U+FFFF (0xFFFF + 1 = 0x10000 wraps to U+0000 via String.fromCharCode). Now guards with < 0xFFFF check — skips the prefix-successor test when overflow would occur.
1 parent c05a4f8 commit eb04362

File tree

2 files changed

+43
-6
lines changed

2 files changed

+43
-6
lines changed

src/decode.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,23 @@ describe("matchesFilter", () => {
530530
expect(matchesFilter("Alice", { column: "x", op: "not_like", value: "Ali%" })).toBe(false);
531531
expect(matchesFilter("alice", { column: "x", op: "not_like", value: "Ali%" })).toBe(true);
532532
});
533+
534+
it("LIKE with escaped percent (\\%) matches literal %", () => {
535+
expect(matchesFilter("100%", { column: "x", op: "like", value: "100\\%" })).toBe(true);
536+
expect(matchesFilter("100abc", { column: "x", op: "like", value: "100\\%" })).toBe(false);
537+
expect(matchesFilter("100%done", { column: "x", op: "like", value: "100\\%%"})).toBe(true);
538+
});
539+
540+
it("LIKE with escaped underscore (\\_) matches literal _", () => {
541+
expect(matchesFilter("a_b", { column: "x", op: "like", value: "a\\_b" })).toBe(true);
542+
expect(matchesFilter("axb", { column: "x", op: "like", value: "a\\_b" })).toBe(false);
543+
});
544+
545+
it("LIKE with regex metacharacters in pattern", () => {
546+
expect(matchesFilter("hello.world", { column: "x", op: "like", value: "hello.world" })).toBe(true);
547+
expect(matchesFilter("helloXworld", { column: "x", op: "like", value: "hello.world" })).toBe(false);
548+
expect(matchesFilter("(test)", { column: "x", op: "like", value: "(test)" })).toBe(true);
549+
});
533550
});
534551

535552
describe("bigIntReplacer", () => {

src/decode.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,14 @@ export function canSkipPage(page: PageInfo, filters: QueryDescriptor["filters"],
7676
if (typeof filter.value !== "string" || typeof min !== "string") break;
7777
const prefix = extractLikePrefix(filter.value);
7878
if (!prefix) break;
79-
// Page max < prefix or page min >= prefix + '\uffff' → no match possible
80-
const prefixEnd = prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1);
81-
if ((max as string) < prefix || (min as string) >= prefixEnd) return true;
79+
// Page max < prefix → no match possible
80+
if ((max as string) < prefix) { return true; }
81+
// Page min >= prefix successor → no match possible (guard U+FFFF overflow)
82+
const lastCode = prefix.charCodeAt(prefix.length - 1);
83+
if (lastCode < 0xffff) {
84+
const prefixEnd = prefix.slice(0, -1) + String.fromCharCode(lastCode + 1);
85+
if ((min as string) >= prefixEnd) return true;
86+
}
8287
break;
8388
}
8489
// not_like: skip uniform pages where the single value matches the pattern
@@ -582,9 +587,24 @@ const likeRegexCache = new Map<string, RegExp>();
582587
export function compileLikeRegex(pattern: string): RegExp {
583588
let cached = likeRegexCache.get(pattern);
584589
if (cached) return cached;
585-
// Escape regex metacharacters, then replace SQL wildcards
586-
const escaped = pattern.replace(/[.+?^${}()|[\]\\*]/g, "\\$&");
587-
const re = new RegExp("^" + escaped.replace(/%/g, ".*").replace(/_/g, ".") + "$", "s");
590+
// Walk character-by-character to handle SQL escape sequences (\%, \_)
591+
let reStr = "^";
592+
for (let i = 0; i < pattern.length; i++) {
593+
const ch = pattern[i];
594+
if (ch === "\\" && i + 1 < pattern.length) {
595+
// SQL escape: \% → literal %, \_ → literal _, \\ → literal \
596+
const next = pattern[++i];
597+
reStr += next.replace(/[.+?^${}()|[\]\\*]/g, "\\$&");
598+
} else if (ch === "%") {
599+
reStr += ".*";
600+
} else if (ch === "_") {
601+
reStr += ".";
602+
} else {
603+
reStr += ch.replace(/[.+?^${}()|[\]\\*]/g, "\\$&");
604+
}
605+
}
606+
reStr += "$";
607+
const re = new RegExp(reStr, "s");
588608
likeRegexCache.set(pattern, re);
589609
if (likeRegexCache.size > 1000) likeRegexCache.clear(); // prevent unbounded growth
590610
return re;

0 commit comments

Comments
 (0)