Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 101 additions & 35 deletions src/Phaseolies/Database/Entity/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -551,18 +551,20 @@ private function handleRelationshipExists(string $relation, ?callable $callback,
$relationType = $model->getLastRelationType();
$relatedModel = $model->getLastRelatedModel();

$bindings = [];

if ($relationType === 'bindToMany') {
$subquery = $this->buildManyToManySubquery($model, $relatedModel, $callback);
$subquery = $this->buildManyToManySubquery($model, $relatedModel, $callback, $bindings);
} else {
$subquery = $this->buildDirectRelationshipSubquery($model, $relatedModel, $callback);
$subquery = $this->buildDirectRelationshipSubquery($model, $relatedModel, $callback, $bindings);
}

$subquery .= ' LIMIT 1';

$this->conditions[] = [
'type' => $type,
'subquery' => $subquery,
'bindings' => [],
'bindings' => $bindings,
'boolean' => $boolean
];

Expand All @@ -575,9 +577,10 @@ private function handleRelationshipExists(string $relation, ?callable $callback,
* @param Model $model
* @param mixed $relatedModel
* @param callable|null $callback
* @param array $bindings
* @return string
*/
private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?callable $callback): string
private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?callable $callback, array &$bindings): string
{
$pivotTable = $model->getLastPivotTable();
$foreignKey = $model->getLastForeignKey();
Expand All @@ -602,7 +605,7 @@ private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?cal
$subquery .= " WHERE {$quote($pivotTable)}.{$quote($foreignKey)} = {$quote($this->table)}.{$quote($localKey)}";

if ($callback) {
$subquery = $this->addCallbackConditions($subquery, $relatedQuery, $relatedTable);
$subquery .= $this->compileCallbackConditions($relatedQuery, $relatedTable, $bindings);
}

if ($softDeleteConstraint !== null) {
Expand All @@ -618,9 +621,10 @@ private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?cal
* @param Model $model
* @param mixed $relatedModel
* @param callable|null $callback
* @param array $bindings
* @return string
*/
private function buildDirectRelationshipSubquery(Model $model, mixed $relatedModel, ?callable $callback): string
private function buildDirectRelationshipSubquery(Model $model, mixed $relatedModel, ?callable $callback, array &$bindings): string
{
$foreignKey = $model->getLastForeignKey();
$localKey = $model->getLastLocalKey();
Expand All @@ -634,7 +638,7 @@ private function buildDirectRelationshipSubquery(Model $model, mixed $relatedMod
WHERE {$quote($relatedTable)}.{$quote($foreignKey)} = {$quote($this->table)}.{$quote($localKey)}";

if ($callback) {
$subquery = $this->addCallbackConditions($subquery, $relatedQuery, $relatedTable);
$subquery .= $this->compileCallbackConditions($relatedQuery, $relatedTable, $bindings);
}

if (($softDeleteConstraint = $relatedQuery->compileSoftDeleteConstraint($relatedTable)) !== null) {
Expand Down Expand Up @@ -664,39 +668,52 @@ private function buildRelatedSubqueryScope(mixed $relatedModel, ?callable $callb
}

/**
* Add callback conditions to the subquery
* Compile the conditions a callback added to a related query into an
* " AND (...)" fragment for a hand-built subquery.
*
* @param string $subquery
* @param self $subQueryBuilder
* @param self $relatedQuery
* @param string $relatedTable
* @param array $bindings
* @return string
*/
private function addCallbackConditions(string $subquery, self $subQueryBuilder, string $relatedTable): string
private function compileCallbackConditions(self $relatedQuery, string $relatedTable, array &$bindings): string
{
$quote = fn($identifier) => $this->quoteIdentifier($identifier);
$escapeValue = fn($value) => $this->escapeValue($value);
$this->qualifyConditionColumns($relatedQuery, $relatedTable);

foreach ($subQueryBuilder->conditions as $condition) {
if (isset($condition['type'])) {
continue;
}
[$whereSql, $whereBindings] = $relatedQuery->buildConditionWhereClause();

$column = $condition[1];
$operator = $condition[2];
$value = $condition[3];
if ($whereSql === '') {
return '';
}

// Ensure column is properly qualified with table name if not already
if (strpos($column, '.') === false) {
$column = "{$quote($relatedTable)}.{$quote($column)}";
} else {
// Quote qualified column names
$column = $quote($column);
}
$bindings = array_merge($bindings, $whereBindings);

$subquery .= $this->buildConditionClause($column, $operator, $value, $escapeValue);
}
return " AND ({$whereSql})";
}

return $subquery;
/**
* Qualify the plain column names of a query's conditions, including
* those of nested groups, with the given table.
*
* @param self $query
* @param string $table
* @return void
*/
private function qualifyConditionColumns(self $query, string $table): void
{
foreach ($query->conditions as &$condition) {
$type = $condition['type'] ?? null;

if ($type === 'NESTED') {
$this->qualifyConditionColumns($condition['query'], $table);
} elseif ($type === null && is_string($condition[1] ?? null)) {
if (preg_match('/^[A-Za-z_]\w*$/', $condition[1])) {
$condition[1] = $this->quoteIdentifier("{$table}.{$condition[1]}");
} elseif (preg_match('/^[A-Za-z_]\w*\.[A-Za-z_]\w*$/', $condition[1])) {
$condition[1] = $this->quoteIdentifier($condition[1]);
}
}
}
}

/**
Expand All @@ -716,13 +733,13 @@ private function buildConditionClause(string $column, string $operator, $value,
} elseif ($operator === '!=') {
return " AND {$column} IS NOT NULL";
}
} elseif ($operator === 'IN') {
} elseif ($operator === 'IN' || $operator === 'NOT IN') {
if (empty($value)) {
// Empty IN clause is always false
return " AND 1=0";
// Empty IN clause is always false, empty NOT IN always true
return $operator === 'IN' ? " AND 1=0" : " AND 1=1";
} else {
$escapedValues = $escapeValue($value);
return " AND {$column} IN {$escapedValues}";
return " AND {$column} {$operator} {$escapedValues}";
}
} elseif ($operator === 'LIKE' || $operator === 'NOT LIKE') {
$escapedValue = $escapeValue($value);
Expand Down Expand Up @@ -1928,6 +1945,55 @@ public function orWhereIn(string $field, array $values): self
return $this;
}

/**
* Add a WHERE NOT IN condition
*
* An empty list excludes nothing.
*
* @param string $field
* @param array $values
* @return self
*/
public function whereNotIn(string $field, array $values): self
{
return $this->addNotInCondition('AND', $field, $values);
}

/**
* Add a OR WHERE NOT IN condition
*
* @param string $field
* @param array $values
* @return self
*/
public function orWhereNotIn(string $field, array $values): self
{
return $this->addNotInCondition('OR', $field, $values);
}

/**
* Add a NOT IN condition with the given boolean
*
* @param string $boolean
* @param string $field
* @param array $values
* @return self
*/
private function addNotInCondition(string $boolean, string $field, array $values): self
{
$this->assertValidIdentifier($field);

if (strpos($field, '.') === false) {
$field = "{$this->tableReference()}.{$field}";
}

$values = array_values($values);
$placeholders = implode(',', array_fill(0, count($values), '?'));
$this->conditions[] = [$boolean, $field, 'NOT IN', $values, "($placeholders)"];

return $this;
}


/**
* Load one-to-one relationships
Expand Down Expand Up @@ -2334,7 +2400,7 @@ protected function bindNestedValues(PDOStatement $stmt, self $nestedQuery, int &
if (in_array($nestedCondition[2], ['BETWEEN', 'NOT BETWEEN'])) {
$stmt->bindValue($index++, $nestedCondition[3], $this->getPdoParamType($nestedCondition[3]));
$stmt->bindValue($index++, $nestedCondition[4], $this->getPdoParamType($nestedCondition[4]));
} elseif ($nestedCondition[2] === 'IN') {
} elseif (in_array($nestedCondition[2], ['IN', 'NOT IN'])) {
foreach ($nestedCondition[3] as $value) {
$stmt->bindValue($index++, $value, $this->getPdoParamType($value));
}
Expand Down
2 changes: 1 addition & 1 deletion src/Phaseolies/Database/Entity/Query/Debuggable.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ protected function getBindings(): array
continue;
}

if ($condition[2] === 'IN') {
if (in_array($condition[2], ['IN', 'NOT IN'])) {
$bindings = array_merge($bindings, $condition[3]);
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,13 @@ protected function buildCondition(array $condition): array
break;

case 'IN':
case 'NOT IN':
if (empty($value)) {
$sql = "1=0"; // Empty IN clause is always false
// Empty IN clause is always false, empty NOT IN always true
$sql = $operator === 'IN' ? "1=0" : "1=1";
} else {
$placeholders = implode(',', array_fill(0, count($value), '?'));
$sql = "{$column} IN ({$placeholders})";
$sql = "{$column} {$operator} ({$placeholders})";
$bindings = $value;
}
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ private function handleNestedRelationshipExists(string $nestedRelation, ?callabl
$relations = explode('.', $nestedRelation);
$model = $this->getModel();

$subquery = $this->buildNestedRelationshipExistsSubquery($model, $relations, $callback);
$bindings = [];

$subquery = $this->buildNestedRelationshipExistsSubquery($model, $relations, $callback, $bindings);
$subquery .= ' LIMIT 1';

$this->conditions[] = [
'type' => $type,
'subquery' => $subquery,
'bindings' => [],
'bindings' => $bindings,
'boolean' => $boolean
];

Expand All @@ -40,12 +42,12 @@ private function handleNestedRelationshipExists(string $nestedRelation, ?callabl
* @param Model $model
* @param array $relations
* @param callable|null $callback
* @param array $bindings
* @return string
*/
private function buildNestedRelationshipExistsSubquery(Model $model, array $relations, ?callable $callback): string
private function buildNestedRelationshipExistsSubquery(Model $model, array $relations, ?callable $callback, array &$bindings): string
{
$quote = fn($identifier) => $this->quoteIdentifier($identifier);
$escapeValue = fn($val) => $this->escapeValue($val);

$subqueryParts = [];
$currentModel = $model;
Expand Down Expand Up @@ -130,24 +132,7 @@ private function buildNestedRelationshipExistsSubquery(Model $model, array $rela
if ($callback && $previousTable) {
$callback($subQueryBuilder);

foreach ($subQueryBuilder->conditions as $condition) {
if (isset($condition['type'])) {
continue;
}

$column = $condition[1];
$operator = $condition[2];
$value = $condition[3];

// Qualify column with table name
if (strpos($column, '.') === false) {
$column = "{$quote($previousTable)}.{$quote($column)}";
} else {
$column = $quote($column);
}

$subquery .= $this->buildConditionClause($column, $operator, $value, $escapeValue);
}
$subquery .= $this->compileCallbackConditions($subQueryBuilder, $previousTable, $bindings);
}

if (($softDeleteConstraint = $subQueryBuilder->compileSoftDeleteConstraint($previousTable)) !== null) {
Expand Down
84 changes: 84 additions & 0 deletions tests/Model/Query/EntityModelQueryBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,90 @@ public function testWhereIn(): void
$this->assertCount(3, $users);
}

public function testWhereNotIn(): void
{
$total = MockUser::query()->count();

$users = MockUser::whereNotIn('id', [1, 2])->get();

$this->assertCount($total - 2, $users);
$this->assertNotContains(1, array_column($users->toArray(), 'id'));
$this->assertNotContains(2, array_column($users->toArray(), 'id'));

// Values that match nothing exclude nothing
$this->assertCount($total, MockUser::whereNotIn('id', [100, 101])->get());
$this->assertSame($total, MockUser::whereNotIn('id', [100])->count());

// A list with gaps in its keys still binds every value
$this->assertCount($total - 2, MockUser::whereNotIn('id', [3 => 1, 7 => 2])->get());
}

public function testWhereNotInWithAnEmptyListExcludesNothing(): void
{
$total = MockUser::query()->count();

$this->assertCount($total, MockUser::whereNotIn('id', [])->get());
$this->assertCount(1, MockUser::where('id', 1)->whereNotIn('id', [])->get());
}

public function testOrWhereNotIn(): void
{
$total = MockUser::query()->count();

// id = 1 OR id NOT IN (1, 2, 3) => everyone except users 2 and 3
$users = MockUser::where('id', 1)->orWhereNotIn('id', [1, 2, 3])->get();

$this->assertCount($total - 2, $users);
$this->assertContains(1, array_column($users->toArray(), 'id'));

// An empty list in an OR group matches every row
$this->assertCount($total, MockUser::where('id', 1)->orWhereNotIn('id', [])->get());
}

public function testWhereNotInCombinesWithOtherConditionsAndBindingsKeepTheirOrder(): void
{
$users = MockUser::where('id', '>', 0)
->whereNotIn('id', [1])
->whereIn('id', [1, 2, 3])
->where('id', '<', 3)
->get();

$this->assertSame([2], array_map('intval', array_column($users->toArray(), 'id')));
}

public function testWhereNotInInsideANestedGroupAndWithAggregatesAndBulkWrites(): void
{
$total = MockUser::query()->count();

$this->assertSame(
$total - 1,
MockUser::where(fn($q) => $q->whereNotIn('id', [1])->orWhereNotIn('id', [1]))->count()
);

$this->assertSame(
(int) MockUser::query()->sum('id') - 1,
(int) MockUser::whereNotIn('id', [1])->sum('id')
);

MockUser::whereNotIn('id', [1])->update(['name' => 'Not In Updated']);

$this->assertSame($total - 1, MockUser::where('name', 'Not In Updated')->count());
$this->assertNotSame('Not In Updated', MockUser::find(1)->name);
}

public function testWhereNotInIsQualifiedWhenJoining(): void
{
// posts also has an id column, so an unqualified NOT IN would be ambiguous
$rows = MockUser::query()
->select('users.id')
->join('posts', 'users.id', '=', 'posts.user_id')
->whereNotIn('id', [2])
->get();

$this->assertNotEmpty($rows->toArray());
$this->assertNotContains(2, array_map('intval', array_column($rows->toArray(), 'id')));
}

public function testWhereBetween()
{
$users = MockUser::query()
Expand Down
Loading
Loading