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:
1 parent
6f3ce6f7c8
commit
f39e2ce7cf
2 files changed
+218
No files matched your search
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user