Skip to content

Commit f82bb3f

Browse files
committed
Improve query builder type safety and binding management
- Restructure bindings array to use typed categories (where, whereIn, whereNotIn, etc.) instead of flat array - Add getCompiledBindings() method to ensure correct parameter order during query execution - Fix WHERE clause construction to properly handle empty conditions - Add orWhereNested() method for complex nested OR conditions - Enhance whereGroup() implementation to use proper SQL generation - Add comprehensive test coverage for advanced expressions, subqueries, and nested conditions - Improve binding order consistency across all query types (SELECT, UPDATE, DELETE)
1 parent f197b5b commit f82bb3f

File tree

2 files changed

+318
-54
lines changed

2 files changed

+318
-54
lines changed

src/QueryBuilder/AsyncQueryBuilder.php

Lines changed: 105 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class AsyncQueryBuilder
8383
* @var array<string> Raw OR WHERE conditions.
8484
*/
8585
protected array $orWhereRaw = [];
86-
86+
8787
/**
8888
* @var array<string> The GROUP BY clauses for the query.
8989
*/
@@ -110,9 +110,18 @@ class AsyncQueryBuilder
110110
protected ?int $offset = null;
111111

112112
/**
113-
* @var array<mixed> The parameter bindings for the query.
113+
* @var array<string, array<mixed>> The parameter bindings for the query, grouped by type.
114114
*/
115-
protected array $bindings = [];
115+
protected array $bindings = [
116+
'where' => [],
117+
'whereIn' => [],
118+
'whereNotIn' => [],
119+
'whereBetween' => [],
120+
'whereRaw' => [],
121+
'orWhere' => [],
122+
'orWhereRaw' => [],
123+
'having' => [],
124+
];
116125

117126
/**
118127
* @var int The current binding index counter.
@@ -225,7 +234,7 @@ public function where(string $column, mixed $operator = null, mixed $value = nul
225234

226235
$placeholder = $this->getPlaceholder();
227236
$this->where[] = "{$column} {$operator} {$placeholder}";
228-
$this->bindings[] = $value;
237+
$this->bindings['where'][] = $value;
229238

230239
return $this;
231240
}
@@ -252,7 +261,7 @@ public function orWhere(string $column, mixed $operator = null, mixed $value = n
252261

253262
$placeholder = $this->getPlaceholder();
254263
$this->orWhere[] = "{$column} {$operator} {$placeholder}";
255-
$this->bindings[] = $value;
264+
$this->bindings['orWhere'][] = $value;
256265

257266
return $this;
258267
}
@@ -269,7 +278,7 @@ public function whereIn(string $column, array $values): self
269278
$placeholders = [];
270279
foreach ($values as $value) {
271280
$placeholders[] = $this->getPlaceholder();
272-
$this->bindings[] = $value;
281+
$this->bindings['whereIn'][] = $value;
273282
}
274283
$this->whereIn[] = "{$column} IN (" . implode(', ', $placeholders) . ')';
275284

@@ -288,7 +297,7 @@ public function whereNotIn(string $column, array $values): self
288297
$placeholders = [];
289298
foreach ($values as $value) {
290299
$placeholders[] = $this->getPlaceholder();
291-
$this->bindings[] = $value;
300+
$this->bindings['whereNotIn'][] = $value;
292301
}
293302
$this->whereNotIn[] = "{$column} NOT IN (" . implode(', ', $placeholders) . ')';
294303

@@ -312,8 +321,8 @@ public function whereBetween(string $column, array $values): self
312321
$placeholder1 = $this->getPlaceholder();
313322
$placeholder2 = $this->getPlaceholder();
314323
$this->whereBetween[] = "{$column} BETWEEN {$placeholder1} AND {$placeholder2}";
315-
$this->bindings[] = $values[0];
316-
$this->bindings[] = $values[1];
324+
$this->bindings['whereBetween'][] = $values[0];
325+
$this->bindings['whereBetween'][] = $values[1];
317326

318327
return $this;
319328
}
@@ -364,7 +373,7 @@ public function like(string $column, string $value, string $side = 'both'): self
364373
default => $value
365374
};
366375

367-
$this->bindings[] = $likeValue;
376+
$this->bindings['where'][] = $likeValue;
368377

369378
return $this;
370379
}
@@ -407,7 +416,7 @@ public function having(string $column, mixed $operator = null, mixed $value = nu
407416

408417
$placeholder = $this->getPlaceholder();
409418
$this->having[] = "{$column} {$operator} {$placeholder}";
410-
$this->bindings[] = $value;
419+
$this->bindings['having'][] = $value;
411420

412421
return $this;
413422
}
@@ -465,7 +474,7 @@ public function get(): PromiseInterface
465474
{
466475
$sql = $this->buildSelectQuery();
467476

468-
return AsyncPDO::query($sql, $this->bindings);
477+
return AsyncPDO::query($sql, $this->getCompiledBindings());
469478
}
470479

471480
/**
@@ -480,7 +489,7 @@ public function first(): PromiseInterface
480489
$sql = $this->buildSelectQuery();
481490
$this->limit = $originalLimit;
482491

483-
return AsyncPDO::fetchOne($sql, $this->bindings);
492+
return AsyncPDO::fetchOne($sql, $this->getCompiledBindings());
484493
}
485494

486495
/**
@@ -546,7 +555,7 @@ public function count(string $column = '*'): PromiseInterface
546555
{
547556
$sql = $this->buildCountQuery($column);
548557

549-
return AsyncPDO::fetchValue($sql, $this->bindings);
558+
return AsyncPDO::fetchValue($sql, $this->getCompiledBindings());
550559
}
551560

552561
/**
@@ -620,7 +629,8 @@ public function create(array $data): PromiseInterface
620629
public function update(array $data): PromiseInterface
621630
{
622631
$sql = $this->buildUpdateQuery($data);
623-
$bindings = array_merge(array_values($data), $this->bindings);
632+
$whereBindings = $this->getCompiledBindings();
633+
$bindings = array_merge(array_values($data), $whereBindings);
624634

625635
return AsyncPDO::execute($sql, $bindings);
626636
}
@@ -634,7 +644,7 @@ public function delete(): PromiseInterface
634644
{
635645
$sql = $this->buildDeleteQuery();
636646

637-
return AsyncPDO::execute($sql, $this->bindings);
647+
return AsyncPDO::execute($sql, $this->getCompiledBindings());
638648
}
639649

640650
/**
@@ -689,7 +699,10 @@ protected function buildSelectQuery(): string
689699
}
690700

691701
// Add where clauses
692-
$sql .= $this->buildWhereClause();
702+
$whereSql = $this->buildWhereClause();
703+
if ($whereSql !== '') {
704+
$sql .= ' WHERE ' . $whereSql;
705+
}
693706

694707
// Add group by
695708
if (count($this->groupBy) > 0) {
@@ -733,7 +746,10 @@ protected function buildCountQuery(string $column = '*'): string
733746
}
734747

735748
// Add where clauses
736-
$sql .= $this->buildWhereClause();
749+
$whereSql = $this->buildWhereClause();
750+
if ($whereSql !== '') {
751+
$sql .= ' WHERE ' . $whereSql;
752+
}
737753

738754
// Add group by
739755
if (count($this->groupBy) > 0) {
@@ -797,7 +813,10 @@ protected function buildUpdateQuery(array $data): string
797813
$setClauses[] = "{$column} = ?";
798814
}
799815
$sql = "UPDATE {$this->table} SET " . implode(', ', $setClauses);
800-
$sql .= $this->buildWhereClause();
816+
$whereSql = $this->buildWhereClause();
817+
if ($whereSql !== '') {
818+
$sql .= ' WHERE ' . $whereSql;
819+
}
801820

802821
return $sql;
803822
}
@@ -810,7 +829,10 @@ protected function buildUpdateQuery(array $data): string
810829
protected function buildDeleteQuery(): string
811830
{
812831
$sql = "DELETE FROM {$this->table}";
813-
$sql .= $this->buildWhereClause();
832+
$whereSql = $this->buildWhereClause();
833+
if ($whereSql !== '') {
834+
$sql .= ' WHERE ' . $whereSql;
835+
}
814836

815837
return $sql;
816838
}
@@ -828,7 +850,7 @@ protected function buildWhereClause(): string
828850
return '';
829851
}
830852

831-
return ' WHERE ' . $this->combineConditionParts($allParts);
853+
return $this->combineConditionParts($allParts);
832854
}
833855

834856
/**
@@ -1032,24 +1054,18 @@ protected function getAllConditions(): array
10321054
*/
10331055
public function whereGroup(callable $callback, string $logicalOperator = 'AND'): self
10341056
{
1035-
$subBuilder = new static();
1057+
$subBuilder = new static($this->table);
10361058
$callback($subBuilder);
10371059

1038-
$subConditions = $subBuilder->getAllConditions();
1039-
$hasConditions = !empty($subConditions['AND']) || !empty($subConditions['OR']);
1040-
1041-
if ($hasConditions) {
1042-
$this->conditionGroups[] = [
1043-
'type' => 'group',
1044-
'conditions' => $subConditions,
1045-
'operator' => strtoupper($logicalOperator),
1046-
'bindings' => $subBuilder->bindings
1047-
];
1060+
$subSql = $subBuilder->buildWhereClause();
10481061

1049-
// Merge bindings
1050-
$this->bindings = array_merge($this->bindings, $subBuilder->bindings);
1062+
// If the subquery is empty, do nothing.
1063+
if ($subSql === '') {
1064+
return $this;
10511065
}
10521066

1067+
$this->whereRaw("({$subSql})", $subBuilder->getCompiledBindings(), $logicalOperator);
1068+
10531069
return $this;
10541070
}
10551071

@@ -1077,12 +1093,12 @@ public function whereRaw(string $condition, array $bindings = [], string $operat
10771093
{
10781094
if (strtoupper($operator) === 'OR') {
10791095
$this->orWhereRaw[] = $condition;
1096+
$this->bindings['orWhereRaw'] = array_merge($this->bindings['orWhereRaw'], $bindings);
10801097
} else {
10811098
$this->whereRaw[] = $condition;
1099+
$this->bindings['whereRaw'] = array_merge($this->bindings['whereRaw'], $bindings);
10821100
}
10831101

1084-
$this->bindings = array_merge($this->bindings, $bindings);
1085-
10861102
return $this;
10871103
}
10881104

@@ -1113,7 +1129,7 @@ public function whereExists(callable $callback, string $operator = 'AND'): self
11131129
$subSql = $subBuilder->buildSelectQuery();
11141130
$condition = "EXISTS ({$subSql})";
11151131

1116-
return $this->whereRaw($condition, $subBuilder->bindings, $operator);
1132+
return $this->whereRaw($condition, $subBuilder->getCompiledBindings(), $operator);
11171133
}
11181134

11191135
/**
@@ -1131,7 +1147,18 @@ public function whereNotExists(callable $callback, string $operator = 'AND'): se
11311147
$subSql = $subBuilder->buildSelectQuery();
11321148
$condition = "NOT EXISTS ({$subSql})";
11331149

1134-
return $this->whereRaw($condition, $subBuilder->bindings, $operator);
1150+
return $this->whereRaw($condition, $subBuilder->getCompiledBindings(), $operator);
1151+
}
1152+
1153+
/**
1154+
* Add a nested OR WHERE condition with custom logic.
1155+
*
1156+
* @param callable $callback Callback function for nested conditions.
1157+
* @return self Returns the query builder instance for method chaining.
1158+
*/
1159+
public function orWhereNested(callable $callback): self
1160+
{
1161+
return $this->whereGroup($callback, 'OR');
11351162
}
11361163

11371164
/**
@@ -1151,7 +1178,7 @@ public function toSql(): string
11511178
*/
11521179
public function getBindings(): array
11531180
{
1154-
return $this->bindings;
1181+
return $this->getCompiledBindings();
11551182
}
11561183

11571184
/**
@@ -1171,7 +1198,16 @@ public function resetWhere(): self
11711198
$this->whereRaw = [];
11721199
$this->orWhereRaw = [];
11731200
$this->conditionGroups = [];
1174-
$this->bindings = [];
1201+
$this->bindings = [
1202+
'where' => [],
1203+
'whereIn' => [],
1204+
'whereNotIn' => [],
1205+
'whereBetween' => [],
1206+
'whereRaw' => [],
1207+
'orWhere' => [],
1208+
'orWhereRaw' => [],
1209+
'having' => [],
1210+
];
11751211
$this->bindingIndex = 0;
11761212

11771213
return $this;
@@ -1186,4 +1222,33 @@ protected function getPlaceholder(): string
11861222
{
11871223
return '?';
11881224
}
1225+
1226+
/**
1227+
* Compiles the final bindings array in the correct order for execution.
1228+
*
1229+
* @return array<mixed>
1230+
*/
1231+
protected function getCompiledBindings(): array
1232+
{
1233+
// This merge order MUST match the order in `collectAllConditionParts()`
1234+
// and the order of the final query construction.
1235+
$whereBindings = array_merge(
1236+
$this->bindings['where'],
1237+
$this->bindings['whereIn'],
1238+
$this->bindings['whereNotIn'],
1239+
$this->bindings['whereBetween'],
1240+
$this->bindings['whereRaw'],
1241+
$this->bindings['orWhere'],
1242+
$this->bindings['orWhereRaw']
1243+
);
1244+
1245+
// This handles bindings from complex groups like whereGroup
1246+
foreach ($this->conditionGroups as $group) {
1247+
if (isset($group['bindings'])) {
1248+
$whereBindings = array_merge($whereBindings, $group['bindings']);
1249+
}
1250+
}
1251+
1252+
return array_merge($whereBindings, $this->bindings['having']);
1253+
}
11891254
}

0 commit comments

Comments
 (0)