Skip to content

Commit bf5540e

Browse files
abnegateclaude
andcommitted
fix(adapter): use JSON_CONTAINS / json_each for array CONTAINS
The LIKE `%json_encode%` pattern produces false positives on array columns: searching `2` in a TINYINT array matches `[12, 200]`, searching `"apple"` matches `["pineapple"]`, etc. Route onArray() CONTAINS / CONTAINS_ANY / NOT_CONTAINS through exact-element operators instead: - MariaDB: JSON_OVERLAPS when supported, JSON_CONTAINS per element OR'd together as fallback. The LIKE branch no longer special-cases arrays. - SQLite: EXISTS (SELECT 1 FROM json_each(col) WHERE value IN (...)) via a new buildArrayContainsCondition routed from getSQLCondition before the LIKE handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 93a0575 commit bf5540e

2 files changed

Lines changed: 71 additions & 12 deletions

File tree

src/Database/Adapter/MariaDB.php

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,12 +1627,30 @@ protected function getSQLCondition(Query $query, array &$binds, ?string $forColl
16271627
case Query::TYPE_CONTAINS:
16281628
case Query::TYPE_CONTAINS_ANY:
16291629
case Query::TYPE_NOT_CONTAINS:
1630-
if ($this->getSupportForJSONOverlaps() && $query->onArray()) {
1631-
$binds[":{$placeholder}_0"] = json_encode($query->getValues());
1630+
if ($query->onArray()) {
16321631
$isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS;
1633-
return $isNot
1634-
? "NOT (JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0))"
1635-
: "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)";
1632+
1633+
if ($this->getSupportForJSONOverlaps()) {
1634+
$binds[":{$placeholder}_0"] = json_encode($query->getValues());
1635+
return $isNot
1636+
? "NOT (JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0))"
1637+
: "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)";
1638+
}
1639+
1640+
// JSON_CONTAINS per element OR'd together — exact
1641+
// element match without LIKE's substring false positives
1642+
// (`%2%` matching `[12, 200]`, `%"apple"%` matching
1643+
// `["pineapple"]`).
1644+
$conditions = [];
1645+
foreach ($query->getValues() as $key => $value) {
1646+
$binds[":{$placeholder}_{$key}"] = json_encode($value);
1647+
$conditions[] = "JSON_CONTAINS({$alias}.{$attribute}, :{$placeholder}_{$key})";
1648+
}
1649+
if (empty($conditions)) {
1650+
return '';
1651+
}
1652+
$expression = '(' . implode(' OR ', $conditions) . ')';
1653+
return $isNot ? "NOT {$expression}" : $expression;
16361654
}
16371655
// no break
16381656
default:
@@ -1649,8 +1667,7 @@ protected function getSQLCondition(Query $query, array &$binds, ?string $forColl
16491667
Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%',
16501668
Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value),
16511669
Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value),
1652-
Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY => ($query->onArray()) ? '%' . $this->escapeWildcards((string) (\json_encode($value) ?: '')) . '%' : '%' . $this->escapeWildcards($value) . '%',
1653-
Query::TYPE_NOT_CONTAINS => ($query->onArray()) ? '%' . $this->escapeWildcards((string) (\json_encode($value) ?: '')) . '%' : '%' . $this->escapeWildcards($value) . '%',
1670+
Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_NOT_CONTAINS => '%' . $this->escapeWildcards($value) . '%',
16541671
default => $value
16551672
};
16561673

src/Database/Adapter/SQLite.php

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3162,6 +3162,17 @@ protected function getSQLCondition(Query $query, array &$binds, ?string $forColl
31623162
];
31633163

31643164
if (\in_array($method, $likeMethods, true)) {
3165+
// Array CONTAINS via json_each — exact element match without
3166+
// LIKE substring false positives (`%2%` matching `[12, 200]`).
3167+
$arrayContainsMethods = [
3168+
Query::TYPE_CONTAINS,
3169+
Query::TYPE_CONTAINS_ANY,
3170+
Query::TYPE_NOT_CONTAINS,
3171+
];
3172+
if ($query->onArray() && \in_array($method, $arrayContainsMethods, true)) {
3173+
return $this->buildArrayContainsCondition($query, $binds);
3174+
}
3175+
31653176
return $this->getLikeCondition($query, $binds);
31663177
}
31673178

@@ -3218,6 +3229,41 @@ private function buildSearchLikeFallback(
32183229
return $method === Query::TYPE_SEARCH ? $sql : "NOT ({$sql})";
32193230
}
32203231

3232+
/**
3233+
* Array CONTAINS / CONTAINS_ANY / NOT_CONTAINS via json_each. Exact
3234+
* element match — avoids the LIKE substring false positives where
3235+
* `%2%` matches `[12, 200]` and `%"apple"%` matches `["pineapple"]`.
3236+
*
3237+
* @param array<string,mixed> $binds
3238+
*/
3239+
private function buildArrayContainsCondition(Query $query, array &$binds): string
3240+
{
3241+
$method = $query->getMethod();
3242+
$query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute()));
3243+
3244+
$attribute = $this->quote($this->filter($query->getAttribute()));
3245+
$alias = $this->quote(Query::DEFAULT_ALIAS);
3246+
$placeholder = ID::unique();
3247+
3248+
$values = $query->getValues();
3249+
if (empty($values)) {
3250+
return '';
3251+
}
3252+
3253+
$params = [];
3254+
foreach ($values as $key => $value) {
3255+
$param = ":{$placeholder}_{$key}";
3256+
$binds[$param] = $value;
3257+
$params[] = $param;
3258+
}
3259+
3260+
$expression = "EXISTS (SELECT 1 FROM json_each({$alias}.{$attribute}) WHERE value IN ("
3261+
. \implode(', ', $params)
3262+
. '))';
3263+
3264+
return $method === Query::TYPE_NOT_CONTAINS ? "NOT {$expression}" : $expression;
3265+
}
3266+
32213267
/**
32223268
* FTS5 vtable on `$collection` that covers `$attribute`. Multi-column
32233269
* indexes can't be addressed from a single attribute alone — the
@@ -3276,16 +3322,12 @@ protected function getLikeCondition(Query $query, array &$binds): string
32763322
Query::TYPE_NOT_CONTAINS,
32773323
], true);
32783324

3279-
$onArray = $query->onArray();
3280-
32813325
$conditions = [];
32823326
foreach ($query->getValues() as $key => $value) {
32833327
$bound = match ($method) {
32843328
Query::TYPE_STARTS_WITH, Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%',
32853329
Query::TYPE_ENDS_WITH, Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value),
3286-
Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_NOT_CONTAINS => $onArray
3287-
? '%' . $this->escapeWildcards((string) (\json_encode($value) ?: '')) . '%'
3288-
: '%' . $this->escapeWildcards($value) . '%',
3330+
Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_NOT_CONTAINS => '%' . $this->escapeWildcards($value) . '%',
32893331
default => $value,
32903332
};
32913333

0 commit comments

Comments
 (0)