From f39e2ce7cf1dee116c8bcd94572b63db1503443f Mon Sep 17 00:00:00 2001 From: Zaid Humaimidi Date: Fri, 13 Mar 2026 13:15:52 -0700 Subject: [PATCH] feat: add LIKE-Compare DC set relationships Detect contradictions between LIKE patterns and comparison operators (=, <, >) on the same field. For prefix patterns like 'abc%', use range bounds to identify impossible combinations such as (string < 'abc') AND (string LIKE 'abc%'). --- lib/common/expression/synth.ts | 154 +++++++++++++++++++++++++++++++++ test/synth.ts | 64 ++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/lib/common/expression/synth.ts b/lib/common/expression/synth.ts index cf773e9..f0d0072 100644 --- a/lib/common/expression/synth.ts +++ b/lib/common/expression/synth.ts @@ -617,6 +617,78 @@ export class SynthContext extends SynthContextBase { } } + for (const [lhsKey, likeGroup] of groupBy(likes, (c) => c.lhs.toString())) { + const compareGroupAll = comparisons.filter( + (c) => c.lhs.toString() === lhsKey, + ); + + for (const like of likeGroup) { + if (like.contradiction) continue; + + const pattern = like.caseSensitive + ? like.pattern + : like.pattern.map((c) => c.toLowerCase()); + + const likeVar = this.getVar(like); + + for (const compare of compareGroupAll.filter((c) => c.op === "=")) { + if (typeof compare.rhs !== "string") continue; + + const value = like.caseSensitive + ? compare.rhs + : compare.rhs.toLowerCase(); + + const matches = likeMatches(pattern, value, true); + const eqVar = this.getVar(compare); + + if (matches) { + dcSet.push([(eqVar << 2) ^ 3, (likeVar << 2) ^ 1]); + // Don't add eq=true AND like=null as DC; combined with eq=true AND + // like=false, espresso would treat LIKE as don't-care when eq=true + } else { + dcSet.push([(eqVar << 2) ^ 3, (likeVar << 2) ^ 3]); + } + } + + // Prefix patterns like 'abc%' match strings in range [prefix, upperBound) + const prefix = getPureLikePrefix(pattern); + if (prefix) { + const upperBound = getLikePrefixUpperBound(prefix); + + // string < prefix means it can't match the pattern + for (const compare of compareGroupAll.filter((c) => c.op === "<")) { + if (typeof compare.rhs !== "string") continue; + + const value = like.caseSensitive + ? compare.rhs + : compare.rhs.toLowerCase(); + const ltVar = this.getVar(compare); + + if (value <= prefix) { + dcSet.push([(ltVar << 2) ^ 3, (likeVar << 2) ^ 3]); + } + } + + // string > upperBound means it can't match the pattern + // (string > prefix could still match, e.g., 'abcd' > 'abc' matches 'abc%') + if (upperBound) { + for (const compare of compareGroupAll.filter((c) => c.op === ">")) { + if (typeof compare.rhs !== "string") continue; + + const value = like.caseSensitive + ? compare.rhs + : compare.rhs.toLowerCase(); + const gtVar = this.getVar(compare); + + if (value >= upperBound) { + dcSet.push([(gtVar << 2) ^ 3, (likeVar << 2) ^ 3]); + } + } + } + } + } + } + for (const v of whitelist) { const clause = this.getClause(v); const nullables = [...clause.getNullables()].map((c) => this.getVar(c)); @@ -786,6 +858,88 @@ export function createSynthContext(): SynthContext { return new SynthContext(); } +function likeMatches( + pattern: string[], + value: string, + caseSensitive: boolean, +): boolean { + if (!caseSensitive) { + value = value.toLowerCase(); + pattern = pattern.map((c) => + c === "\\%" || c === "\\_" ? c : c.toLowerCase(), + ); + } + + let pi = 0; + let vi = 0; + let backtrackPi = -1; + let backtrackVi = -1; + + while (vi < value.length) { + if (pi < pattern.length && pattern[pi] === "\\%") { + backtrackPi = pi; + backtrackVi = vi; + pi++; + } else if ( + pi < pattern.length && + (pattern[pi] === "\\_" || pattern[pi] === value[vi]) + ) { + pi++; + vi++; + } else if (backtrackPi >= 0) { + pi = backtrackPi + 1; + backtrackVi++; + vi = backtrackVi; + } else { + return false; + } + } + + while (pi < pattern.length && pattern[pi] === "\\%") pi++; + + return pi === pattern.length; +} + +function getLikePrefixUpperBound(prefix: string): string | null { + if (!prefix) return null; + + for (let i = prefix.length - 1; i >= 0; i--) { + const charCode = prefix.charCodeAt(i); + // 0x10ffff is max Unicode code point; sufficient for practical strings + if (charCode < 0x10ffff) { + return prefix.slice(0, i) + String.fromCodePoint(charCode + 1); + } + } + return null; +} + +function getPureLikePrefix(pattern: string[]): string | null { + if (pattern.length === 0) return null; + + let hasTrailingPercent = false; + let prefix = ""; + + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === "\\%") { + for (let j = i; j < pattern.length; j++) { + if (pattern[j] !== "\\%") return null; + } + hasTrailingPercent = true; + break; + } else if (c === "\\_") { + return null; + } else { + prefix += c; + } + } + + if (!hasTrailingPercent) return null; + if (!prefix) return null; + + return prefix; +} + export function likeImplies(pat1: string[], pat2: string[]): boolean { let backtrack: [number, number] = null; diff --git a/test/synth.ts b/test/synth.ts index 98d909f..f46017d 100644 --- a/test/synth.ts +++ b/test/synth.ts @@ -4,6 +4,10 @@ import initSqlJs from "sql.js/dist/sql-asm.js"; import { covers, minimize, unionDiff } from "../lib/common/expression/synth.ts"; import Expression from "../lib/common/expression.ts"; +function isFalse(expr: Expression): boolean { + return expr instanceof Expression.Literal && expr.value === false; +} + const STRING_VALUES = [null, "", "a", "ab", "ab10", "ab-10"]; const DECIMAL_VALUES = [null, 0, -10, 10]; @@ -169,3 +173,63 @@ void test("covers", async () => { ); } }); + +void test("LIKE-Compare DC set relationships", () => { + const likeExpr = Expression.parse("string LIKE 'a%'"); + + const eqExpr = Expression.parse("string = 'a'"); + const conjExpr: Expression = new Expression.Binary("AND", eqExpr, likeExpr); + assert.strictEqual( + isFalse(minimize(conjExpr, true)), + false, + "(string = 'a') AND (string LIKE 'a%') should NOT minimize to false", + ); + + const nonMatchingExpr = Expression.parse("string = 'b'"); + const conjNonMatch: Expression = new Expression.Binary( + "AND", + nonMatchingExpr, + likeExpr, + ); + assert.strictEqual( + isFalse(minimize(conjNonMatch, true)), + true, + "(string = 'b') AND (string LIKE 'a%') should minimize to false", + ); +}); + +void test("LIKE-Compare DC set with range operators", () => { + const likeExpr = Expression.parse("string LIKE 'abc%'"); + + const ltExpr = Expression.parse("string < 'abc'"); + const ltConj: Expression = new Expression.Binary("AND", ltExpr, likeExpr); + assert.strictEqual( + isFalse(minimize(ltConj, true)), + true, + "(string < 'abc') AND (string LIKE 'abc%') should be false", + ); + + const ltExpr2 = Expression.parse("string < 'abd'"); + const ltConj2: Expression = new Expression.Binary("AND", ltExpr2, likeExpr); + assert.strictEqual( + isFalse(minimize(ltConj2, true)), + false, + "(string < 'abd') AND (string LIKE 'abc%') should NOT be false", + ); + + const gtExpr = Expression.parse("string > 'abd'"); + const gtConj: Expression = new Expression.Binary("AND", gtExpr, likeExpr); + assert.strictEqual( + isFalse(minimize(gtConj, true)), + true, + "(string > 'abd') AND (string LIKE 'abc%') should be false", + ); + + const gtExpr2 = Expression.parse("string > 'abc'"); + const gtConj2: Expression = new Expression.Binary("AND", gtExpr2, likeExpr); + assert.strictEqual( + isFalse(minimize(gtConj2, true)), + false, + "(string > 'abc') AND (string LIKE 'abc%') should NOT be false", + ); +});