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%').
This commit is contained in:
Zaid Humaimidi committed 2026-03-13 13:15:52 -07:00
1 parent 6f3ce6f7c8
commit f39e2ce7cf
2 files changed
+218

No files matched your search

+154
View File
@@ -617,6 +617,78 @@ export class SynthContext extends SynthContextBase<Clause, Clause> {
} }
} }
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) { for (const v of whitelist) {
const clause = this.getClause(v); const clause = this.getClause(v);
const nullables = [...clause.getNullables()].map((c) => this.getVar(c)); const nullables = [...clause.getNullables()].map((c) => this.getVar(c));
@@ -786,6 +858,88 @@ export function createSynthContext(): SynthContext {
return new 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 { export function likeImplies(pat1: string[], pat2: string[]): boolean {
let backtrack: [number, number] = null; let backtrack: [number, number] = null;
+64
View File
@@ -4,6 +4,10 @@ import initSqlJs from "sql.js/dist/sql-asm.js";
import { covers, minimize, unionDiff } from "../lib/common/expression/synth.ts"; import { covers, minimize, unionDiff } from "../lib/common/expression/synth.ts";
import Expression from "../lib/common/expression.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 STRING_VALUES = [null, "", "a", "ab", "ab10", "ab-10"];
const DECIMAL_VALUES = [null, 0, -10, 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",
);
});