diff --git a/docs/en/appendices/6-0-migration-guide.md b/docs/en/appendices/6-0-migration-guide.md index 482b2039a8..0c75159d9e 100644 --- a/docs/en/appendices/6-0-migration-guide.md +++ b/docs/en/appendices/6-0-migration-guide.md @@ -138,6 +138,40 @@ Some properties have also been renamed to better reflect their purpose. These ar ### Database +- Operators in condition-array keys, such as `['views >' => 1000]` or + `['id IN' => $ids]`, are no longer supported and raise an + `InvalidArgumentException`. Use the predicate functions in + `Cake\Database\Expression` instead. Plain equality conditions, nested boolean + arrays, and the fluent `QueryExpression` API remain supported. + + ```php + use function Cake\Database\Expression\{equals, greaterThan, in, notEquals}; + + // Before (CakePHP 5.x) + $query->where([ + 'views >' => 1000, + 'id IN' => $ids, + 'parent_id IS' => $parentId, + 'deleted IS NOT' => null, + ]); + + // After (CakePHP 6.x) + $query->where([ + 'views' => greaterThan(1000), + 'id' => in($ids), + 'parent_id' => equals($parentId), + 'deleted' => notEquals(null), + ]); + ``` + + `equals()` and `notEquals()` generate `IS NULL` and `IS NOT NULL` when their + value is `null`. Use `isNull()` and `isNotNull()` for explicit null checks. + Predicates infer binding types from the query; pass `type:` to override them. + The [upgrade tool](6-0-upgrade-guide#upgrade-tool-use) includes this conversion + in its `cakephp60` rules. See + [Predicate Functions](../orm/query-builder#predicate-functions) for the full + function reference and examples. + - Binary columns now use `binary` for fixed-length data and `varbinary` for variable-length data. In migration `addColumn()` calls, remove `'fixed' => true` from fixed-length `binary` columns, and change variable-length @@ -262,6 +296,13 @@ still uses other Diactoros classes or functions, declare ## New Features +### Database + +- Namespaced predicate functions build field-keyed conditions with bound values. + They support comparisons, ranges, membership, null checks, and custom SQL + operators. The returned immutable predicates can be reused across queries. + See [Predicate Functions](../orm/query-builder#predicate-functions). + ### Http - The HTTP client supports live response bodies through the Stream adapter with diff --git a/docs/en/development/dependency-injection.md b/docs/en/development/dependency-injection.md index cbeb9804f3..5db636a523 100644 --- a/docs/en/development/dependency-injection.md +++ b/docs/en/development/dependency-injection.md @@ -487,6 +487,8 @@ the source using the `RequestToDtoSource` enum: use Cake\Controller\Attribute\RequestToDto; use Cake\Controller\Attribute\Enum\RequestToDtoSource; +use function Cake\Database\Expression\like; + class ArticlesController extends AppController { // Use query string parameters @@ -494,7 +496,7 @@ class ArticlesController extends AppController #[RequestToDto(source: RequestToDtoSource::Query)] SearchCriteriaDto $criteria ): void { $articles = $this->Articles->find() - ->where(['title LIKE' => "%{$criteria->query}%"]) + ->where(['title' => like("%{$criteria->query}%")]) ->limit($criteria->limit); } diff --git a/docs/en/orm/behaviors/tree.md b/docs/en/orm/behaviors/tree.md index 094b8d3386..94bbb970ec 100644 --- a/docs/en/orm/behaviors/tree.md +++ b/docs/en/orm/behaviors/tree.md @@ -98,9 +98,11 @@ foreach ($descendants as $category) { If you need to pass conditions you do so as per normal: ```php +use function Cake\Database\Expression\like; + $descendants = $categories ->find('children', for: 1) - ->where(['name LIKE' => '%Foo%']) + ->where(['name' => like('%Foo%')]) ->all(); foreach ($descendants as $category) { diff --git a/docs/en/orm/database-basics.md b/docs/en/orm/database-basics.md index be81151ece..50dca80ff2 100644 --- a/docs/en/orm/database-basics.md +++ b/docs/en/orm/database-basics.md @@ -73,10 +73,12 @@ $results = $connection Instead of writing the SQL manually, you can use the query builder: ```php +use function Cake\Database\Expression\greaterThan; + // Prior to 4.5 use $connection->query() instead. $results = $connection ->selectQuery('*', 'articles') - ->where(['created >' => new DateTime('1 day ago')], ['created' => 'datetime']) + ->where(['created' => greaterThan(new DateTime('1 day ago'))], ['created' => 'datetime']) ->orderBy(['title' => 'DESC']) ->execute() ->fetchAll('assoc'); diff --git a/docs/en/orm/query-builder.md b/docs/en/orm/query-builder.md index bfefcb020d..dcac671325 100644 --- a/docs/en/orm/query-builder.md +++ b/docs/en/orm/query-builder.md @@ -14,6 +14,11 @@ queries using unions and subqueries with ease. Underneath the covers, the query builder uses PDO prepared statements which protect against SQL injection attacks. +For comparisons in condition arrays, import the predicate functions from +`Cake\Database\Expression` with `use function`. The array key supplies the field, +and the predicate supplies the operator and values. See +[Predicate Functions](#predicate-functions) for the available functions. + ## The SelectQuery Object The easiest way to create a `SelectQuery` object is to use `find()` from a @@ -68,10 +73,12 @@ $query->orderBy(['title' => 'DESC']); // Still same object, no SQL executed You can of course chain the methods you call on SelectQuery objects: ```php +use function Cake\Database\Expression\notEquals; + $query = $articles ->find() ->select(['id', 'name']) - ->where(['id !=' => 1]) + ->where(['id' => notEquals(1)]) ->orderBy(['created' => 'DESC']); foreach ($query->all() as $article) { @@ -95,9 +102,11 @@ You can execute a query directly without having to use `foreach` on it. The easiest way is to either call the `all()` or `toList()` methods: ```php +use function Cake\Database\Expression\greaterThan; + $resultsIteratorObject = $articles ->find() - ->where(['id >' => 1]) + ->where(['id' => greaterThan(1)]) ->all(); foreach ($resultsIteratorObject as $article) { @@ -106,7 +115,7 @@ foreach ($resultsIteratorObject as $article) { $resultsArray = $articles ->find() - ->where(['id >' => 1]) + ->where(['id' => greaterThan(1)]) ->all() ->toList(); @@ -171,13 +180,15 @@ improve your skills in efficiently traversing the results. The ResultSet (return by calling the `SelectQuery`'s `all()` method) implements the collection interface: ```php +use function Cake\Database\Expression\greaterThan; + // Use the combine() method from the collections library // This is equivalent to find('list') $keyValueList = $articles->find()->all()->combine('id', 'title'); // An advanced example $results = $articles->find() - ->where(['id >' => 1]) + ->where(['id' => greaterThan(1)]) ->orderBy(['title' => 'DESC']) ->all() ->map(function ($row) { @@ -572,13 +583,15 @@ When using aggregate functions like `count` and `sum` you may want to use `group by` and `having` clauses: ```php +use function Cake\Database\Expression\greaterThan; + $query = $articles->find(); $query->select([ 'count' => $query->func()->count('view_count'), 'published_date' => 'DATE(created)', ]) ->groupBy('published_date') -->having(['count >' => 3]); +->having(['count' => greaterThan(3)]); ``` ### Case Statements @@ -622,13 +635,15 @@ cities into SMALL, MEDIUM, or LARGE based on population size, we could do the following: ```php +use function Cake\Database\Expression\{greaterThanOrEquals, lessThan}; + $query = $cities->find(); $sizing = $query->expr()->case() - ->when(['population <' => 100000]) + ->when(['population' => lessThan(100000)]) ->then('SMALL') ->when($query->expr()->between('population', 100000, 999000)) ->then('MEDIUM') - ->when(['population >=' => 999001]) + ->when(['population' => greaterThanOrEquals(999001)]) ->then('LARGE'); $query = $query->select(['size' => $sizing]); # SELECT CASE @@ -1051,6 +1066,133 @@ The above would generate SQL like SELECT * FROM articles WHERE author_id = 3 AND (view_count = 2 OR view_count = 3) ``` +### Predicate Functions + +Use the functions in `Cake\Database\Expression` to build comparisons in condition +arrays. Plain values still create equality conditions: + +```php +use function Cake\Database\Expression\{between, greaterThan, lessThan, like}; + +$query = $articles->find()->where([ + 'published' => true, + 'id' => between(1, 100), + 'view_count' => greaterThan(1000), + 'price' => lessThan(50), + 'title' => like('%cake%'), +]); +``` + +This generates conditions equivalent to: + +```sql +WHERE published = true + AND id BETWEEN 1 AND 100 + AND view_count > 1000 + AND price < 50 + AND title LIKE '%cake%' +``` + +The values are bound as parameters. Condition keys must contain only the field +or a trusted SQL expression. CakePHP 6 no longer accepts operators in array keys +such as `'view_count >'` or `'title LIKE'`; these raise an +`InvalidArgumentException`. + +The following predicate functions are available: + +| Function | Comparison | +| --- | --- | +| `equals($value)` | `=`, or `IS NULL` when the value is `null` | +| `notEquals($value)` | `!=`, or `IS NOT NULL` when the value is `null` | +| `greaterThan($value)` | `>` | +| `greaterThanOrEquals($value)` | `>=` | +| `lessThan($value)` | `<` | +| `lessThanOrEquals($value)` | `<=` | +| `like($value)` | `LIKE` | +| `notLike($value)` | `NOT LIKE` | +| `in($values)` | `IN`, accepting a list of values or a subquery | +| `notIn($values)` | `NOT IN`, accepting a list of values or a subquery | +| `inOrNull($values)` | `IN (...) OR field IS NULL` | +| `notInOrNull($values)` | `NOT IN (...) OR field IS NULL` | +| `between($from, $to)` | Inclusive `BETWEEN` | +| `notBetween($from, $to)` | `NOT BETWEEN` | +| `isNull()` | `IS NULL` | +| `isNotNull()` | `IS NOT NULL` | +| `isDistinctFrom($value)` | Null-safe inequality | +| `isNotDistinctFrom($value)` | Null-safe equality | +| `comparison($operator, $value)` | A custom, trusted SQL operator | + +Predicates work wherever the query builder accepts condition arrays, including +`where()`, `having()`, join conditions, and `case()->when()`. They must be assigned +to field keys, rather than passed as numeric array entries or directly as the +value of `AND`, `OR`, or `NOT`. + +Use nested arrays to combine predicates with boolean operators. If you need more +than one condition on the same field, put each condition in its own array to +avoid PHP overwriting duplicate keys: + +```php +use function Cake\Database\Expression\{greaterThan, isNull, lessThan, like}; + +$query = $articles->find()->where([ + 'OR' => [ + ['title' => like('%cake%')], + ['published_at' => isNull()], + ], + 'AND' => [ + ['view_count' => greaterThan(100)], + ['view_count' => lessThan(1000)], + ], + 'NOT' => ['archived' => true], +]); +``` + +Predicates infer binding types from the surrounding query's type map, including +column types from the ORM schema. Every function except `isNull()` and +`isNotNull()` accepts an optional `type` argument to override the inferred type: + +```php +use function Cake\Database\Expression\{between, equals, in, notEquals}; + +$query = $articles->find()->where([ + 'created' => between($start, $end, type: 'datetime'), + 'author_id' => in([1, 2, 3], type: 'integer'), + 'id' => equals($ids, type: 'integer[]'), + 'category_id' => notEquals($excludedIds, type: 'integer[]'), +]); +``` + +With array types such as `integer[]`, `equals()` produces `IN` and +`notEquals()` produces `NOT IN`. For `in()` and `notIn()`, specify the element +type, such as `integer`. + +Each function returns an immutable `Cake\Database\Expression\Predicate`. Its +field and binding type are resolved independently each time it is used, so you +can reuse a predicate across fields and queries: + +```php +use function Cake\Database\Expression\greaterThan; + +$recent = greaterThan($cutoff, type: 'datetime'); +$articlesQuery = $articles->find()->where(['created' => $recent]); +$commentsQuery = $comments->find()->where(['created' => $recent]); +``` + +For a database-specific operator without a dedicated function, use +`comparison()`: + +```php +use function Cake\Database\Expression\comparison; + +// PostgreSQL case-insensitive pattern matching. +$query = $articles->find()->where(['title' => comparison('ILIKE', '%cake%')]); +``` + +Only values are parameterized. Field expressions and the operator passed to +`comparison()` are SQL structure and must never come from untrusted input. + +### Building Conditions with Expressions + If you'd prefer to avoid deeply nested arrays, you can use the callback form of `where()` to build your queries. The callback accepts a QueryExpression which allows you to use the expression builder interface to build more complex conditions without arrays. @@ -1394,12 +1536,14 @@ conditions: - `exists()` Create a condition using `EXISTS`: ```php + use function Cake\Database\Expression\greaterThan; + $subquery = $cities->find() ->select(['id']) ->where(function (QueryExpression $exp, SelectQuery $q) { return $exp->equalFields('countries.id', 'cities.country_id'); }) - ->andWhere(['population >' => 5000000]); + ->andWhere(['population' => greaterThan(5000000)]); $query = $countries->find() ->where(function (QueryExpression $exp, SelectQuery $q) use ($subquery) { @@ -1411,12 +1555,14 @@ conditions: - `notExists()` Create a negated condition using `EXISTS`: ```php + use function Cake\Database\Expression\greaterThan; + $subquery = $cities->find() ->select(['id']) ->where(function (QueryExpression $exp, SelectQuery $q) { return $exp->equalFields('countries.id', 'cities.country_id'); }) - ->andWhere(['population >' => 5000000]); + ->andWhere(['population' => greaterThan(5000000)]); $query = $countries->find() ->where(function (QueryExpression $exp, SelectQuery $q) use ($subquery) { @@ -1508,11 +1654,13 @@ $query->select([ You can use `identifier()` in comparisons to aggregations too: ```php +use function Cake\Database\Expression\greaterThanOrEquals; + $query = $this->Orders->find(); $query->select(['Customers.customer_name', 'total_orders' => $query->func()->count('Orders.order_id')]) ->contain('Customers') ->groupBy(['Customers.customer_name']) - ->having(['total_orders >=' => $query->identifier('Customers.minimum_order_count')]); + ->having(['total_orders' => greaterThanOrEquals($query->identifier('Customers.minimum_order_count'))]); ``` > [!WARNING] @@ -1547,10 +1695,6 @@ the column data type: ```php $query = $articles->find() ->where(['id' => $ids], ['id' => 'integer[]']); - -// Or include IN to automatically cast to an array. -$query = $articles->find() - ->where(['id IN' => $ids]); ``` The above will automatically create `id IN (...)` instead of `id = ?`. This @@ -1567,31 +1711,48 @@ $query = $articles->find() ->where(['post_date' => $dates], ['post_date' => 'date[]']); ``` +When you already have a list of values or a subquery, use the `in()` or +`notIn()` predicates directly: + +```php +use function Cake\Database\Expression\in; + +$query = $articles->find() + ->where(['id' => in($ids)]); +``` + ### Automatic IS NULL Creation -When a condition value is expected to be `null` or any other value, you can -use the `IS` operator to automatically create the correct expression: +When a condition value can be `null` or a non-null value, use `equals()` to +automatically create the correct expression: ```php +use function Cake\Database\Expression\equals; + $query = $categories->find() - ->where(['parent_id IS' => $parentId]); + ->where(['parent_id' => equals($parentId)]); ``` -The above will generate`parent_id = :c1` or `parent_id IS NULL` depending on -the type of `$parentId` +The above will generate `parent_id = :c1` or `parent_id IS NULL` depending on +whether `$parentId` is `null`. Use `isNull()` for an unconditional null check. +A plain condition such as `['parent_id' => null]` raises an +`InvalidArgumentException`; a null-aware predicate is required. ### Automatic IS NOT NULL Creation -When a condition value is expected not to be `null` or any other value, you -can use the `IS NOT` operator to automatically create the correct expression: +Use `notEquals()` to compare against a value that can be `null`: ```php +use function Cake\Database\Expression\notEquals; + $query = $categories->find() - ->where(['parent_id IS NOT' => $parentId]); + ->where(['parent_id' => notEquals($parentId)]); ``` -The above will generate`parent_id != :c1` or `parent_id IS NOT NULL` -depending on the type of `$parentId` +The above will generate `parent_id != :c1` or `parent_id IS NOT NULL` +depending on whether `$parentId` is `null`. Use `isNotNull()` for an +unconditional non-null check. Ordering and pattern predicates such as +`greaterThan()` and `like()` reject `null` values. ### Raw Expressions @@ -1668,11 +1829,13 @@ Tuple comparison involves comparing two rows of data (tuples) element by element typically using comparison operators like `<, >, =`: ```php +use function Cake\Database\Expression\{lessThan, lessThanOrEquals}; + $products->find() ->where([ 'OR' => [ - ['unit_price <' => 20], - ['unit_price' => 20, 'tax_percentage <=' => 5], + ['unit_price' => lessThan(20)], + ['unit_price' => 20, 'tax_percentage' => lessThanOrEquals(5)], ] ]); @@ -1949,13 +2112,15 @@ As seen above, when adding joins the alias can be the outer array key. Join conditions can also be expressed as an array of conditions: ```php +use function Cake\Database\Expression\greaterThan; + $query = $articles->find() ->join([ 'c' => [ 'table' => 'comments', 'type' => 'LEFT', 'conditions' => [ - 'c.created >' => new DateTime('-5 days'), + 'c.created' => greaterThan(new DateTime('-5 days')), 'c.moderated' => true, 'c.article_id = articles.id', ] @@ -2276,14 +2441,16 @@ Subqueries enable you to compose queries together and build conditions and results based on the results of other queries: ```php +use function Cake\Database\Expression\{in, like}; + $matchingComment = $articles->getAssociation('Comments')->find() ->select(['article_id']) ->distinct() - ->where(['comment LIKE' => '%CakePHP%']); + ->where(['comment' => like('%CakePHP%')]); // Use a subquery to create conditions $query = $articles->find() - ->where(['id IN' => $matchingComment]); + ->where(['id' => in($matchingComment)]); // Join the results of a subquery into another query. // Giving the subquery an alias provides a way to reference @@ -2304,15 +2471,17 @@ referencing results in the outer query more complex. As of 4.2.0 you can use generate aliases: ```php +use function Cake\Database\Expression\{in, like}; + $comments = $articles->getAssociation('Comments')->getTarget(); $matchingComment = $comments->subquery() ->select(['article_id']) ->distinct() - ->where(['comment LIKE' => '%CakePHP%']); + ->where(['comment' => like('%CakePHP%')]); $query = $articles->find() - ->where(['id IN' => $matchingComment]); + ->where(['id' => in($matchingComment)]); ``` ### Adding Locking Statements diff --git a/docs/en/orm/retrieving-data-and-resultsets.md b/docs/en/orm/retrieving-data-and-resultsets.md index fd6e9dd983..1c620753d6 100644 --- a/docs/en/orm/retrieving-data-and-resultsets.md +++ b/docs/en/orm/retrieving-data-and-resultsets.md @@ -127,9 +127,11 @@ $data = $query->toArray(); > limits, or include associations using the fluent interface. ```php +use function Cake\Database\Expression\greaterThan; + // In a controller or table method. $query = $articles->find('all') - ->where(['Articles.created >' => new DateTime('-10 days')]) + ->where(['Articles.created' => greaterThan(new DateTime('-10 days'))]) ->contain(['Comments', 'Authors']) ->limit(10); ``` @@ -137,9 +139,11 @@ $query = $articles->find('all') You can also provide many commonly used options to `find()`: ```php +use function Cake\Database\Expression\greaterThan; + // In a controller or table method. $query = $articles->find('all', - conditions: ['Articles.created >' => new DateTime('-10 days')], + conditions: ['Articles.created' => greaterThan(new DateTime('-10 days'))], contain: ['Authors', 'Comments'], limit: 10, ); @@ -149,8 +153,10 @@ If your finder options are in an array, you can use the [splat operator](https:/ to pass them into `find()`: ```php +use function Cake\Database\Expression\greaterThan; + $options = [ - 'conditions' => ['Articles.created >' => new DateTime('-10 days')], + 'conditions' => ['Articles.created' => greaterThan(new DateTime('-10 days'))], 'contain' => ['Authors', 'Comments'], 'limit' => 10, ] @@ -245,8 +251,10 @@ Once you have created a query object, you can use the `count()` method to get a result count of that query: ```php +use function Cake\Database\Expression\like; + // In a controller or table method. -$query = $articles->find('all', conditions: ['Articles.title LIKE' => '%Ovens%']); +$query = $articles->find('all', conditions: ['Articles.title' => like('%Ovens%')]); $number = $query->count(); ``` @@ -766,9 +774,11 @@ You can apply this strategy to HasMany associations as well. For example if published articles using the following: ```php +use function Cake\Database\Expression\greaterThanOrEquals; + $query = $authors->find(); $query->matching('Articles', function ($q) { - return $q->where(['Articles.created >=' => new DateTime('-10 days')]); + return $q->where(['Articles.created' => greaterThanOrEquals(new DateTime('-10 days'))]); }); ``` @@ -881,10 +891,12 @@ for example, find all the authors with no published articles in the last 10 days: ```php +use function Cake\Database\Expression\greaterThanOrEquals; + $query = $authorsTable ->find() ->notMatching('Articles', function ($q) { - return $q->where(['Articles.created >=' => new \DateTime('-10 days')]); + return $q->where(['Articles.created' => greaterThanOrEquals(new \DateTime('-10 days'))]); }); ``` @@ -1274,9 +1286,11 @@ $reducer = function ($occurrences, $word, $mapReduce) { Finally, we put everything together: ```php +use function Cake\Database\Expression\greaterThanOrEquals; + $wordCount = $articles->unhydratedFind() ->where(['published' => true]) - ->andWhere(['published_date >=' => new DateTime('2014-01-01')]) + ->andWhere(['published_date' => greaterThanOrEquals(new DateTime('2014-01-01'))]) ->mapReduce($mapper, $reducer) ->all() ->toArray(); @@ -1373,18 +1387,22 @@ This allows you to keep chaining additional methods and filters to the query even after adding a map-reduce routine: ```php +use function Cake\Database\Expression\greaterThanOrEquals; + $query = $articles->find() ->where(['published' => true]) ->mapReduce($mapper, $reducer); // At a later point in your app: -$query->where(['created >=' => new DateTime('1 day ago')]); +$query->where(['created' => greaterThanOrEquals(new DateTime('1 day ago'))]); ``` This is particularly useful for building custom finder methods as described in the [Custom Find Methods](#custom-find-methods) section: ```php +use function Cake\Database\Expression\greaterThanOrEquals; + public function findPublished(SelectQuery $query): SelectQuery { return $query->where(['published' => true]); @@ -1392,7 +1410,7 @@ public function findPublished(SelectQuery $query): SelectQuery public function findRecent(SelectQuery $query): SelectQuery { - return $query->where(['created >=' => new DateTime('1 day ago')]); + return $query->where(['created' => greaterThanOrEquals(new DateTime('1 day ago'))]); } public function findCommonWords(SelectQuery $query): SelectQuery diff --git a/docs/en/orm/saving-data.md b/docs/en/orm/saving-data.md index 5a5601aa91..08d923a6cd 100644 --- a/docs/en/orm/saving-data.md +++ b/docs/en/orm/saving-data.md @@ -111,10 +111,12 @@ $articlesTable->Tags->link($article, [$tag1, $tag2]); Unlinking many to many records is done via the `unlink()` method: ```php +use function Cake\Database\Expression\in; + $tags = $articlesTable ->Tags ->find() - ->where(['name IN' => ['cakephp', 'awesome']]) + ->where(['name' => in(['cakephp', 'awesome'])]) ->toList(); $articlesTable->Tags->unlink($article, $tags); @@ -629,11 +631,13 @@ delete for those not in the list: // In a controller. use Cake\Collection\Collection; +use function Cake\Database\Expression\notIn; + $comments = $this->fetchTable('Comments'); $present = (new Collection($entity->comments))->extract('id')->filter()->toList(); $comments->deleteAll([ 'article_id' => $article->id, - 'id NOT IN' => $present, + 'id' => notIn($present), ]); ``` diff --git a/docs/en/tutorials-and-examples/cms/tags-and-users.md b/docs/en/tutorials-and-examples/cms/tags-and-users.md index e0dbcfce4a..dd5195c7f3 100644 --- a/docs/en/tutorials-and-examples/cms/tags-and-users.md +++ b/docs/en/tutorials-and-examples/cms/tags-and-users.md @@ -250,6 +250,8 @@ method has not been implemented yet, so let's do that. In // the Query class use Cake\ORM\Query\SelectQuery; +use function Cake\Database\Expression\{in, isNull}; + // The $query argument is a query builder instance. // The $options array will contain the 'tags' option we passed // to find('tagged') in our controller action. @@ -268,11 +270,11 @@ public function findTagged(SelectQuery $query, array $tags = []): SelectQuery if (!$tags) { // If there are no tags provided, find articles that have no tags. $query->leftJoinWith('Tags') - ->where(['Tags.title IS' => null]); + ->where(['Tags.title' => isNull()]); } else { // Find articles that have one or more of the provided tags. $query->innerJoinWith('Tags') - ->where(['Tags.title IN' => $tags]); + ->where(['Tags.title' => in($tags)]); } return $query->groupBy(['Articles.id']); @@ -444,7 +446,7 @@ protected function _buildTags(string $tagString): array $out = []; $tags = $this->Tags->find() - ->where(['Tags.title IN' => $newTags]) + ->where(['Tags.title' => in($newTags)]) ->all(); // Remove existing tags from the list of new tags.